feat: bump-version.sh v2 — preview, confirm, undo, profiles, help

Complete rewrite with safety features:
- Version profiles: groups files by version, only updates the current
  version (ignores unrelated X.Y.Z strings in the project)
- Preview: shows exact line-by-line diff before applying changes
- Confirmation: asks y/N before modifying files (--yes to skip)
- Undo: saves rollback info, ./bump-version.sh undo to revert
- --help: full usage documentation with examples
- Only matches version DECLARATIONS (requires keyword context like
  "version":, version=, VERSION=, v prefix) — won't match random
  numbers like dates, IPs, or port numbers

https://claude.ai/code/session_01SWSwDfMVij53bCTNSCLMwn
This commit is contained in:
Claude
2026-03-28 12:43:32 +00:00
parent 863e637642
commit eb2e946673
+252 -52
View File
@@ -1,8 +1,9 @@
#!/bin/bash
#
# Version Bump Script — works with any project
# bump-version.sh — Universal version bump tool
#
# Finds version strings in any file format and updates them together.
# Finds version strings in any project, shows what will change,
# asks for confirmation, and updates them. Supports undo.
#
# Supported patterns:
# JSON: "version": "1.2.3"
@@ -15,11 +16,13 @@
# setup.cfg: version = 1.2.3
#
# Usage:
# ./bump-version.sh # interactive
# ./bump-version.sh 1.2.3 # set all to 1.2.3
# ./bump-version.sh patch # bump patch: 0.9.0 → 0.9.1
# ./bump-version.sh minor # bump minor: 0.9.0 → 0.10.0
# ./bump-version.sh major # bump major: 0.9.0 → 1.0.0
# ./bump-version.sh # interactive
# ./bump-version.sh patch # bump patch: 0.9.0 → 0.9.1
# ./bump-version.sh minor # bump minor: 0.9.0 → 0.10.0
# ./bump-version.sh major # bump major: 0.9.0 → 1.0.0
# ./bump-version.sh 1.2.3 # set all to 1.2.3
# ./bump-version.sh undo # revert last bump
# ./bump-version.sh --help # show help
#
set -e
@@ -27,34 +30,76 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"
UNDO_FILE="$SCRIPT_DIR/.version-bump-undo"
# Colors
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
RED='\033[0;31m'
DIM='\033[2m'
BOLD='\033[1m'
NC='\033[0m'
# Directories to skip
SKIP_DIRS="node_modules|dist|build|\.git|__pycache__|\.venv|venv|\.egg-info|vendor"
# File extensions to search
SEARCH_EXTS="json|toml|yaml|yml|py|php|sh|bash|html|js|css|cfg|ini|txt|md|xml|plist|gradle|gemspec|podspec|csproj|props"
# --- Help ---
show_help() {
cat << 'HELPEOF'
bump-version.sh — Universal version bump tool
# Find all files that might contain version strings
USAGE:
./bump-version.sh Interactive mode
./bump-version.sh patch Bump patch: 0.9.0 → 0.9.1
./bump-version.sh minor Bump minor: 0.9.0 → 0.10.0
./bump-version.sh major Bump major: 0.9.0 → 1.0.0
./bump-version.sh 1.2.3 Set to exact version
./bump-version.sh undo Revert last version bump
./bump-version.sh --help Show this help
HOW IT WORKS:
1. Scans all text files for version-like patterns
2. Groups files by version number (the "version profile")
3. Only updates files matching the most common version
4. Shows a preview of all changes before applying
5. Asks for confirmation (unless --yes flag is used)
6. Saves undo information for easy rollback
SUPPORTED PATTERNS:
"version": "1.2.3" JSON (package.json, manifest.json, etc.)
version = "1.2.3" TOML, Python (pyproject.toml, setup.py)
version: 1.2.3 YAML (helm charts, GitHub Actions)
__version__ = "1.2.3" Python packages
Version: 1.2.3 PHP/WordPress plugin/theme headers
VERSION="1.2.3" Shell scripts
v1.2.3 HTML footers, display strings
SAFETY:
- Only updates files matching the current version (ignores unrelated X.Y.Z)
- Shows all changes before applying and asks for confirmation
- Saves undo info so you can revert with ./bump-version.sh undo
- Skips node_modules, dist, build, .git, __pycache__, venv, vendor
- Skips binary files and files over 500KB
- Skips itself (won't match examples in this help text)
EXAMPLES:
./bump-version.sh # shows versions, pick what to do
./bump-version.sh patch # quick patch bump, still confirms
./bump-version.sh 1.0.0 --yes # set to 1.0.0 without confirmation
./bump-version.sh undo # oops, go back
HELPEOF
exit 0
}
# --- Find version files ---
find_version_files() {
local files=""
# Use find + grep for maximum compatibility
while IFS= read -r file; do
[ -z "$file" ] && continue
# Skip binary files and large files
[ ! -f "$file" ] && continue
local size=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file" 2>/dev/null || echo 0)
[ "$size" -gt 500000 ] && continue
files="$files
$file"
echo "$file"
done < <(find . -type f \( \
-name "*.json" -o -name "*.toml" -o -name "*.yaml" -o -name "*.yml" \
-o -name "*.py" -o -name "*.php" -o -name "*.sh" -o -name "*.bash" \
@@ -66,11 +111,11 @@ $file"
-o -name "setup.py" -o -name "setup.cfg" -o -name "pyproject.toml" \
-o -name "package.json" -o -name "composer.json" -o -name "Gemfile" \
\) 2>/dev/null | grep -Ev "$SKIP_DIRS" | grep -v "bump-version")
echo "$files"
}
# Extract version from a file — tries all known patterns
# --- Extract version from file ---
# Only matches version DECLARATIONS, not arbitrary X.Y.Z in text.
# Each pattern requires a keyword context (version, VERSION, __version__, etc.)
get_version_from_file() {
local file="$1"
local ver=""
@@ -83,28 +128,24 @@ get_version_from_file() {
ver=$(grep -oP '(?:__)?version\s*=\s*["\x27]\K[0-9]+\.[0-9]+\.[0-9]+' "$file" 2>/dev/null | head -1)
[ -n "$ver" ] && echo "$ver" && return
# YAML: version: X.Y.Z or version: "X.Y.Z"
# YAML: version: X.Y.Z (must be at start of line)
ver=$(grep -oP '^version:\s*["\x27]?\K[0-9]+\.[0-9]+\.[0-9]+' "$file" 2>/dev/null | head -1)
[ -n "$ver" ] && echo "$ver" && return
# PHP/WordPress: Version: X.Y.Z (in comment header)
# PHP/WordPress: Version: X.Y.Z
ver=$(grep -oP 'Version:\s*\K[0-9]+\.[0-9]+\.[0-9]+' "$file" 2>/dev/null | head -1)
[ -n "$ver" ] && echo "$ver" && return
# Shell: VERSION="X.Y.Z" or VERSION='X.Y.Z'
# Shell: VERSION="X.Y.Z"
ver=$(grep -oP 'VERSION\s*=\s*["\x27]?\K[0-9]+\.[0-9]+\.[0-9]+' "$file" 2>/dev/null | head -1)
[ -n "$ver" ] && echo "$ver" && return
# HTML/display: " vX.Y.Z" or ">vX.Y.Z"
# HTML display: " vX.Y.Z" or ">vX.Y.Z" (requires v prefix — not bare numbers)
ver=$(grep -oP '[ >]v\K[0-9]+\.[0-9]+\.[0-9]+' "$file" 2>/dev/null | head -1)
[ -n "$ver" ] && echo "$ver" && return
# XML/plist: <string>X.Y.Z</string> near version key
ver=$(grep -A1 -i 'version' "$file" 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+' | head -1)
[ -n "$ver" ] && echo "$ver" && return
}
# Get the most common version (the "current" version)
# --- Get current version (most common across files) ---
get_current_version() {
local versions=""
while IFS= read -r file; do
@@ -116,35 +157,93 @@ get_current_version() {
echo "$versions" | tr ' ' '\n' | grep -v '^$' | sort | uniq -c | sort -rn | head -1 | awk '{print $2}'
}
# Show all version occurrences
# --- Show all version occurrences grouped by version ---
show_versions() {
echo -e "${CYAN}Files with version strings:${NC}"
local current="$1"
local files_list=""
echo -e "${CYAN}Version profile:${NC}"
echo ""
# Collect all versions
declare -A version_files
while IFS= read -r file; do
[ -z "$file" ] && continue
local ver=$(get_version_from_file "$file")
if [ -n "$ver" ]; then
local rel="${file#./}"
echo -e " ${GREEN}$ver${NC} $rel"
if [ -z "${version_files[$ver]+x}" ]; then
version_files[$ver]="$rel"
else
version_files[$ver]="${version_files[$ver]}|$rel"
fi
fi
done <<< "$(find_version_files)"
# Show grouped by version
for ver in $(echo "${!version_files[@]}" | tr ' ' '\n' | sort -V); do
if [ "$ver" = "$current" ]; then
echo -e " ${GREEN}$ver${NC} ${BOLD}(current — will be updated)${NC}"
else
echo -e " ${DIM}$ver (different — will NOT be changed)${NC}"
fi
IFS='|' read -ra files <<< "${version_files[$ver]}"
for f in "${files[@]}"; do
if [ "$ver" = "$current" ]; then
echo -e " ${GREEN}${NC} $f"
else
echo -e " ${DIM}· $f${NC}"
fi
done
done
echo ""
}
# Replace version in all files
# --- Preview changes ---
preview_changes() {
local old="$1"
local new="$2"
echo -e "${YELLOW}Changes to be made:${NC}"
echo ""
local count=0
while IFS= read -r file; do
[ -z "$file" ] && continue
local ver=$(get_version_from_file "$file")
if [ "$ver" = "$old" ]; then
local rel="${file#./}"
# Show the actual line that will change
local line=$(grep -n "$old" "$file" 2>/dev/null | head -1)
local linenum=$(echo "$line" | cut -d: -f1)
local content=$(echo "$line" | cut -d: -f2-)
local newcontent=$(echo "$content" | sed "s/$old/$new/g")
echo -e " ${BOLD}$rel${NC} (line $linenum)"
echo -e " ${RED}- $content${NC}"
echo -e " ${GREEN}+ $newcontent${NC}"
echo ""
count=$((count + 1))
fi
done <<< "$(find_version_files)"
echo -e " ${CYAN}$count file(s) will be modified${NC}"
echo ""
}
# --- Apply changes ---
replace_version() {
local old="$1"
local new="$2"
local escaped_old=$(echo "$old" | sed 's/\./\\./g')
echo -e "${YELLOW}Replacing $old$new${NC}"
echo ""
# Save undo info
echo "$new $old" > "$UNDO_FILE"
local undo_files=""
while IFS= read -r file; do
[ -z "$file" ] && continue
local ver=$(get_version_from_file "$file")
if [ "$ver" = "$old" ]; then
# Replace all known version patterns
# JSON: "version": "X.Y.Z"
sed -i "s/\(\"version\"\s*:\s*\"\)$escaped_old\"/\1$new\"/g" "$file" 2>/dev/null
# TOML/Python: version = "X.Y.Z" or __version__ = "X.Y.Z"
@@ -160,14 +259,57 @@ replace_version() {
local rel="${file#./}"
echo -e " ${GREEN}${NC} $rel"
undo_files="$undo_files $rel"
fi
done <<< "$(find_version_files)"
# Append file list to undo
echo "$undo_files" >> "$UNDO_FILE"
echo ""
echo -e "${GREEN}Done! All versions set to $new${NC}"
echo -e "${GREEN}Done! $old $new${NC}"
echo -e "${DIM}Run ./bump-version.sh undo to revert${NC}"
}
# Bump a version component
# --- Undo ---
do_undo() {
if [ ! -f "$UNDO_FILE" ]; then
echo -e "${RED}No undo information found. Nothing to revert.${NC}"
exit 1
fi
local versions=$(head -1 "$UNDO_FILE")
local current=$(echo "$versions" | awk '{print $1}')
local previous=$(echo "$versions" | awk '{print $2}')
echo -e "${YELLOW}Undo: $current$previous${NC}"
echo ""
# Just do a version replace in the other direction
local escaped_current=$(echo "$current" | sed 's/\./\\./g')
while IFS= read -r file; do
[ -z "$file" ] && continue
local ver=$(get_version_from_file "$file")
if [ "$ver" = "$current" ]; then
sed -i "s/\(\"version\"\s*:\s*\"\)$escaped_current\"/\1$previous\"/g" "$file" 2>/dev/null
sed -i "s/\(\(__\)\?version\s*=\s*[\"']\)$escaped_current/\1$previous/g" "$file" 2>/dev/null
sed -i "s/\(^version:\s*[\"']\?\)$escaped_current/\1$previous/g" "$file" 2>/dev/null
sed -i "s/\(Version:\s*\)$escaped_current/\1$previous/g" "$file" 2>/dev/null
sed -i "s/\(VERSION\s*=\s*[\"']\?\)$escaped_current/\1$previous/g" "$file" 2>/dev/null
sed -i "s/\([ >]v\)$escaped_current/\1$previous/g" "$file" 2>/dev/null
local rel="${file#./}"
echo -e " ${GREEN}${NC} $rel"
fi
done <<< "$(find_version_files)"
rm -f "$UNDO_FILE"
echo ""
echo -e "${GREEN}Reverted to $previous${NC}"
}
# --- Bump version component ---
bump_version() {
local ver="$1"
local part="$2"
@@ -179,45 +321,94 @@ bump_version() {
esac
}
# --- Confirm ---
confirm() {
local msg="$1"
read -p "$msg [y/N] " answer
case "$answer" in
y|Y|yes|YES) return 0 ;;
*) return 1 ;;
esac
}
# --- Main ---
# Parse flags
YES_FLAG=false
for arg in "$@"; do
case "$arg" in
--help|-h) show_help ;;
--yes|-y) YES_FLAG=true ;;
undo) do_undo; exit 0 ;;
esac
done
# Get first non-flag argument
ACTION=""
for arg in "$@"; do
case "$arg" in
--*|-*) continue ;;
undo) continue ;;
*) ACTION="$arg"; break ;;
esac
done
CURRENT=$(get_current_version)
if [ -z "$CURRENT" ]; then
echo -e "${RED}No version strings found in the project.${NC}"
echo -e "${RED}No version declarations found in the project.${NC}"
echo -e "${DIM}Looking for patterns like \"version\": \"X.Y.Z\" or VERSION=\"X.Y.Z\"${NC}"
echo -e "${DIM}Run ./bump-version.sh --help for supported formats${NC}"
exit 1
fi
# Handle command-line argument
if [ -n "$1" ]; then
case "$1" in
if [ -n "$ACTION" ]; then
case "$ACTION" in
patch|minor|major)
NEW=$(bump_version "$CURRENT" "$1")
show_versions
replace_version "$CURRENT" "$NEW"
exit 0
NEW=$(bump_version "$CURRENT" "$ACTION")
;;
[0-9]*)
show_versions
replace_version "$CURRENT" "$1"
exit 0
if ! [[ "$ACTION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo -e "${RED}Invalid version format. Use X.Y.Z (e.g. 1.0.0)${NC}"
exit 1
fi
NEW="$ACTION"
;;
*)
echo "Usage: $0 [patch|minor|major|X.Y.Z]"
echo -e "${RED}Unknown action: $ACTION${NC}"
echo "Usage: $0 [patch|minor|major|X.Y.Z|undo|--help]"
exit 1
;;
esac
show_versions "$CURRENT"
preview_changes "$CURRENT" "$NEW"
if [ "$YES_FLAG" = false ]; then
if ! confirm "Apply these changes?"; then
echo "Cancelled."
exit 0
fi
fi
replace_version "$CURRENT" "$NEW"
exit 0
fi
# Interactive mode
show_versions
show_versions "$CURRENT"
echo -e "Current version: ${GREEN}$CURRENT${NC}"
echo -e "Current version: ${GREEN}${BOLD}$CURRENT${NC}"
echo ""
echo " [1] Bump patch: $CURRENT$(bump_version "$CURRENT" patch)"
echo " [2] Bump minor: $CURRENT$(bump_version "$CURRENT" minor)"
echo " [3] Bump major: $CURRENT$(bump_version "$CURRENT" major)"
echo " [4] Enter custom version"
if [ -f "$UNDO_FILE" ]; then
prev=$(head -1 "$UNDO_FILE" | awk '{print $2}')
echo " [u] Undo last bump (revert to $prev)"
fi
echo " [q] Quit"
echo ""
read -p "Choice: " choice
@@ -227,14 +418,23 @@ case "$choice" in
2) NEW=$(bump_version "$CURRENT" minor) ;;
3) NEW=$(bump_version "$CURRENT" major) ;;
4)
read -p "Enter version: " NEW
read -p "Enter version (X.Y.Z): " NEW
if ! [[ "$NEW" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo -e "${RED}Invalid version format. Use X.Y.Z${NC}"
exit 1
fi
;;
u|U) do_undo; exit 0 ;;
q|Q|"") echo "Cancelled."; exit 0 ;;
*) echo -e "${RED}Invalid choice.${NC}"; exit 1 ;;
esac
echo ""
preview_changes "$CURRENT" "$NEW"
if ! confirm "Apply these changes?"; then
echo "Cancelled."
exit 0
fi
replace_version "$CURRENT" "$NEW"