返回 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 )
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
222 lines PYTHON