| 1 | #!/usr/bin/env python3 |
| 2 | """Prepare a lockstep release: towncrier changelog + bump every version surface. |
| 3 | |
| 4 | Usage (from repo root): |
| 5 | python3 .github/scripts/prepare_release.py --bump patch |
| 6 | python3 .github/scripts/prepare_release.py --version 3.19.0 |
| 7 | python3 .github/scripts/prepare_release.py --bump minor --dry-run |
| 8 | |
| 9 | Do not edit CHANGELOG.md or version manifests in feature PRs — add a |
| 10 | changelog.d/ fragment instead. This script is for release PRs only. |
| 11 | """ |
| 12 | |
| 13 | from __future__ import annotations |
| 14 | |
| 15 | import argparse |
| 16 | import json |
| 17 | import re |
| 18 | import subprocess |
| 19 | import sys |
| 20 | from pathlib import Path |
| 21 | |
| 22 | ROOT = Path(__file__).resolve().parents[2] |
| 23 | |
| 24 | SKILL_MD = ROOT / "skills" / "last30days" / "SKILL.md" |
| 25 | PYPROJECT = ROOT / "pyproject.toml" |
| 26 | UV_LOCK = ROOT / "uv.lock" |
| 27 | |
| 28 | JSON_VERSION_FILES = ( |
| 29 | ROOT / ".claude-plugin" / "plugin.json", |
| 30 | ROOT / ".codex-plugin" / "plugin.json", |
| 31 | ROOT / ".grok-plugin" / "plugin.json", |
| 32 | ROOT / "gemini-extension.json", |
| 33 | ROOT / "mcp" / "manifest.json", |
| 34 | ) |
| 35 | |
| 36 | MARKETPLACE_FILES = ( |
| 37 | ROOT / ".claude-plugin" / "marketplace.json", |
| 38 | ROOT / ".grok-plugin" / "marketplace.json", |
| 39 | ) |
| 40 | |
| 41 | _VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") |
| 42 | _PYPROJECT_VERSION_RE = re.compile( |
| 43 | r'^(version\s*=\s*")([^"]+)(")\s*$', re.MULTILINE |
| 44 | ) |
| 45 | _SKILL_FRONTMATTER_VERSION_RE = re.compile( |
| 46 | r'^(version:\s*")([^"]+)(")\s*$', re.MULTILINE |
| 47 | ) |
| 48 | _SKILL_HEADER_RE = re.compile( |
| 49 | r"^(# last30days v)(\d+\.\d+\.\d+)(:)", re.MULTILINE |
| 50 | ) |
| 51 | _UV_LOCK_PACKAGE_RE = re.compile( |
| 52 | r'(?ms)^(\[\[package\]\]\nname = "last30days-skill"\nversion = ")([^"]+)(")' |
| 53 | ) |
| 54 | |
| 55 | |
| 56 | def _parse_version(text: str) -> tuple[int, int, int]: |
| 57 | match = _VERSION_RE.fullmatch(text.strip()) |
| 58 | if not match: |
| 59 | raise SystemExit(f"Invalid semver (expected X.Y.Z): {text!r}") |
| 60 | return int(match.group(1)), int(match.group(2)), int(match.group(3)) |
| 61 | |
| 62 | |
| 63 | def _format_version(parts: tuple[int, int, int]) -> str: |
| 64 | return f"{parts[0]}.{parts[1]}.{parts[2]}" |
| 65 | |
| 66 | |
| 67 | def read_current_version() -> str: |
| 68 | text = PYPROJECT.read_text(encoding="utf-8") |
| 69 | match = _PYPROJECT_VERSION_RE.search(text) |
| 70 | if not match: |
| 71 | raise SystemExit("Could not find [project].version in pyproject.toml") |
| 72 | return match.group(2) |
| 73 | |
| 74 | |
| 75 | def next_version(current: str, bump: str) -> str: |
| 76 | major, minor, patch = _parse_version(current) |
| 77 | if bump == "major": |
| 78 | return _format_version((major + 1, 0, 0)) |
| 79 | if bump == "minor": |
| 80 | return _format_version((major, minor + 1, 0)) |
| 81 | if bump == "patch": |
| 82 | return _format_version((major, minor, patch + 1)) |
| 83 | raise SystemExit(f"Unknown bump kind: {bump!r}") |
| 84 | |
| 85 | |
| 86 | def _replace_once(path: Path, pattern: re.Pattern[str], new: str, label: str) -> None: |
| 87 | text = path.read_text(encoding="utf-8") |
| 88 | updated, count = pattern.subn(rf"\g<1>{new}\g<3>", text, count=1) |
| 89 | if count != 1: |
| 90 | raise SystemExit(f"{path.relative_to(ROOT)}: expected one {label} match, found {count}") |
| 91 | path.write_text(updated, encoding="utf-8") |
| 92 | |
| 93 | |
| 94 | def bump_pyproject(version: str) -> None: |
| 95 | _replace_once(PYPROJECT, _PYPROJECT_VERSION_RE, version, "version") |
| 96 | |
| 97 | |
| 98 | def bump_skill_md(version: str) -> None: |
| 99 | text = SKILL_MD.read_text(encoding="utf-8") |
| 100 | text2, n1 = _SKILL_FRONTMATTER_VERSION_RE.subn( |
| 101 | rf"\g<1>{version}\g<3>", text, count=1 |
| 102 | ) |
| 103 | text3, n2 = _SKILL_HEADER_RE.subn(rf"\g<1>{version}\g<3>", text2, count=1) |
| 104 | if n1 != 1 or n2 != 1: |
| 105 | raise SystemExit( |
| 106 | f"SKILL.md: expected one frontmatter version and one H1 version, " |
| 107 | f"found frontmatter={n1} header={n2}" |
| 108 | ) |
| 109 | SKILL_MD.write_text(text3, encoding="utf-8") |
| 110 | |
| 111 | |
| 112 | def bump_json_version(path: Path, version: str) -> None: |
| 113 | data = json.loads(path.read_text(encoding="utf-8")) |
| 114 | if "version" not in data: |
| 115 | raise SystemExit(f"{path.relative_to(ROOT)}: missing top-level version") |
| 116 | data["version"] = version |
| 117 | path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") |
| 118 | |
| 119 | |
| 120 | def bump_marketplace(path: Path, version: str) -> None: |
| 121 | data = json.loads(path.read_text(encoding="utf-8")) |
| 122 | plugins = data.get("plugins") or [] |
| 123 | if not plugins: |
| 124 | raise SystemExit(f"{path.relative_to(ROOT)}: plugins[] is empty") |
| 125 | plugins[0]["version"] = version |
| 126 | path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") |
| 127 | |
| 128 | |
| 129 | def bump_uv_lock(version: str) -> None: |
| 130 | text = UV_LOCK.read_text(encoding="utf-8") |
| 131 | updated, count = _UV_LOCK_PACKAGE_RE.subn(rf"\g<1>{version}\g<3>", text, count=1) |
| 132 | if count != 1: |
| 133 | raise SystemExit(f"uv.lock: expected one last30days-skill package stanza, found {count}") |
| 134 | UV_LOCK.write_text(updated, encoding="utf-8") |
| 135 | |
| 136 | |
| 137 | def run_towncrier(version: str, *, dry_run: bool) -> None: |
| 138 | cmd = [ |
| 139 | sys.executable, |
| 140 | "-m", |
| 141 | "towncrier", |
| 142 | "build", |
| 143 | "--version", |
| 144 | version, |
| 145 | "--yes", |
| 146 | ] |
| 147 | if dry_run: |
| 148 | cmd.append("--draft") |
| 149 | subprocess.run(cmd, cwd=ROOT, check=True) |
| 150 | |
| 151 | |
| 152 | def bump_all(version: str) -> list[str]: |
| 153 | touched: list[str] = [] |
| 154 | bump_pyproject(version) |
| 155 | touched.append(str(PYPROJECT.relative_to(ROOT))) |
| 156 | bump_skill_md(version) |
| 157 | touched.append(str(SKILL_MD.relative_to(ROOT))) |
| 158 | for path in JSON_VERSION_FILES: |
| 159 | bump_json_version(path, version) |
| 160 | touched.append(str(path.relative_to(ROOT))) |
| 161 | for path in MARKETPLACE_FILES: |
| 162 | bump_marketplace(path, version) |
| 163 | touched.append(str(path.relative_to(ROOT))) |
| 164 | bump_uv_lock(version) |
| 165 | touched.append(str(UV_LOCK.relative_to(ROOT))) |
| 166 | return touched |
| 167 | |
| 168 | |
| 169 | def main(argv: list[str] | None = None) -> int: |
| 170 | parser = argparse.ArgumentParser(description=__doc__) |
| 171 | group = parser.add_mutually_exclusive_group(required=True) |
| 172 | group.add_argument("--bump", choices=("major", "minor", "patch")) |
| 173 | group.add_argument("--version", help="Explicit X.Y.Z to set") |
| 174 | parser.add_argument( |
| 175 | "--dry-run", |
| 176 | action="store_true", |
| 177 | help="Print the planned version and towncrier draft; do not write files", |
| 178 | ) |
| 179 | parser.add_argument( |
| 180 | "--skip-towncrier", |
| 181 | action="store_true", |
| 182 | help="Only bump version surfaces (changelog already prepared)", |
| 183 | ) |
| 184 | args = parser.parse_args(argv) |
| 185 | |
| 186 | current = read_current_version() |
| 187 | version = args.version or next_version(current, args.bump) |
| 188 | _parse_version(version) |
| 189 | if args.version: |
| 190 | parsed_new = _parse_version(version) |
| 191 | parsed_cur = _parse_version(current) |
| 192 | if parsed_new < parsed_cur: |
| 193 | raise SystemExit(f"Refusing to downgrade {current} → {version}") |
| 194 | if parsed_new == parsed_cur and not args.dry_run: |
| 195 | raise SystemExit( |
| 196 | f"Refusing to re-release {current}; pass --bump or a newer --version " |
| 197 | "(use --dry-run to preview towncrier output for the current version)" |
| 198 | ) |
| 199 | |
| 200 | print(f"Current version: {current}") |
| 201 | print(f"Next version: {version}") |
| 202 | |
| 203 | if args.dry_run: |
| 204 | if not args.skip_towncrier: |
| 205 | run_towncrier(version, dry_run=True) |
| 206 | print("Dry run only — no files written.") |
| 207 | return 0 |
| 208 | |
| 209 | if not args.skip_towncrier: |
| 210 | run_towncrier(version, dry_run=False) |
| 211 | print("Updated CHANGELOG.md via towncrier") |
| 212 | |
| 213 | touched = bump_all(version) |
| 214 | print("Bumped lockstep files:") |
| 215 | for path in touched: |
| 216 | print(f" - {path}") |
| 217 | print(f"\nNext: open a release PR, merge, then tag v{version} (tag-release workflow).") |
| 218 | return 0 |
| 219 | |
| 220 | |
| 221 | if __name__ == "__main__": |
| 222 | raise SystemExit(main()) |
| 223 |