Add release automation: version bump script and pre-commit changelog hook

scripts/release.py:
  - --patch/--minor/--major flag bumps VERSION file
  - Renames [Unreleased] → [x.y.z] — date in CHANGELOG.md
  - Inserts fresh [Unreleased] section + correct compare links
  - Commits + creates annotated git tag
  - --dry-run flag for preview without writes
  - Prints git push + GitHub Releases URL on completion

scripts/hooks/pre-commit:
  - Warns (exit 0, non-blocking) when source files are staged but
    CHANGELOG.md or VERSION are not staged
  - Already installed in .git/hooks/

scripts/install-hooks.sh:
  - One-liner to install hooks after a fresh clone

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-05-29 14:14:41 +02:00
parent db6302f99d
commit 06ed33ca67
4 changed files with 200 additions and 0 deletions

View File

@ -7,6 +7,18 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased]
### Added
- **`scripts/release.py`** — automates version bump + CHANGELOG promotion.
`python scripts/release.py --patch|--minor|--major [--dry-run]` renames
`[Unreleased]` to the new version, updates compare links, writes `VERSION`,
commits, and creates an annotated git tag in one command.
- **Git pre-commit hook** (`scripts/hooks/pre-commit`) — warns (does not block)
when `.py`/`.js`/`.css`/`.html` files are staged but `CHANGELOG.md` or
`VERSION` are not. Run `bash scripts/install-hooks.sh` after cloning.
- **`scripts/install-hooks.sh`** — one-liner to install the hook after a fresh
clone: `bash scripts/install-hooks.sh`.
---
## [1.1.0] — 2026-05-29

31
scripts/hooks/pre-commit Executable file
View File

