From 5e75804c79c109cea665045e0bbac340f0a73dd5 Mon Sep 17 00:00:00 2001 From: Jim Cresswell Date: Thu, 18 Jun 2026 14:42:53 +0100 Subject: [PATCH] ci(release): make releases PR-merge-only and authenticate via client-id Per owner direction: release creation originates ONLY from a merge to main. Remove the manual workflow_dispatch trigger and the forced-increment / manual-major path from the release workflow; a breaking marker still stands the auto-release down, and the rare major is cut by a human engineer outside this repo's automation. audit_release_workflow now FORBIDS workflow_dispatch (it previously required it), with tests updated. Switch actions/create-github-app-token to client-id (RELEASE_APP_CLIENT_ID) to clear the app-id deprecation. Refresh the governance doc to the verified GitHub state: required status checks (Quality gates, Secret scanning, CodeQL, SonarCloud) and a v* tag ruleset are now enforced; only the Code Quality org preview remains. Scrub the predicted version number from continuity and record that continuous release is verified and PR-merge-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- .agent/memory/operational/repo-continuity.md | 45 ++++++++---------- .github/workflows/release.yml | 50 ++++++++------------ README.md | 22 ++++----- docs/dev-tooling.md | 19 ++++---- docs/repository-governance.md | 44 +++++++---------- tests/test_repo_audit.py | 19 ++++---- tools/repo_audit.py | 7 +-- 7 files changed, 92 insertions(+), 114 deletions(-) diff --git a/.agent/memory/operational/repo-continuity.md b/.agent/memory/operational/repo-continuity.md index 5ba9120..d6ef409 100644 --- a/.agent/memory/operational/repo-continuity.md +++ b/.agent/memory/operational/repo-continuity.md @@ -1,17 +1,16 @@ # Repo Continuity **Last refreshed**: 2026-06-18 (final+) — the "highest proportionate bar" program -is COMPLETE (`v0.3.0` cut). **NEW: the release model is now CONTINUOUS release on -merge (PR #44)** — the release-PR/`--auto` pattern is retired. After CI passes on -`main`, the Oak Semantic Release Bot (app 2995796, a ruleset bypass actor) bumps + -tags + pushes to `main` and publishes the Release; every qualifying merge advances -the version. **Not yet live-verified:** PR #44's own merge skipped CI (its squash -message contained the literal CI-skip token in prose), so no release fired and -`main` is still `0.3.0`. The next clean merge (message free of the CI-skip token) -will cut the first continuous release (**v0.4.0**, the computed minor from #44's -feat) and verify the bot's push-to-protected-main. Do NOT force it via -`workflow_dispatch` — the harness blocks an agent-forced increment, and the owner -wants the computed one. +is COMPLETE. **The release model is now CONTINUOUS release on merge (PR #44), +PR-merge-ONLY** — the release-PR/`--auto` pattern AND the manual `workflow_dispatch` +are both gone (owner: releases originate only from a merge to `main`; the rare +major is cut by a human outside this repo's automation). After CI passes on `main`, +the Oak Semantic Release Bot (app 2995796, a ruleset bypass actor) bumps + tags + +pushes to `main` and publishes the Release. **Live-verified** (a clean merge cut a +release end-to-end; the bot's push-to-protected-`main` works). Don't write specific +future version numbers in durable docs — they go stale; describe the mechanism. +Required status checks (Quality gates, Secret scanning, CodeQL, SonarCloud) and a +`v*` tag ruleset are now enforced in GitHub settings. (Earlier this session, pre-#44:) `main` is green. Landed: **F6** agent-hook hardening (#37, owner chose recurse-and-check), **Tier 3** branch coverage + floor 86 (#38), Hypothesis @@ -125,20 +124,16 @@ over-block). Full state in the ## Next Safe Step -- **Verify the first continuous release.** When this closeout PR (or any clean - merge) lands, watch the `Release` `workflow_run` run: it should bump to - **v0.4.0**, push the bump commit + tag to `main`, and publish the Release. If - it fails, the likely cause is the bot app lacking `Contents: write` (the one - unverified prerequisite) — main stays intact; fix the app perms and re-merge. -- **Remaining work otherwise:** (1) **owner-only GitHub settings** in - `docs/repository-governance.md` (required status checks, Code Quality preview, - `v*` tag protection — the release-bot secrets + bypass actor are already set); - (2) **F6 residuals** (glued shell operators like `ok|git`, bare subshells, and - heredoc-prose over-block) — deferred, the glued-operator one needs a quote-aware - raw tokeniser; (3) **PyPI publishing guide** for template adopters (owner asked; - docs-only, deferred — see Open Side-Tasks); (4) **Tier 4** stays deferred. - Normal PRs merge with `gh pr merge --squash --delete-branch` once green — - keep the literal CI-skip token out of the message or the release won't fire. +- **Release automation is done and verified** (continuous, PR-merge-only). No + queued release work. Remaining: (1) **owner-only GitHub setting** — enable the + GitHub Code Quality org preview (the required checks + `v*` tag ruleset + + release-bot secrets/bypass are all in place); (2) **PyPI publishing guide** for + adopters (in progress this session — docs-only); (3) **F6 residuals** (glued + shell operators like `ok|git`, bare subshells, heredoc-prose over-block) — + deferred, the glued-operator one needs a quote-aware raw tokeniser; (4) **Tier 4** + stays deferred. Normal PRs merge with `gh pr merge --squash --delete-branch` + once green — keep the literal CI-skip token out of the commit/PR message or the + squash-merge skips CI and no release fires. ## Open Side-Tasks diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dff6c05..77e340b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,29 +1,24 @@ name: Release -# Continuous release on merge to main. After CI passes on main, compute the -# Conventional Commits increment (custom policy via tools/release_increment.py), -# bump pyproject + uv.lock + CHANGELOG with Commitizen, then commit + tag and push -# straight to the protected `main` branch using the Oak Semantic Release Bot -# GitHub App token (a ruleset bypass actor). The bump commit carries `[skip ci]` -# so it does not re-trigger CI and loop. Every qualifying merge therefore advances -# the version and publishes a GitHub Release with the wheel + sdist. +# Continuous release on merge to main, and ONLY from a merge to main. After CI +# passes on main, compute the Conventional Commits increment (custom policy via +# tools/release_increment.py), bump pyproject + uv.lock + CHANGELOG with +# Commitizen, then commit + tag and push straight to the protected `main` branch +# using the Oak Semantic Release Bot GitHub App token (a ruleset bypass actor). +# The bump commit carries `[skip ci]` so it does not re-trigger CI and loop. Every +# qualifying merge therefore advances the version and publishes a GitHub Release +# with the wheel + sdist. # -# Major releases stay manual: a `!`/`BREAKING CHANGE` marker makes the auto-release -# stand down (and tools/prevent_accidental_major.py blocks the marker at commit -# time); cut the major deliberately via "Run workflow" with increment = MAJOR. +# There is deliberately no manual trigger: releases originate only from the PR +# workflow. Major releases are NOT automated — a `!`/`BREAKING CHANGE` marker +# makes the auto-release stand down (and tools/prevent_accidental_major.py blocks +# the marker at commit time); the rare major is cut by a human engineer outside +# this repo's automation. on: workflow_run: workflows: [CI] types: [completed] branches: [main] - workflow_dispatch: - inputs: - increment: - description: "Release increment to force (use MAJOR for a deliberate major release)" - type: choice - options: [MAJOR, MINOR, PATCH] - default: MAJOR - required: true # Least privilege by default; the job widens to what it needs. permissions: @@ -37,10 +32,8 @@ concurrency: jobs: release: name: Release - # Only release after CI succeeded on main, or on a deliberate manual dispatch. - if: >- - github.event_name == 'workflow_dispatch' - || github.event.workflow_run.conclusion == 'success' + # Only release after CI succeeded on main. + if: github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest permissions: contents: write @@ -51,7 +44,7 @@ jobs: id: app-token uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 with: - app-id: ${{ secrets.RELEASE_APP_ID }} + client-id: ${{ secrets.RELEASE_APP_CLIENT_ID }} private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} - name: Check out full history and tags @@ -78,17 +71,12 @@ jobs: - name: Compute the release increment id: increment - env: - FORCED_INCREMENT: ${{ inputs.increment }} - EVENT_NAME: ${{ github.event_name }} run: | set -euo pipefail version=$(uv run python -c "import tomllib, pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])") prev="v${version}" echo "previous=${prev}" >> "$GITHUB_OUTPUT" - if [ "${EVENT_NAME}" = "workflow_dispatch" ]; then - increment="${FORCED_INCREMENT}" - elif git rev-parse -q --verify "refs/tags/${prev}" >/dev/null; then + if git rev-parse -q --verify "refs/tags/${prev}" >/dev/null; then # cz_conventional_commits ignores [tool.commitizen].bump_map, so we # compute the increment from that single policy source ourselves. increment="$(git log "${prev}..HEAD" --pretty=format:'%B%x00' | uv run python tools/release_increment.py)" @@ -103,14 +91,14 @@ jobs: fi case "${increment}" in BREAKING) - echo "::warning::Breaking change since ${prev}; cut a major via Run workflow (increment = MAJOR). Auto-release stood down." + echo "::warning::Breaking change since ${prev}; auto-release stood down. A major is cut by a human engineer outside this repo's automation." echo "release=false" >> "$GITHUB_OUTPUT" ;; NONE) echo "No releasable commits since ${prev}." echo "release=false" >> "$GITHUB_OUTPUT" ;; - MAJOR | MINOR | PATCH) + MINOR | PATCH) echo "increment=${increment}" >> "$GITHUB_OUTPUT" echo "mode=bump" >> "$GITHUB_OUTPUT" echo "release=true" >> "$GITHUB_OUTPUT" diff --git a/README.md b/README.md index 8acb415..434098f 100644 --- a/README.md +++ b/README.md @@ -174,12 +174,13 @@ uv run cz check --message "feat: add truthful commit-msg enforcement" ## Releases -Releases are **continuous**: every merge to `main` advances the version. After +Releases are **continuous**, and originate **only** from a merge to `main`. After CI passes on `main`, the Release workflow computes the Conventional Commits increment, and the **Oak Semantic Release Bot** (a `main`-ruleset bypass actor) bumps `pyproject.toml`, `uv.lock`, and `CHANGELOG.md`, commits + tags `vX.Y.Z`, pushes straight to `main`, and publishes a GitHub Release with the built wheel + -sdist attached. The bump commit carries `[skip ci]` so it does not loop. +sdist attached. The bump commit carries `[skip ci]` so it does not loop. There is +no manual release trigger. The bump level is computed by Commitizen with this repo's policy: @@ -187,17 +188,16 @@ The bump level is computed by Commitizen with this repo's policy: | --- | --- | | `feat`, `fix` | minor | | everything else (`chore`, `docs`, `perf`, `refactor`, `build`, `ci`, …) | patch | -| `!` / `BREAKING CHANGE` | no auto-release — a **major** is required | +| `!` / `BREAKING CHANGE` | no auto-release | -**Major versions are manual.** A breaking marker makes the auto-release stand -down; cut the major deliberately via the Release workflow's *Run workflow* button -(`increment = MAJOR`). To stop a breaking marker landing by accident (which would -silently halt the auto-release), the `prevent-accidental-major` commit-msg hook -rejects `type!:` / `BREAKING CHANGE` in commits. Releases publish to GitHub -Releases only (no PyPI). +**Major versions are not automated.** A breaking marker makes the auto-release +stand down, and the `prevent-accidental-major` commit-msg hook rejects `type!:` / +`BREAKING CHANGE` in commits so one cannot land by accident. On the rare occasion +a major is warranted, a human engineer cuts it strategically, outside this repo's +automation. Releases publish to GitHub Releases only (not PyPI). -The workflow needs the `RELEASE_APP_ID` / `RELEASE_APP_PRIVATE_KEY` secrets and -the bot added as a ruleset bypass actor — see +The workflow needs the `RELEASE_APP_CLIENT_ID` / `RELEASE_APP_PRIVATE_KEY` +secrets and the bot added as a ruleset bypass actor — see [docs/repository-governance.md](docs/repository-governance.md). ## Governance diff --git a/docs/dev-tooling.md b/docs/dev-tooling.md index a5c5d94..f420041 100644 --- a/docs/dev-tooling.md +++ b/docs/dev-tooling.md @@ -196,17 +196,20 @@ uv run cz check --message "docs: explain the Commitizen workflow" reads it and computes the increment, which the workflow applies via `cz bump --increment` - the **Oak Semantic Release Bot** GitHub App (a `main`-ruleset bypass actor) - authenticates via `actions/create-github-app-token` (secrets `RELEASE_APP_ID` - - `RELEASE_APP_PRIVATE_KEY`); the workflow bumps `pyproject.toml`, `uv.lock`, - and `CHANGELOG.md`, commits + tags `vX.Y.Z`, pushes straight to `main`, then - `uv build`s and creates the GitHub Release with the wheel + sdist attached + authenticates via `actions/create-github-app-token` (secrets + `RELEASE_APP_CLIENT_ID` and `RELEASE_APP_PRIVATE_KEY`); the workflow bumps + `pyproject.toml`, `uv.lock`, and `CHANGELOG.md`, commits + tags `vX.Y.Z`, pushes + straight to `main`, then `uv build`s and creates the GitHub Release with the + wheel + sdist attached - the bump commit is marked `[skip ci]` so pushing it to `main` does not re-trigger CI → Release (an infinite loop) -- **major releases are manual**: a `!`/`BREAKING CHANGE` marker makes the - auto-release stand down; cut the major via the workflow's `workflow_dispatch` - (`increment = MAJOR`). The `prevent-accidental-major` commit-msg hook +- there is **no manual release trigger** (no `workflow_dispatch`): releases + originate only from a merge to `main`, which `audit_release_workflow` enforces +- **major releases are not automated**: a `!`/`BREAKING CHANGE` marker makes the + auto-release stand down, and the `prevent-accidental-major` commit-msg hook (`tools/prevent_accidental_major.py`) rejects the marker at commit time so it - cannot land by accident and silently halt the auto-release + cannot land by accident and silently halt releases. The rare major is cut by a + human engineer outside this repo's automation - the version stays committed in the tree; releases publish to GitHub Releases only (no PyPI). `audit_release_workflow` keeps the workflow (trigger, `cz bump`, the increment tool, the `[skip ci]` loop guard) and the bump policy honest diff --git a/docs/repository-governance.md b/docs/repository-governance.md index f08421b..fc4131d 100644 --- a/docs/repository-governance.md +++ b/docs/repository-governance.md @@ -10,43 +10,33 @@ and cannot be expressed in the repo. This page is the canonical owner-action checklist for them. An adopter of this template should work through it once for their own repository. -## Already enforced by the `main` ruleset +## Already enforced in GitHub settings -The active "Protect default branch" ruleset already requires: +The active rulesets enforce: -- a **pull request** to change `main` (no direct pushes); -- the **CodeQL `code_quality`** check to pass before merge; -- **no branch deletion** and **no force-push** (non-fast-forward) on `main`. +- a **pull request** to change `main` (no direct pushes), and **no branch + deletion** or **force-push** on `main`; +- **required status checks** before merge: `Quality gates`, `Secret scanning + (gitleaks)`, `CodeQL`, and `SonarCloud Code Analysis` — so `main` cannot go red + and still merge; +- **release-tag protection**: a `v*` tag ruleset (default rules) so release tags + cannot be force-moved or deleted. -## Owner actions outstanding - -These are settings changes a repository owner must make by hand. Each closes a -gap the in-repo gates structurally cannot. - -1. **Make CI a real merge gate (highest priority).** - Add **`Quality gates`** and **`Secret scanning (gitleaks)`** to the `main` - ruleset's *required status checks*. Today the ruleset requires a PR and the - CodeQL check, but **not** these two — so `main` can go red and a PR can still - merge. This is the single biggest enforcement gap. +The **Oak Semantic Release Bot** GitHub App is wired for continuous release: the +`RELEASE_APP_CLIENT_ID` / `RELEASE_APP_PRIVATE_KEY` repo secrets are set and the +app is a **bypass actor** on the `main` ruleset, so it can push the bump commit + +tag to protected `main`. If the app's key is rotated, or it is removed as a +bypass actor, the release push will be rejected. See +[Releases](dev-tooling.md#releases). -2. **Release bot (done — keep it wired).** - Continuous release pushes the bump commit + tag straight to protected `main` - via the **Oak Semantic Release Bot** GitHub App. This needs the - `RELEASE_APP_ID` / `RELEASE_APP_PRIVATE_KEY` repo secrets **and** the app added - as a **bypass actor** on the `main` ruleset (both are in place). If the app's - key is rotated or it is removed as a bypass actor, the release push will be - rejected. See [Releases](dev-tooling.md#releases). +## Owner actions outstanding -3. **Enable GitHub Code Quality (organisation preview).** +1. **Enable GitHub Code Quality (organisation preview).** Coverage is uploaded as Cobertura on every PR, but the upload runs with `fail-on-error: false`, so until the org enables the Code Quality preview it is a harmless no-op rather than a visible PR signal. See [Coverage reporting](dev-tooling.md#coverage-reporting). -4. **Protect release tags.** - Add a tag ruleset for `v*` so release tags cannot be force-moved or deleted. - There is currently no tag ruleset; only the branch ruleset above exists. - ## Why these stay manual Repository and organisation settings are owner-controlled and live outside the diff --git a/tests/test_repo_audit.py b/tests/test_repo_audit.py index 34eb1ee..b10c2ba 100644 --- a/tests/test_repo_audit.py +++ b/tests/test_repo_audit.py @@ -853,16 +853,16 @@ def test_audit_ci_workflow_reports_missing_gate_command_independently(tmp_path: def _release_workflow_yaml( *, - triggers: str = "both", + triggers: str = "run", with_cz_bump: bool = True, with_increment_tool: bool = True, with_skip_ci: bool = True, ) -> str: on_block = { - "both": "on:\n workflow_run:\n workflows: [CI]\n types: [completed]\n" + "run": "on:\n workflow_run:\n workflows: [CI]\n types: [completed]\n", + "with-dispatch": "on:\n workflow_run:\n workflows: [CI]\n types: [completed]\n" " workflow_dispatch:\n", - "run-only": "on:\n workflow_run:\n workflows: [CI]\n types: [completed]\n", - "dispatch-only": "on:\n workflow_dispatch:\n", + "push-only": "on:\n push:\n branches: [main]\n", }[triggers] steps = "" if with_increment_tool: @@ -926,20 +926,21 @@ def test_audit_release_workflow_rejects_a_non_mapping_workflow(tmp_path: Path) - def test_audit_release_workflow_requires_a_workflow_run_trigger(tmp_path: Path) -> None: - _write_release_world(tmp_path, workflow=_release_workflow_yaml(triggers="dispatch-only")) + _write_release_world(tmp_path, workflow=_release_workflow_yaml(triggers="push-only")) joined = "\n".join(subject.audit_release_workflow(tmp_path)) assert "must trigger on workflow_run" in joined - assert "must offer workflow_dispatch" not in joined + assert "must NOT offer workflow_dispatch" not in joined -def test_audit_release_workflow_requires_workflow_dispatch_for_manual_major(tmp_path: Path) -> None: - _write_release_world(tmp_path, workflow=_release_workflow_yaml(triggers="run-only")) +def test_audit_release_workflow_rejects_a_workflow_dispatch_trigger(tmp_path: Path) -> None: + # Releases originate only from a merge to main; a manual dispatch is forbidden. + _write_release_world(tmp_path, workflow=_release_workflow_yaml(triggers="with-dispatch")) joined = "\n".join(subject.audit_release_workflow(tmp_path)) - assert "must offer workflow_dispatch for manual major releases" in joined + assert "must NOT offer workflow_dispatch" in joined assert "must trigger on workflow_run" not in joined diff --git a/tools/repo_audit.py b/tools/repo_audit.py index 3398248..9cca1d8 100644 --- a/tools/repo_audit.py +++ b/tools/repo_audit.py @@ -705,10 +705,11 @@ def audit_release_workflow(root: Path) -> list[str]: require( failures, check, - "workflow_dispatch" in triggers, + "workflow_dispatch" not in triggers, ( - ".github/workflows/release.yml must offer workflow_dispatch for manual " - "major releases" + ".github/workflows/release.yml must NOT offer workflow_dispatch: releases " + "originate only from a merge to main; a major is cut by a human outside " + "this repo's automation" ), ) run_commands = _workflow_run_commands(mapping)