| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282 |
- #!/usr/bin/env bash
- #
- # bump-version.sh — bump version numbers across all declared files,
- # with drift detection and repo-wide audit for missed files.
- #
- # Usage:
- # bump-version.sh <new-version> Bump all declared files to new version
- # bump-version.sh --check Report current versions (detect drift)
- # bump-version.sh --audit Check + grep repo for old version strings
- #
- set -euo pipefail
- SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
- REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
- CONFIG="$REPO_ROOT/.version-bump.json"
- if [[ ! -f "$CONFIG" ]]; then
- echo "error: .version-bump.json not found at $CONFIG" >&2
- exit 1
- fi
- # --- helpers ---
- # Read a dotted field path from a JSON file.
- # Handles both simple ("version") and nested ("plugins.0.version") paths.
- read_json_field() {
- local file="$1" field="$2"
- # Convert dot-path to jq path: "plugins.0.version" -> .plugins[0].version
- local jq_path
- jq_path=$(echo "$field" | sed -E 's/\.([0-9]+)/[\1]/g' | sed 's/^/./' | sed 's/\.\././g')
- jq -r "$jq_path" "$file"
- }
- # Write a dotted field path in a JSON file, preserving formatting.
- write_json_field() {
- local file="$1" field="$2" value="$3"
- local jq_path
- jq_path=$(echo "$field" | sed -E 's/\.([0-9]+)/[\1]/g' | sed 's/^/./' | sed 's/\.\././g')
- local tmp="${file}.tmp"
- jq "$jq_path = \"$value\"" "$file" > "$tmp" && mv "$tmp" "$file"
- }
- require_tool() {
- command -v "$1" >/dev/null 2>&1 || {
- echo "error: required tool '$1' is not on PATH" >&2
- return 1
- }
- }
- read_yaml_field() {
- local file="$1" field="$2"
- require_tool yq || return 1
- FIELD="$field" yq -er '.[strenv(FIELD)] | select(tag == "!!str")' "$file"
- }
- write_yaml_field() {
- local file="$1" field="$2" value="$3"
- FIELD="$field" VALUE="$value" \
- yq -i '.[strenv(FIELD)] = strenv(VALUE)' "$file"
- }
- read_manifest_field() {
- local file="$1"
- case "$file" in
- *.json) read_json_field "$@" ;;
- *.yaml) read_yaml_field "$@" ;;
- *)
- echo "error: unsupported manifest format: $file" >&2
- return 1
- ;;
- esac
- }
- write_manifest_field() {
- local file="$1"
- case "$file" in
- *.json) write_json_field "$@" ;;
- *.yaml) write_yaml_field "$@" ;;
- *)
- echo "error: unsupported manifest format: $file" >&2
- return 1
- ;;
- esac
- }
- # Read the list of declared files from config.
- # Outputs lines of "path<TAB>field"
- declared_files() {
- jq -r '.files[] | "\(.path)\t\(.field)"' "$CONFIG"
- }
- preflight_manifests() {
- local path field fullpath
- require_tool jq || return 1
- while IFS=$'\t' read -r path field; do
- fullpath="$REPO_ROOT/$path"
- [[ -f "$fullpath" ]] || continue
- if ! read_manifest_field "$fullpath" "$field" >/dev/null; then
- echo "error: cannot read declared manifest: $path ($field)" >&2
- return 1
- fi
- done < <(declared_files)
- }
- # Read the audit exclude patterns from config.
- audit_excludes() {
- jq -r '.audit.exclude[]' "$CONFIG" 2>/dev/null
- }
- # --- commands ---
- cmd_check() {
- local has_drift=0
- local versions=()
- echo "Version check:"
- echo ""
- while IFS=$'\t' read -r path field; do
- local fullpath="$REPO_ROOT/$path"
- if [[ ! -f "$fullpath" ]]; then
- printf " %-45s MISSING\n" "$path ($field)"
- has_drift=1
- continue
- fi
- local ver
- ver=$(read_manifest_field "$fullpath" "$field")
- printf " %-45s %s\n" "$path ($field)" "$ver"
- versions+=("$ver")
- done < <(declared_files)
- echo ""
- # Check if all versions match
- local unique
- unique=$(printf '%s\n' "${versions[@]}" | sort -u | wc -l | tr -d ' ')
- if [[ "$unique" -gt 1 ]]; then
- echo "DRIFT DETECTED — versions are not in sync:"
- printf '%s\n' "${versions[@]}" | sort | uniq -c | sort -rn | while read -r count ver; do
- echo " $ver ($count files)"
- done
- has_drift=1
- else
- echo "All declared files are in sync at ${versions[0]}"
- fi
- return $has_drift
- }
- cmd_audit() {
- # First run check
- cmd_check || true
- echo ""
- # Determine the current version (most common across declared files)
- local current_version
- current_version=$(
- while IFS=$'\t' read -r path field; do
- local fullpath="$REPO_ROOT/$path"
- [[ -f "$fullpath" ]] && read_manifest_field "$fullpath" "$field"
- done < <(declared_files) | sort | uniq -c | sort -rn | head -1 | awk '{print $2}'
- )
- if [[ -z "$current_version" ]]; then
- echo "error: could not determine current version" >&2
- return 1
- fi
- echo "Audit: scanning repo for version string '$current_version'..."
- echo ""
- # Build grep exclude args
- local -a exclude_args=()
- while IFS= read -r pattern; do
- exclude_args+=("--exclude=$pattern" "--exclude-dir=$pattern")
- done < <(audit_excludes)
- # Also always exclude binary files and .git
- exclude_args+=("--exclude-dir=.git" "--exclude-dir=node_modules" "--binary-files=without-match")
- # Get list of declared paths for comparison
- local -a declared_paths=()
- while IFS=$'\t' read -r path _field; do
- declared_paths+=("$path")
- done < <(declared_files)
- # Grep for the version string
- local found_undeclared=0
- while IFS= read -r match; do
- local match_file
- match_file=$(echo "$match" | cut -d: -f1)
- # Make path relative to repo root
- local rel_path="${match_file#$REPO_ROOT/}"
- # Check if this file is in the declared list
- local is_declared=0
- for dp in "${declared_paths[@]}"; do
- if [[ "$rel_path" == "$dp" ]]; then
- is_declared=1
- break
- fi
- done
- if [[ "$is_declared" -eq 0 ]]; then
- if [[ "$found_undeclared" -eq 0 ]]; then
- echo "UNDECLARED files containing '$current_version':"
- found_undeclared=1
- fi
- echo " $match"
- fi
- done < <(grep -rn "${exclude_args[@]}" -F "$current_version" "$REPO_ROOT" 2>/dev/null || true)
- if [[ "$found_undeclared" -eq 0 ]]; then
- echo "No undeclared files contain the version string. All clear."
- else
- echo ""
- echo "Review the above files — if they should be bumped, add them to .version-bump.json"
- echo "If they should be skipped, add them to the audit.exclude list."
- fi
- }
- cmd_bump() {
- local new_version="$1"
- # Validate semver-ish format
- if ! echo "$new_version" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+'; then
- echo "error: '$new_version' doesn't look like a version (expected X.Y.Z)" >&2
- exit 1
- fi
- preflight_manifests
- echo "Bumping all declared files to $new_version..."
- echo ""
- while IFS=$'\t' read -r path field; do
- local fullpath="$REPO_ROOT/$path"
- if [[ ! -f "$fullpath" ]]; then
- echo " SKIP (missing): $path"
- continue
- fi
- local old_ver
- old_ver=$(read_manifest_field "$fullpath" "$field")
- write_manifest_field "$fullpath" "$field" "$new_version"
- printf " %-45s %s -> %s\n" "$path ($field)" "$old_ver" "$new_version"
- done < <(declared_files)
- echo ""
- echo "Done. Running audit to check for missed files..."
- echo ""
- cmd_audit
- }
- # --- main ---
- case "${1:-}" in
- --check)
- cmd_check
- ;;
- --audit)
- cmd_audit
- ;;
- --help|-h|"")
- echo "Usage: bump-version.sh <new-version> | --check | --audit"
- echo ""
- echo " <new-version> Bump all declared files to the given version"
- echo " --check Show current versions, detect drift"
- echo " --audit Check + scan repo for undeclared version references"
- exit 0
- ;;
- --*)
- echo "error: unknown flag '$1'" >&2
- exit 1
- ;;
- *)
- cmd_bump "$1"
- ;;
- esac
|