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
45 changes: 20 additions & 25 deletions .agent/memory/operational/repo-continuity.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 <n> --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 <n> --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

Expand Down
50 changes: 19 additions & 31 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)"
Expand All @@ -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"
Expand Down
22 changes: 11 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,30 +174,30 @@ 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:

| Commit type(s) | Bump |
| --- | --- |
| `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
Expand Down
19 changes: 11 additions & 8 deletions docs/dev-tooling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 17 additions & 27 deletions docs/repository-governance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 10 additions & 9 deletions tests/test_repo_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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


Expand Down
7 changes: 4 additions & 3 deletions tools/repo_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down