Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/scripts/check_required_contexts.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@
"Tag-triggered (v*.*.*); builds image, generates SBOM, publishes"
" release. Never appears on PR check sets."
),
"changelog-rollup.yml": (
"workflow_run-triggered after release.yml + workflow_dispatch only;"
" opens its own roll-up PR (which goes through ci.yml as normal)."
),
}


Expand Down
268 changes: 268 additions & 0 deletions .github/scripts/rollup_changelog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,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())
Loading
Loading