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:
parent
db6302f99d
commit
06ed33ca67
12
CHANGELOG.md
12
CHANGELOG.md
@ -7,6 +7,18 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [1.1.0] — 2026-05-29
|
||||||
|
|||||||
31
scripts/hooks/pre-commit
Executable file
31
scripts/hooks/pre-commit
Executable 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
10
scripts/install-hooks.sh
Normal 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
147
scripts/release.py
Executable 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()
|
||||||
Loading…
Reference in New Issue
Block a user