Skip to content

Commit 5b8284d

Browse files
authored
chore: post-release CHANGELOG rollup automation (#150, #166, #167) (#74)
1 parent 6375817 commit 5b8284d

7 files changed

Lines changed: 645 additions & 7 deletions

File tree

.github/scripts/check_required_contexts.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@
5151
"Tag-triggered (v*.*.*); builds image, generates SBOM, publishes"
5252
" release. Never appears on PR check sets."
5353
),
54+
"changelog-rollup.yml": (
55+
"workflow_run-triggered after release.yml + workflow_dispatch only;"
56+
" opens its own roll-up PR (which goes through ci.yml as normal)."
57+
),
5458
}
5559

5660

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
#!/usr/bin/env python3
2+
"""Roll up the CHANGELOG `[Unreleased]` section under a `[<version>]` heading.
3+
4+
Triggered automatically by `.github/workflows/changelog-rollup.yml` after a
5+
successful `release.yml` run. Performs the mechanical edits that would
6+
otherwise be hand-rolled per release.
7+
8+
Edits to `CHANGELOG.md`:
9+
10+
1. Insert `## [<version>] - <date>` heading immediately after `## [Unreleased]`.
11+
2. Update `[Unreleased]: …/compare/<old>...HEAD` → `…/compare/<tag>...HEAD`.
12+
3. Insert `[<version>]: …/compare/<prior-tag>...<tag>` link in the footer.
13+
14+
Edits to `pyproject.toml` and `uv.lock`:
15+
16+
4. Bump `[project].version` PATCH (e.g. `0.2.10` → `0.2.11`). The release
17+
tag's version IS the current dev version (release: PRs don't bump),
18+
so the rollup PR is what advances develop into the next cycle.
19+
5. Sync the `[[package]] name = "harness-python-react"` self-version line
20+
in `uv.lock` to match `pyproject.toml`. Same hand-edit pattern as
21+
the version-bump gate enforces on regular PRs.
22+
23+
Idempotency:
24+
25+
- If a `## [<version>]` heading already exists, step 1 is skipped (the
26+
rollup PR can be re-run without duplicating sections).
27+
- If a `[<version>]:` footer link already exists, step 3 is skipped.
28+
- The version-bump steps are idempotent only if pyproject's version still
29+
equals the released tag; running twice on an already-bumped develop
30+
would push it forward again. Workflow uses a fresh checkout so this
31+
doesn't compound across re-runs.
32+
33+
Modes:
34+
35+
- Default: full roll-up (CHANGELOG edits + version bump). This is what
36+
`changelog-rollup.yml` does after a release tag is cut.
37+
- `--no-bump`: CHANGELOG edits only, skip the version bump. Use when
38+
pre-staging the CHANGELOG before a release PR is opened — develop's
39+
current version is the about-to-be-released version, and the
40+
post-release rollup is what advances develop into the next cycle.
41+
42+
Edge case — no prior tag (first release):
43+
44+
- `--prior-tag ""` produces a footer link of shape
45+
`[<version>]: …/releases/tag/<tag>` (mirrors the existing `[1.0.0]`
46+
/ first-tag entry shape).
47+
48+
Exit codes:
49+
0 — file edits applied (or already-rolled-up; idempotent path)
50+
1 — argument validation failure (bad version shape, etc.)
51+
2 — file not found / write error / TOML parse error
52+
53+
Usage:
54+
55+
python .github/scripts/rollup_changelog.py \\
56+
--tag v0.3.0 --prior-tag v0.2.5 --date 2026-05-01
57+
"""
58+
59+
from __future__ import annotations
60+
61+
import argparse
62+
import re
63+
import sys
64+
import tomllib
65+
from pathlib import Path
66+
67+
CHANGELOG = Path("CHANGELOG.md")
68+
PYPROJECT = Path("pyproject.toml")
69+
UV_LOCK = Path("uv.lock")
70+
71+
REPO_PATH = "constk/harness-python-react"
72+
PACKAGE_NAME = "harness-python-react"
73+
COMPARE_URL_BASE = f"https://github.com/{REPO_PATH}/compare"
74+
RELEASES_TAG_BASE = f"https://github.com/{REPO_PATH}/releases/tag"
75+
76+
_SEMVER_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$")
77+
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
78+
79+
80+
def _strip_v(tag: str) -> str:
81+
return tag[1:] if tag.startswith("v") else tag
82+
83+
84+
def _bump_patch(version: str) -> str:
85+
"""`0.2.10` → `0.2.11`. Raises ValueError on non-semver input."""
86+
match = _SEMVER_RE.fullmatch(version)
87+
if not match:
88+
msg = f"unsupported semver shape: {version!r}"
89+
raise ValueError(msg)
90+
major, minor, patch = match.groups()
91+
return f"{major}.{minor}.{int(patch) + 1}"
92+
93+
94+
def rollup_changelog_text(
95+
text: str,
96+
tag: str,
97+
prior_tag: str,
98+
date: str,
99+
) -> str:
100+
"""Pure-string transform — `text` → updated `text`. Idempotent."""
101+
version = _strip_v(tag)
102+
heading_re = re.compile(
103+
rf"^## \[{re.escape(version)}\]\s+-\s+\d{{4}}-\d{{2}}-\d{{2}}",
104+
re.MULTILINE,
105+
)
106+
if not heading_re.search(text):
107+
# Trailing `\n\n` keeps the blank line between the new heading and
108+
# its first subsection that every existing release section has.
109+
# Without it the rendered diff reads `## [v0.3.0] - …\n### Features`
110+
# which Keep-a-Changelog tolerates but is cosmetically inconsistent.
111+
text = re.sub(
112+
r"^## \[Unreleased\]\s*\n",
113+
f"## [Unreleased]\n\n## [{version}] - {date}\n\n",
114+
text,
115+
count=1,
116+
flags=re.MULTILINE,
117+
)
118+
119+
text = re.sub(
120+
r"^\[Unreleased\]:\s+(.*?/compare/)\S+?\.\.\.HEAD\s*$",
121+
rf"[Unreleased]: \1{tag}...HEAD",
122+
text,
123+
count=1,
124+
flags=re.MULTILINE,
125+
)
126+
127+
if f"[{version}]:" not in text:
128+
if prior_tag:
129+
new_link = f"[{version}]: {COMPARE_URL_BASE}/{prior_tag}...{tag}"
130+
else:
131+
new_link = f"[{version}]: {RELEASES_TAG_BASE}/{tag}"
132+
text = re.sub(
133+
r"^(\[Unreleased\]:.*)$",
134+
rf"\1\n{new_link}",
135+
text,
136+
count=1,
137+
flags=re.MULTILINE,
138+
)
139+
return text
140+
141+
142+
def bump_pyproject_text(text: str, current: str, new: str) -> str:
143+
"""Replace `version = "<current>"` with `version = "<new>"` once."""
144+
pattern = re.compile(rf'^version\s*=\s*"{re.escape(current)}"', re.MULTILINE)
145+
if not pattern.search(text):
146+
msg = (
147+
f'pyproject.toml version line `version = "{current}"` not found; '
148+
"either the rollup ran out-of-order or the file shape changed."
149+
)
150+
raise ValueError(msg)
151+
return pattern.sub(f'version = "{new}"', text, count=1)
152+
153+
154+
def bump_uv_lock_text(text: str, current: str, new: str) -> str:
155+
"""Replace the project's self-version line in `uv.lock` once."""
156+
pattern = re.compile(
157+
rf'^name = "{re.escape(PACKAGE_NAME)}"\nversion = "{re.escape(current)}"\n',
158+
re.MULTILINE,
159+
)
160+
if not pattern.search(text):
161+
msg = (
162+
f'uv.lock self-version line `version = "{current}"` not found '
163+
f'under the `[[package]] name = "{PACKAGE_NAME}"` block.'
164+
)
165+
raise ValueError(msg)
166+
return pattern.sub(f'name = "{PACKAGE_NAME}"\nversion = "{new}"\n', text, count=1)
167+
168+
169+
def _read_pyproject_version() -> str:
170+
data = tomllib.loads(PYPROJECT.read_text(encoding="utf-8"))
171+
version = data.get("project", {}).get("version", "")
172+
if not isinstance(version, str) or not _SEMVER_RE.fullmatch(version):
173+
msg = f"unable to read [project].version from {PYPROJECT}: {version!r}"
174+
raise ValueError(msg)
175+
return version
176+
177+
178+
def main() -> int:
179+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
180+
parser.add_argument("--tag", required=True, help="released tag, e.g. v0.3.0")
181+
parser.add_argument(
182+
"--prior-tag",
183+
default="",
184+
help="previous tag for compare link; empty for first release",
185+
)
186+
parser.add_argument("--date", required=True, help="release date YYYY-MM-DD (UTC)")
187+
parser.add_argument(
188+
"--no-bump",
189+
action="store_true",
190+
help=(
191+
"skip the pyproject.toml + uv.lock version bump — used when "
192+
"pre-staging the CHANGELOG before the release tag is cut; the "
193+
"post-release rollup is what advances develop into the next cycle"
194+
),
195+
)
196+
args = parser.parse_args()
197+
198+
if not _SEMVER_RE.fullmatch(args.tag):
199+
print(f"::error::--tag must be vMAJOR.MINOR.PATCH; got {args.tag!r}")
200+
return 1
201+
if args.prior_tag and not _SEMVER_RE.fullmatch(args.prior_tag):
202+
print(f"::error::--prior-tag must be vX.Y.Z or empty; got {args.prior_tag!r}")
203+
return 1
204+
if not _DATE_RE.fullmatch(args.date):
205+
print(f"::error::--date must be YYYY-MM-DD; got {args.date!r}")
206+
return 1
207+
208+
released_version = _strip_v(args.tag)
209+
next_version = _bump_patch(released_version)
210+
211+
try:
212+
current_version = _read_pyproject_version()
213+
except (FileNotFoundError, ValueError) as exc:
214+
print(f"::error::{exc}")
215+
return 2
216+
# Version-vs-tag sanity. Skipped under --no-bump because the prestage
217+
# runs *before* the release tag is cut: develop's current version IS
218+
# the about-to-be-released version, which matches `released_version`,
219+
# but we don't want to bump it (the post-release rollup does that).
220+
if not args.no_bump and current_version != released_version:
221+
print(
222+
f"::error::pyproject.toml version is {current_version!r} but tag is "
223+
f"{args.tag!r} (expected {released_version!r}). The rollup workflow "
224+
"must run against develop *as the release was cut from*; if develop "
225+
"moved on, replay manually after rebasing."
226+
)
227+
return 1
228+
229+
try:
230+
new_changelog = rollup_changelog_text(
231+
CHANGELOG.read_text(encoding="utf-8"),
232+
args.tag,
233+
args.prior_tag,
234+
args.date,
235+
)
236+
if not args.no_bump:
237+
new_pyproject = bump_pyproject_text(
238+
PYPROJECT.read_text(encoding="utf-8"),
239+
current_version,
240+
next_version,
241+
)
242+
new_uv_lock = bump_uv_lock_text(
243+
UV_LOCK.read_text(encoding="utf-8"),
244+
current_version,
245+
next_version,
246+
)
247+
except (FileNotFoundError, ValueError) as exc:
248+
print(f"::error::{exc}")
249+
return 2
250+
251+
CHANGELOG.write_text(new_changelog, encoding="utf-8")
252+
if not args.no_bump:
253+
PYPROJECT.write_text(new_pyproject, encoding="utf-8")
254+
UV_LOCK.write_text(new_uv_lock, encoding="utf-8")
255+
print(
256+
f"Rolled up CHANGELOG [Unreleased] under [{released_version}] - "
257+
f"{args.date}; bumped {current_version} -> {next_version}."
258+
)
259+
else:
260+
print(
261+
f"Pre-staged CHANGELOG [Unreleased] under [{released_version}] - "
262+
f"{args.date}; version bump deferred to the post-release rollup."
263+
)
264+
return 0
265+
266+
267+
if __name__ == "__main__":
268+
sys.exit(main())

0 commit comments

Comments
 (0)