From c8e9af58ef40025fc947e10e55badeddbc452f6d Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 26 Aug 2026 16:42:05 -0400 Subject: [PATCH] feat(ci): propose the next release as a reviewable pull request The release workflow refuses to guess a version: it requires pyproject.toml and CHANGELOG.md to already carry the exact version being published. Somebody had to write those by hand. This writes them instead. scripts/plan_release.py reads the conventional commits since the last tag and computes the next version under the policy already declared in pyproject.toml (major_on_zero = false, allow_zero_version = true), then stamps the version and renders the changelog section in the exact form the release workflow greps for. It replaces the version arithmetic python-semantic-release used to do, which cannot run here: GitPython 3.1.60 removed Actor.name_email_regex, every released version reads it during config load, and the upstream fix is unmerged. .github/workflows/propose-release.yml runs that on every push to main, syncs uv.lock through the existing verify_release_lock helper, pushes release/next with the built-in token, and opens or updates one pull request using the lifecycle App token. The App token is what makes CI run on the proposal; a pull request opened with the built-in token starts no further workflow. The bot cannot land what it proposes. The lifecycle App is never granted contents access, the workflow never pushes to main, and the contract test asserts it contains no merge, no auto-merge, no tag, no release, no publish step, and no reference to the release App. A staged or empty history is a quiet no-op rather than a failure, so main does not go red between releases. Co-Authored-By: Claude Opus 5 --- .github/workflows/propose-release.yml | 154 ++++++++++++++ scripts/plan_release.py | 246 +++++++++++++++++++++++ tests/test_plan_release.py | 156 ++++++++++++++ tests/test_workflow_identity_contract.py | 27 +++ 4 files changed, 583 insertions(+) create mode 100644 .github/workflows/propose-release.yml create mode 100644 scripts/plan_release.py create mode 100644 tests/test_plan_release.py diff --git a/.github/workflows/propose-release.yml b/.github/workflows/propose-release.yml new file mode 100644 index 0000000..07e4c3c --- /dev/null +++ b/.github/workflows/propose-release.yml @@ -0,0 +1,154 @@ +name: Propose the next release + +# Writes the version bump, the lock sync, and the changelog section, then opens +# a pull request with them. It never merges, tags, or publishes. +# +# The release workflow refuses to guess a version: it requires pyproject.toml +# and CHANGELOG.md to already carry the exact version being published. This +# workflow produces that state as a reviewable diff, so the only manual step +# left is reading the proposal and merging it. +# +# The branch push uses the built-in token. The pull request is opened with the +# lifecycle App token, because a pull request opened with the built-in token +# does not start any further workflow run, and this proposal has to be tested +# before anybody merges it. The lifecycle App is never granted contents access, +# so it cannot land the change it proposes. + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + reject-lifecycle-app: + permissions: {} + runs-on: ubuntu-latest + steps: + - name: Reject the lifecycle App + env: + ACTOR: ${{ github.actor }} + TRIGGERING_ACTOR: ${{ github.triggering_actor }} + run: | + test "$ACTOR" != 'openadapt-lifecycle[bot]' + test "$TRIGGERING_ACTOR" != 'openadapt-lifecycle[bot]' + + propose-release: + needs: reject-lifecycle-app + if: >- + github.repository == 'OpenAdaptAI/openadapt-evals' && + github.ref == 'refs/heads/main' && + github.actor != 'openadapt-lifecycle[bot]' && + github.triggering_actor != 'openadapt-lifecycle[bot]' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write + steps: + - name: Checkout exact main + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + token: ${{ github.token }} + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.12' + + - name: Plan the release + id: plan + run: | + set -euo pipefail + python scripts/plan_release.py | tee "$GITHUB_OUTPUT" + + - name: Write the bump, the lock, and the changelog + if: steps.plan.outputs.released == 'true' + run: | + set -euo pipefail + python scripts/plan_release.py --write + python -m pip install --quiet uv==0.11.29 + python scripts/verify_release_lock.py --write + python scripts/verify_release_lock.py + git diff --stat + + - name: Push the proposal branch + if: steps.plan.outputs.released == 'true' + env: + NEXT: ${{ steps.plan.outputs.next }} + run: | + set -euo pipefail + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git checkout -B release/next + git add pyproject.toml uv.lock CHANGELOG.md + git commit -m "chore(release): prepare ${NEXT}" + git push --force-with-lease origin release/next + + - name: Create the lifecycle App pull-request token + if: steps.plan.outputs.released == 'true' + id: lifecycle-app + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ vars.OPENADAPT_LIFECYCLE_APP_ID }} + private-key: ${{ secrets.OPENADAPT_LIFECYCLE_APP_PRIVATE_KEY }} + owner: OpenAdaptAI + repositories: openadapt-evals + permission-pull-requests: write + + - name: Verify the lifecycle App identity + if: steps.plan.outputs.released == 'true' + env: + ACTUAL_APP_SLUG: ${{ steps.lifecycle-app.outputs.app-slug }} + ACTUAL_INSTALLATION_ID: ${{ steps.lifecycle-app.outputs.installation-id }} + EXPECTED_INSTALLATION_ID: ${{ vars.OPENADAPT_LIFECYCLE_INSTALLATION_ID }} + run: | + set -euo pipefail + test "$ACTUAL_APP_SLUG" = 'openadapt-lifecycle' + test "$ACTUAL_INSTALLATION_ID" = "$EXPECTED_INSTALLATION_ID" + + - name: Open or update the release pull request + if: steps.plan.outputs.released == 'true' + env: + GH_TOKEN: ${{ steps.lifecycle-app.outputs.token }} + NEXT: ${{ steps.plan.outputs.next }} + PREVIOUS: ${{ steps.plan.outputs.previous }} + COUNT: ${{ steps.plan.outputs.changes }} + run: | + set -euo pipefail + existing=$(gh pr list --head release/next --state open --json number --jq '.[0].number // empty') + body=$(printf '%s\n' \ + "Prepares \`${NEXT}\`, up from \`${PREVIOUS}\`, across ${COUNT} commits." \ + "" \ + "Written by \`scripts/plan_release.py\` from the conventional commits since the last tag. Merging this does not publish anything. After it lands, dispatch **Release and publish** with version \`${NEXT}\` and the merge commit SHA." \ + "" \ + "Read the changelog section before merging. That is the whole point of this pull request.") + if [ -n "$existing" ]; then + gh pr edit "$existing" --title "chore(release): prepare ${NEXT}" --body "$body" + echo "updated PR #${existing}" + else + gh pr create \ + --base main \ + --head release/next \ + --title "chore(release): prepare ${NEXT}" \ + --body "$body" + fi + + - name: Report a quiet run + if: steps.plan.outputs.released != 'true' + env: + STAGED: ${{ steps.plan.outputs.staged }} + run: | + set -euo pipefail + if [ "$STAGED" = 'true' ]; then + echo 'A release is already staged on main and is waiting to be published.' + else + echo 'No releasable change since the last tag.' + fi diff --git a/scripts/plan_release.py b/scripts/plan_release.py new file mode 100644 index 0000000..70b6b38 --- /dev/null +++ b/scripts/plan_release.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +"""Derive the next release version and changelog section from merged commits. + +The release workflow refuses to guess a version. It requires `pyproject.toml` +and `CHANGELOG.md` to already carry the exact version being published, so some +step has to compute that version and write those files. This module is that +step, and it is deliberately a pure function of the git history plus the +release policy already declared in `pyproject.toml`. + +It replaces the version arithmetic that `python-semantic-release` used to do. +That tool cannot run here: GitPython 3.1.60 removed `Actor.name_email_regex`, +which every released version reads while loading its config, and the upstream +fix is unmerged. The arithmetic is small enough to own. + +Nothing here publishes, tags, pushes, or merges. It writes two files in the +working tree and prints what it decided. +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from dataclasses import dataclass +from datetime import date +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +REPOSITORY_URL = "https://github.com/OpenAdaptAI/openadapt-evals" + +_SEMVER_TAG = re.compile(r"^v(?P\d+)\.(?P\d+)\.(?P\d+)$") +_SUBJECT = re.compile( + r"^(?P[a-z]+)" + r"(?:\((?P[^)]*)\))?" + r"(?P!)?" + r": (?P.+)$" +) +_HEADING = re.compile(r"(?m)^## v\d+\.\d+\.\d+ \(\d{4}-\d{2}-\d{2}\)$") + +MINOR_TYPES = frozenset({"feat"}) +PATCH_TYPES = frozenset({"fix", "perf"}) +SECTIONS = ( + ("feat", "Features"), + ("fix", "Bug Fixes"), + ("perf", "Performance Improvements"), + ("refactor", "Refactoring"), + ("docs", "Documentation"), + ("build", "Build System"), + ("ci", "Continuous Integration"), + ("test", "Testing"), + ("style", "Styles"), + ("chore", "Chores"), +) + + +class ReleasePlanError(Exception): + """Raised when the history or the policy cannot produce an exact version.""" + + +@dataclass(frozen=True) +class Change: + """One parsed conventional commit.""" + + commit: str + type: str + scope: str + summary: str + breaking: bool + + +@dataclass(frozen=True) +class ReleasePlan: + """The exact next version and the section that documents it.""" + + previous_version: str + next_version: str + changes: tuple[Change, ...] + staged: bool = False + + @property + def released(self) -> bool: + return self.next_version != self.previous_version + + +def _git(root: Path, *arguments: str) -> str: + result = subprocess.run( + ["git", "-C", str(root), *arguments], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + ) + return result.stdout.strip() + + +def _policy(root: Path) -> tuple[bool, bool]: + text = (root / "pyproject.toml").read_text(encoding="utf-8") + return ( + "major_on_zero = true" in text, + "allow_zero_version = true" in text, + ) + + +def project_version(root: Path = ROOT) -> str: + text = (root / "pyproject.toml").read_text(encoding="utf-8") + match = re.search(r'(?m)^version = "(\d+\.\d+\.\d+)"$', text) + if match is None: + raise ReleasePlanError("pyproject.toml has no exact project version") + return match.group(1) + + +def latest_tag(root: Path = ROOT) -> str | None: + tags = [tag for tag in _git(root, "tag", "--list", "v*").splitlines() if _SEMVER_TAG.match(tag)] + if not tags: + return None + return max(tags, key=lambda tag: tuple(int(part) for part in tag[1:].split("."))) + + +def changes_since(reference: str | None, root: Path = ROOT) -> tuple[Change, ...]: + span = f"{reference}..HEAD" if reference else "HEAD" + raw = _git(root, "log", span, "--no-merges", "--format=%H%x1f%s") + changes: list[Change] = [] + for line in raw.splitlines(): + if not line: + continue + commit, _, subject = line.partition("\x1f") + match = _SUBJECT.match(subject) + if match is None: + continue + changes.append( + Change( + commit=commit, + type=match.group("type"), + scope=match.group("scope") or "", + summary=match.group("summary").strip(), + breaking=bool(match.group("breaking")), + ) + ) + return tuple(changes) + + +def next_version(current: str, changes: tuple[Change, ...], *, major_on_zero: bool) -> str: + major, minor, patch = (int(part) for part in current.split(".")) + breaking = any(change.breaking for change in changes) + feature = any(change.type in MINOR_TYPES for change in changes) + fix = any(change.type in PATCH_TYPES for change in changes) + if breaking and (major > 0 or major_on_zero): + return f"{major + 1}.0.0" + if breaking or feature: + return f"{major}.{minor + 1}.0" + if fix: + return f"{major}.{minor}.{patch + 1}" + return current + + +def plan(root: Path = ROOT) -> ReleasePlan: + major_on_zero, allow_zero_version = _policy(root) + current = project_version(root) + tag = latest_tag(root) + if tag is not None and tag[1:] != current: + return ReleasePlan( + previous_version=current, + next_version=current, + changes=(), + staged=True, + ) + if current.startswith("0.") and not allow_zero_version: + raise ReleasePlanError("pyproject.toml is on 0.x but allow_zero_version is not set") + changes = changes_since(tag, root) + return ReleasePlan( + previous_version=current, + next_version=next_version(current, changes, major_on_zero=major_on_zero), + changes=changes, + ) + + +def render_section(release: ReleasePlan, *, released_on: date) -> str: + lines = [f"## v{release.next_version} ({released_on.isoformat()})", ""] + for kind, heading in SECTIONS: + entries = [change for change in release.changes if change.type == kind] + if not entries: + continue + lines.extend([f"### {heading}", ""]) + for change in entries: + summary = change.summary[:1].upper() + change.summary[1:] + scope = f"**{change.scope}**: " if change.scope else "" + short = change.commit[:7] + link = f"[`{short}`]({REPOSITORY_URL}/commit/{change.commit})" + lines.append(f"- {scope}{summary} ({link})") + lines.append("") + return "\n".join(lines).rstrip() + "\n" + + +def write(release: ReleasePlan, *, released_on: date, root: Path = ROOT) -> None: + if not release.released: + raise ReleasePlanError("no releasable change since the last tag") + metadata_path = root / "pyproject.toml" + metadata = metadata_path.read_text(encoding="utf-8") + stamped = metadata.replace( + f'version = "{release.previous_version}"', + f'version = "{release.next_version}"', + 1, + ) + if stamped == metadata: + raise ReleasePlanError("pyproject.toml version could not be stamped") + metadata_path.write_text(stamped, encoding="utf-8") + + changelog_path = root / "CHANGELOG.md" + changelog = changelog_path.read_text(encoding="utf-8") + heading = _HEADING.search(changelog) + insertion = heading.start() if heading else len(changelog) + section = render_section(release, released_on=released_on) + changelog_path.write_text( + changelog[:insertion] + section + "\n\n" + changelog[insertion:], + encoding="utf-8", + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--write", action="store_true") + parser.add_argument("--released-on", default=None) + arguments = parser.parse_args(argv) + try: + release = plan() + except (ReleasePlanError, subprocess.CalledProcessError) as error: + print(f"release plan refused: {error}", file=sys.stderr) + return 2 + print(f"previous={release.previous_version}") + print(f"next={release.next_version}") + print(f"released={'true' if release.released else 'false'}") + print(f"staged={'true' if release.staged else 'false'}") + print(f"changes={len(release.changes)}") + if arguments.write and release.released: + released_on = ( + date.fromisoformat(arguments.released_on) if arguments.released_on else date.today() + ) + write(release, released_on=released_on) + print("wrote pyproject.toml and CHANGELOG.md") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_plan_release.py b/tests/test_plan_release.py new file mode 100644 index 0000000..8fc5e58 --- /dev/null +++ b/tests/test_plan_release.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import importlib.util +import re +import sys +from datetime import date +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "plan_release.py" +SPEC = importlib.util.spec_from_file_location("plan_release", SCRIPT) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +# `@dataclass` resolves annotations through `sys.modules[cls.__module__]`, so a +# module loaded straight from a path has to be registered before it executes. +sys.modules["plan_release"] = MODULE +SPEC.loader.exec_module(MODULE) + +# The release workflow greps CHANGELOG.md with exactly this pattern, so a +# section this module renders has to match it or the publish refuses. +RELEASE_HEADING = re.compile(r"^## v[0-9]+\.[0-9]+\.[0-9]+ \([0-9]{4}-[0-9]{2}-[0-9]{2}\)$") + + +def _change(kind: str, *, scope: str = "", breaking: bool = False) -> object: + return MODULE.Change( + commit="a" * 40, + type=kind, + scope=scope, + summary=f"{kind} something", + breaking=breaking, + ) + + +@pytest.mark.parametrize( + "current,kinds,breaking,major_on_zero,expected", + [ + ("0.92.0", (), False, False, "0.92.0"), + ("0.92.0", ("docs", "ci", "chore"), False, False, "0.92.0"), + ("0.92.0", ("fix",), False, False, "0.92.1"), + ("0.92.0", ("perf",), False, False, "0.92.1"), + ("0.92.0", ("feat",), False, False, "0.93.0"), + ("0.92.0", ("fix", "feat"), False, False, "0.93.0"), + # major_on_zero is false in this repository, so a breaking change on a + # 0.x line moves the minor, not the major. + ("0.92.0", ("feat",), True, False, "0.93.0"), + ("0.92.0", ("fix",), True, False, "0.93.0"), + ("0.92.0", ("feat",), True, True, "1.0.0"), + ("1.4.2", ("feat",), True, False, "2.0.0"), + ("1.4.2", ("feat",), False, False, "1.5.0"), + ("1.4.2", ("fix",), False, False, "1.4.3"), + ], +) +def test_next_version_follows_the_declared_policy( + current: str, + kinds: tuple[str, ...], + breaking: bool, + major_on_zero: bool, + expected: str, +) -> None: + changes = tuple(_change(kind, breaking=breaking and index == 0) for index, kind in enumerate(kinds)) + assert MODULE.next_version(current, changes, major_on_zero=major_on_zero) == expected + + +def test_repository_policy_is_the_one_this_module_assumes() -> None: + major_on_zero, allow_zero_version = MODULE._policy(ROOT) + assert major_on_zero is False + assert allow_zero_version is True + + +def test_rendered_section_matches_what_the_release_workflow_greps() -> None: + release = MODULE.ReleasePlan( + previous_version="0.92.0", + next_version="0.93.0", + changes=(_change("feat", scope="evidence"), _change("fix")), + ) + section = MODULE.render_section(release, released_on=date(2026, 8, 26)) + first = section.splitlines()[0] + + assert RELEASE_HEADING.match(first) + assert first == "## v0.93.0 (2026-08-26)" + assert "### Features" in section + assert "### Bug Fixes" in section + assert section.index("### Features") < section.index("### Bug Fixes") + assert "- **evidence**: Feat something" in section + assert "/commit/" + "a" * 40 in section + + +def test_render_omits_sections_with_no_commits() -> None: + release = MODULE.ReleasePlan( + previous_version="0.92.0", + next_version="0.92.1", + changes=(_change("fix"),), + ) + section = MODULE.render_section(release, released_on=date(2026, 8, 26)) + + assert "### Bug Fixes" in section + assert "### Features" not in section + assert "### Chores" not in section + + +def _repository(tmp_path: Path, *, version: str, changelog: str) -> Path: + (tmp_path / "pyproject.toml").write_text( + f'[project]\nname = "example"\nversion = "{version}"\n', + encoding="utf-8", + ) + (tmp_path / "CHANGELOG.md").write_text(changelog, encoding="utf-8") + return tmp_path + + +def test_write_stamps_the_version_and_puts_the_section_on_top(tmp_path: Path) -> None: + root = _repository( + tmp_path, + version="0.92.0", + changelog="# CHANGELOG\n\n\n## v0.92.0 (2026-08-22)\n\nolder text\n", + ) + release = MODULE.ReleasePlan( + previous_version="0.92.0", + next_version="0.93.0", + changes=(_change("feat"),), + ) + + MODULE.write(release, released_on=date(2026, 8, 26), root=root) + + metadata = (root / "pyproject.toml").read_text(encoding="utf-8") + changelog = (root / "CHANGELOG.md").read_text(encoding="utf-8") + assert 'version = "0.93.0"' in metadata + assert 'version = "0.92.0"' not in metadata + assert changelog.index("## v0.93.0 (2026-08-26)") < changelog.index("## v0.92.0 (2026-08-22)") + assert changelog.startswith("# CHANGELOG") + assert "older text" in changelog + + +def test_write_refuses_when_nothing_is_releasable(tmp_path: Path) -> None: + root = _repository(tmp_path, version="0.92.0", changelog="# CHANGELOG\n") + release = MODULE.ReleasePlan( + previous_version="0.92.0", + next_version="0.92.0", + changes=(_change("docs"),), + ) + + with pytest.raises(MODULE.ReleasePlanError, match="no releasable change"): + MODULE.write(release, released_on=date(2026, 8, 26), root=root) + + +def test_a_staged_release_is_a_quiet_no_op_not_a_failure() -> None: + staged = MODULE.ReleasePlan( + previous_version="0.93.0", + next_version="0.93.0", + changes=(), + staged=True, + ) + + assert staged.released is False + assert staged.staged is True diff --git a/tests/test_workflow_identity_contract.py b/tests/test_workflow_identity_contract.py index ab468cb..c708e50 100644 --- a/tests/test_workflow_identity_contract.py +++ b/tests/test_workflow_identity_contract.py @@ -44,6 +44,7 @@ def test_manual_non_lifecycle_workflows_reject_the_lifecycle_app() -> None: for name, job in ( ("complex-visual.yml", "headed-pixel-campaign"), ("evidence-freshness.yml", "freshness"), + ("propose-release.yml", "propose-release"), ): workflow = _workflow(name) assert "reject-lifecycle-app:" in workflow @@ -59,6 +60,32 @@ def test_manual_non_lifecycle_workflows_reject_the_lifecycle_app() -> None: assert "github.event_name" in group.group(1) +def test_release_proposal_can_only_propose() -> None: + workflow = _workflow("propose-release.yml") + + assert "scripts/plan_release.py" in workflow + assert "scripts/verify_release_lock.py --write" in workflow + assert "gh pr create" in workflow + assert "vars.OPENADAPT_LIFECYCLE_APP_ID" in workflow + assert "vars.OPENADAPT_LIFECYCLE_INSTALLATION_ID" in workflow + assert "secrets.OPENADAPT_LIFECYCLE_APP_PRIVATE_KEY" in workflow + assert "permission-pull-requests: write" in workflow + + # It proposes. It must not be able to land, tag, or publish what it wrote. + assert "permission-contents: write" not in workflow + assert not re.search(r"git\s+push[^\n]*(?:refs/heads/)?main", workflow) + assert "gh pr merge" not in workflow + assert "--auto" not in workflow + assert "git tag" not in workflow + assert "gh release" not in workflow + assert "pypa/gh-action-pypi-publish" not in workflow + assert "OPENADAPT_RELEASE_APP_ID" not in workflow + assert "OPENADAPT_RELEASE_APP_PRIVATE_KEY" not in workflow + + # It writes only the three release-metadata files. + assert "git add pyproject.toml uv.lock CHANGELOG.md" in workflow + + def test_legacy_docs_pat_dispatch_is_removed() -> None: assert not (WORKFLOWS / "notify-docs.yml").exists() combined = "\n".join(