|
| 1 | +#!/usr/bin/env bash |
| 2 | +# |
| 3 | +# bump-version.sh — bump version numbers across all declared files, |
| 4 | +# with drift detection and repo-wide audit for missed files. |
| 5 | +# |
| 6 | +# Usage: |
| 7 | +# bump-version.sh <new-version> Bump all declared files to new version |
| 8 | +# bump-version.sh --check Report current versions (detect drift) |
| 9 | +# bump-version.sh --audit Check + grep repo for old version strings |
| 10 | +# |
| 11 | +set -euo pipefail |
| 12 | + |
| 13 | +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" |
| 14 | +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" |
| 15 | +CONFIG="$REPO_ROOT/.version-bump.json" |
| 16 | + |
| 17 | +if [[ ! -f "$CONFIG" ]]; then |
| 18 | + echo "error: .version-bump.json not found at $CONFIG" >&2 |
| 19 | + exit 1 |
| 20 | +fi |
| 21 | + |
| 22 | +# --- helpers --- |
| 23 | + |
| 24 | +# Read a dotted field path from a JSON file. |
| 25 | +# Handles both simple ("version") and nested ("plugins.0.version") paths. |
| 26 | +read_json_field() { |
| 27 | + local file="$1" field="$2" |
| 28 | + # Convert dot-path to jq path: "plugins.0.version" -> .plugins[0].version |
| 29 | + local jq_path |
| 30 | + jq_path=$(echo "$field" | sed -E 's/\.([0-9]+)/[\1]/g' | sed 's/^/./' | sed 's/\.\././g') |
| 31 | + jq -r "$jq_path" "$file" |
| 32 | +} |
| 33 | + |
| 34 | +# Write a dotted field path in a JSON file, preserving formatting. |
| 35 | +write_json_field() { |
| 36 | + local file="$1" field="$2" value="$3" |
| 37 | + local jq_path |
| 38 | + jq_path=$(echo "$field" | sed -E 's/\.([0-9]+)/[\1]/g' | sed 's/^/./' | sed 's/\.\././g') |
| 39 | + local tmp="${file}.tmp" |
| 40 | + jq "$jq_path = \"$value\"" "$file" > "$tmp" && mv "$tmp" "$file" |
| 41 | +} |
| 42 | + |
| 43 | +# Read the list of declared files from config. |
| 44 | +# Outputs lines of "path<TAB>field" |
| 45 | +declared_files() { |
| 46 | + jq -r '.files[] | "\(.path)\t\(.field)"' "$CONFIG" |
| 47 | +} |
| 48 | + |
| 49 | +# Read the audit exclude patterns from config. |
| 50 | +audit_excludes() { |
| 51 | + jq -r '.audit.exclude[]' "$CONFIG" 2>/dev/null |
| 52 | +} |
| 53 | + |
| 54 | +# --- commands --- |
| 55 | + |
| 56 | +cmd_check() { |
| 57 | + local has_drift=0 |
| 58 | + local versions=() |
| 59 | + |
| 60 | + echo "Version check:" |
| 61 | + echo "" |
| 62 | + |
| 63 | + while IFS=$'\t' read -r path field; do |
| 64 | + local fullpath="$REPO_ROOT/$path" |
| 65 | + if [[ ! -f "$fullpath" ]]; then |
| 66 | + printf " %-45s MISSING\n" "$path ($field)" |
| 67 | + has_drift=1 |
| 68 | + continue |
| 69 | + fi |
| 70 | + local ver |
| 71 | + ver=$(read_json_field "$fullpath" "$field") |
| 72 | + printf " %-45s %s\n" "$path ($field)" "$ver" |
| 73 | + versions+=("$ver") |
| 74 | + done < <(declared_files) |
| 75 | + |
| 76 | + echo "" |
| 77 | + |
| 78 | + # Check if all versions match |
| 79 | + local unique |
| 80 | + unique=$(printf '%s\n' "${versions[@]}" | sort -u | wc -l | tr -d ' ') |
| 81 | + if [[ "$unique" -gt 1 ]]; then |
| 82 | + echo "DRIFT DETECTED — versions are not in sync:" |
| 83 | + printf '%s\n' "${versions[@]}" | sort | uniq -c | sort -rn | while read -r count ver; do |
| 84 | + echo " $ver ($count files)" |
| 85 | + done |
| 86 | + has_drift=1 |
| 87 | + else |
| 88 | + echo "All declared files are in sync at ${versions[0]}" |
| 89 | + fi |
| 90 | + |
| 91 | + return $has_drift |
| 92 | +} |
| 93 | + |
| 94 | +cmd_audit() { |
| 95 | + # First run check |
| 96 | + cmd_check || true |
| 97 | + echo "" |
| 98 | + |
| 99 | + # Determine the current version (most common across declared files) |
| 100 | + local current_version |
| 101 | + current_version=$( |
| 102 | + while IFS=$'\t' read -r path field; do |
| 103 | + local fullpath="$REPO_ROOT/$path" |
| 104 | + [[ -f "$fullpath" ]] && read_json_field "$fullpath" "$field" |
| 105 | + done < <(declared_files) | sort | uniq -c | sort -rn | head -1 | awk '{print $2}' |
| 106 | + ) |
| 107 | + |
| 108 | + if [[ -z "$current_version" ]]; then |
| 109 | + echo "error: could not determine current version" >&2 |
| 110 | + return 1 |
| 111 | + fi |
| 112 | + |
| 113 | + echo "Audit: scanning repo for version string '$current_version'..." |
| 114 | + echo "" |
| 115 | + |
| 116 | + # Build grep exclude args |
| 117 | + local -a exclude_args=() |
| 118 | + while IFS= read -r pattern; do |
| 119 | + exclude_args+=("--exclude=$pattern" "--exclude-dir=$pattern") |
| 120 | + done < <(audit_excludes) |
| 121 | + |
| 122 | + # Also always exclude binary files and .git |
| 123 | + exclude_args+=("--exclude-dir=.git" "--exclude-dir=node_modules" "--binary-files=without-match") |
| 124 | + |
| 125 | + # Get list of declared paths for comparison |
| 126 | + local -a declared_paths=() |
| 127 | + while IFS=$'\t' read -r path _field; do |
| 128 | + declared_paths+=("$path") |
| 129 | + done < <(declared_files) |
| 130 | + |
| 131 | + # Grep for the version string |
| 132 | + local found_undeclared=0 |
| 133 | + while IFS= read -r match; do |
| 134 | + local match_file |
| 135 | + match_file=$(echo "$match" | cut -d: -f1) |
| 136 | + # Make path relative to repo root |
| 137 | + local rel_path="${match_file#$REPO_ROOT/}" |
| 138 | + |
| 139 | + # Check if this file is in the declared list |
| 140 | + local is_declared=0 |
| 141 | + for dp in "${declared_paths[@]}"; do |
| 142 | + if [[ "$rel_path" == "$dp" ]]; then |
| 143 | + is_declared=1 |
| 144 | + break |
| 145 | + fi |
| 146 | + done |
| 147 | + |
| 148 | + if [[ "$is_declared" -eq 0 ]]; then |
| 149 | + if [[ "$found_undeclared" -eq 0 ]]; then |
| 150 | + echo "UNDECLARED files containing '$current_version':" |
| 151 | + found_undeclared=1 |
| 152 | + fi |
| 153 | + echo " $match" |
| 154 | + fi |
| 155 | + done < <(grep -rn "${exclude_args[@]}" -F "$current_version" "$REPO_ROOT" 2>/dev/null || true) |
| 156 | + |
| 157 | + if [[ "$found_undeclared" -eq 0 ]]; then |
| 158 | + echo "No undeclared files contain the version string. All clear." |
| 159 | + else |
| 160 | + echo "" |
| 161 | + echo "Review the above files — if they should be bumped, add them to .version-bump.json" |
| 162 | + echo "If they should be skipped, add them to the audit.exclude list." |
| 163 | + fi |
| 164 | +} |
| 165 | + |
| 166 | +cmd_bump() { |
| 167 | + local new_version="$1" |
| 168 | + |
| 169 | + # Validate semver-ish format |
| 170 | + if ! echo "$new_version" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+'; then |
| 171 | + echo "error: '$new_version' doesn't look like a version (expected X.Y.Z)" >&2 |
| 172 | + exit 1 |
| 173 | + fi |
| 174 | + |
| 175 | + echo "Bumping all declared files to $new_version..." |
| 176 | + echo "" |
| 177 | + |
| 178 | + while IFS=$'\t' read -r path field; do |
| 179 | + local fullpath="$REPO_ROOT/$path" |
| 180 | + if [[ ! -f "$fullpath" ]]; then |
| 181 | + echo " SKIP (missing): $path" |
| 182 | + continue |
| 183 | + fi |
| 184 | + local old_ver |
| 185 | + old_ver=$(read_json_field "$fullpath" "$field") |
| 186 | + write_json_field "$fullpath" "$field" "$new_version" |
| 187 | + printf " %-45s %s -> %s\n" "$path ($field)" "$old_ver" "$new_version" |
| 188 | + done < <(declared_files) |
| 189 | + |
| 190 | + echo "" |
| 191 | + echo "Done. Running audit to check for missed files..." |
| 192 | + echo "" |
| 193 | + cmd_audit |
| 194 | +} |
| 195 | + |
| 196 | +# --- main --- |
| 197 | + |
| 198 | +case "${1:-}" in |
| 199 | + --check) |
| 200 | + cmd_check |
| 201 | + ;; |
| 202 | + --audit) |
| 203 | + cmd_audit |
| 204 | + ;; |
| 205 | + --help|-h|"") |
| 206 | + echo "Usage: bump-version.sh <new-version> | --check | --audit" |
| 207 | + echo "" |
| 208 | + echo " <new-version> Bump all declared files to the given version" |
| 209 | + echo " --check Show current versions, detect drift" |
| 210 | + echo " --audit Check + scan repo for undeclared version references" |
| 211 | + exit 0 |
| 212 | + ;; |
| 213 | + --*) |
| 214 | + echo "error: unknown flag '$1'" >&2 |
| 215 | + exit 1 |
| 216 | + ;; |
| 217 | + *) |
| 218 | + cmd_bump "$1" |
| 219 | + ;; |
| 220 | +esac |
0 commit comments