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(