bump-version.sh 7.3 KB

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