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