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