@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Pre-commit: warn if CHANGELOG.md and VERSION are not staged.
# Never exits 1 — this is a reminder, not a blocker.
staged=$(git diff --cached --name-only 2>/dev/null)
# Only check when real source files are being committed (ignore pure docs/assets)
source_staged=$(echo "$staged" | grep -E '\.(py|js|html|css|yml|yaml|json)$' | head -1)
if [ -z "$source_staged" ]; then
exit 0
fi
missing=()
echo "$staged" | grep -q "CHANGELOG.md" || missing+=("CHANGELOG.md")
echo "$staged" | grep -q "^VERSION$" || missing+=("VERSION")
if [ ${#missing[@]} -gt 0 ]; then
echo ""
echo " ⚠️ Reminder: the following files were not staged:"
for f in "${missing[@]}"; do
echo " - $f"
done
echo ""
echo " Update CHANGELOG.md ([Unreleased] section) and bump VERSION if"
echo " this commit introduces a user-visible change."
echo " Run python scripts/release.py --minor to automate a release."
echo " Skip with git commit --no-verify if this is an internal-only change."
echo ""
fi
exit 0

10
scripts/install-hooks.sh Normal file
View File

@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Run once after cloning: bash scripts/install-hooks.sh
# Copies the project's git hooks into .git/hooks/
set -e
REPO_ROOT="$(git rev-parse --show-toplevel)"
HOOKS_SRC="$REPO_ROOT/scripts/hooks"
HOOKS_DST="$REPO_ROOT/.git/hooks"
cp "$HOOKS_SRC/pre-commit" "$HOOKS_DST/pre-commit"
chmod +x "$HOOKS_DST/pre-commit"
echo "✓ Installed pre-commit hook"

147
scripts/release.py Executable file
View File

@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""
release.py bump version, promote [Unreleased] in CHANGELOG, commit, tag.
Usage:
python scripts/release.py --patch # 1.1.0 → 1.1.1
python scripts/release.py --minor # 1.1.0 → 1.2.0
python scripts/release.py --major # 1.1.0 → 2.0.0
python scripts/release.py --dry-run --minor # preview only, no writes
What it does:
1. Reads current version from VERSION
2. Computes the next version
3. In CHANGELOG.md: renames "## [Unreleased]" "## [x.y.z] — YYYY-MM-DD"
and inserts a fresh "## [Unreleased]" + compare link block at the top
4. Writes new version to VERSION
5. git add VERSION CHANGELOG.md
6. git commit -m "Release vX.Y.Z"
7. git tag -a vX.Y.Z -m "vX.Y.Z"
8. Prints push instructions
"""
from __future__ import annotations
import argparse
import re
import subprocess
import sys
from datetime import date
from pathlib import Path
REPO_ROOT = Path(__file__).parent.parent
VERSION_FILE = REPO_ROOT / "VERSION"
CHANGELOG_FILE = REPO_ROOT / "CHANGELOG.md"
GITHUB_REPO = "mARTin-B78/tts-voice-creator-clone-and-design-2"
GITHUB_BASE = f"https://github.com/{GITHUB_REPO}"
def read_version() -> tuple[int, int, int]:
text = VERSION_FILE.read_text().strip()
parts = text.split(".")
if len(parts) != 3:
sys.exit(f"Invalid VERSION file content: {text!r}. Expected MAJOR.MINOR.PATCH")
return int(parts[0]), int(parts[1]), int(parts[2])
def bump(current: tuple[int, int, int], kind: str) -> tuple[int, int, int]:
major, minor, patch = current
if kind == "major":
return (major + 1, 0, 0)
if kind == "minor":
return (major, minor + 1, 0)
return (major, minor, patch + 1)
def fmt_version(v: tuple[int, int, int]) -> str:
return ".".join(map(str, v))
def git(*args: str, check: bool = True) -> str:
result = subprocess.run(["git", *args], capture_output=True, text=True, cwd=REPO_ROOT)
if check and result.returncode != 0:
sys.exit(f"git {' '.join(args)} failed:\n{result.stderr.strip()}")
return result.stdout.strip()
def main() -> None:
parser = argparse.ArgumentParser(description="Bump version and promote CHANGELOG")
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--patch", action="store_true")
group.add_argument("--minor", action="store_true")
group.add_argument("--major", action="store_true")
parser.add_argument("--dry-run", action="store_true", help="Preview only, no file writes or git ops")
args = parser.parse_args()
kind = "patch" if args.patch else "minor" if args.minor else "major"
dry = args.dry_run
current = read_version()
next_ver = bump(current, kind)
cur_str = fmt_version(current)
new_str = fmt_version(next_ver)
today = date.today().isoformat()
print(f" Current version : {cur_str}")
print(f" Next version : {new_str} ({kind} bump)")
print(f" Release date : {today}")
print(f" Dry run : {dry}")
print()
# ── Patch CHANGELOG.md ────────────────────────────────────────────────────
changelog = CHANGELOG_FILE.read_text()
# Must have an [Unreleased] section
if "## [Unreleased]" not in changelog:
sys.exit("CHANGELOG.md has no [Unreleased] section. Add one before releasing.")
# Build the new compare links block
link_unreleased = f"[Unreleased]: {GITHUB_BASE}/compare/v{new_str}...HEAD"
link_new = f"[{new_str}]: {GITHUB_BASE}/compare/v{cur_str}...v{new_str}"
# Replace the existing [Unreleased] compare link if present, else append
if "[Unreleased]:" in changelog:
changelog = re.sub(
r"^\[Unreleased\]:.*$",
f"{link_unreleased}\n{link_new}",
changelog,
flags=re.MULTILINE,
)
else:
changelog = changelog.rstrip() + f"\n{link_unreleased}\n{link_new}\n"
# Rename "## [Unreleased]" → "## [x.y.z] — date"
new_version_header = f"## [{new_str}] — {today}"
changelog = changelog.replace(
"## [Unreleased]",
f"## [Unreleased]\n\n---\n\n{new_version_header}",
1, # only first occurrence
)
print(" CHANGELOG.md changes:")
for line in changelog.splitlines():
if new_str in line or "Unreleased" in line:
print(f" {line}")
print()
if not dry:
CHANGELOG_FILE.write_text(changelog)
VERSION_FILE.write_text(new_str + "\n")
print(f" ✓ Wrote VERSION → {new_str}")
print(f" ✓ Wrote CHANGELOG.md")
git("add", "VERSION", "CHANGELOG.md")
git("commit", "--no-verify", "-m", f"Release v{new_str}")
git("tag", "-a", f"v{new_str}", "-m", f"v{new_str}")
print(f"\n ✓ Committed and tagged v{new_str}")
print(f"\n Push with:")
print(f" git push origin main --tags")
print(f"\n Then create a GitHub Release at:")
print(f" {GITHUB_BASE}/releases/new?tag=v{new_str}")
else:
print(" [dry-run] No files written, no git operations performed.")
if __name__ == "__main__":
main()