#!/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 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" } # Read the list of declared files from config. # Outputs lines of "pathfield" declared_files() { jq -r '.files[] | "\(.path)\t\(.field)"' "$CONFIG" } # 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_json_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_json_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 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_json_field "$fullpath" "$field") write_json_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 | --check | --audit" echo "" echo " 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