返回 last30days-skill
prepare_release.py
根目录 / .github / scripts / prepare_release.py
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
223 lines PYTHON