Release process¶
Changes to the plugin go through feat/* / fix/* branches off main and land
via PR. Releases are cut from the changelog fragments accumulated under
.changes/unreleased/.
The two-stage changelog gate¶
flowchart LR
PR[Implementation PR] -->|touches plugins/steer/** or a version-bearing manifest| ENTRY["Add a fragment<br/>.changes/unreleased/*.yaml"]
ENTRY --> MERGE[Merge to main]
MERGE --> REPEAT{More changes?}
REPEAT -->|yes| PR
REPEAT -->|cut release| RELEASE["Release PR:<br/>changie batch -> .changes/vX.Y.Z.md<br/>changie merge -> CHANGELOG.md + manifests"]
- Every change under
plugins/steer/- or to the root.github/plugin/marketplace.json, the one version-bearing manifest that sits outside it - needs a changelog fragment: one YAML file per change under.changes/unreleased/, written by hand.CHANGELOG.mditself is generated bychangie mergeat release and must never be edited directly.check_changelog.py --baseenforces this on PRs, and it is deny-by-default: everything the plugin ships counts, and the exemptions are enumerated in the script with a reason each (tests/anywhere,evals/, the plugin's maintainerREADME.md, andplugins/steer/.claude/). -
Implementation PRs do not bump
plugins/steer/.claude-plugin/plugin.json. Theversionbump happens once, at release, written bychangie mergeacross all three manifests - so a stream of PRs cuts one coherent release instead of a bump per PR. -
Never write a next-version number in an implementation PR. It merges before the release that names it, so the number is always a guess. This bites hardest in the spec-spine migration ledger (
templates/reference/MIGRATIONS.md), whose entries are keyed by the version that introduced them:/steer:setup syncskips every entry at or below a repo'sspec/.versionstamp, so an entry keyed below the release it actually shipped in is silently skipped by every repo stamped in between - the migration never runs and nothing reports it. Author ledger entries as### [Unreleased] - <what>, the same convention the changelog uses; the release PR renames the heading alongside the manifest bump.check_plugin.pyfails the build on any ledger heading ahead ofplugin.json, andcheck_migrations.py(wired intoplugin-check) enforces the rest of the ledger's shape - entry structure, the required fields, newest-first ordering, and a deep pass over[Unreleased]entries - so a guess cannot reachmain.
check_changelog.py also validates that plugin.json's version equals the newest
released heading and that released headings are in descending semver order.
Before the cut: drive the audit to convergence¶
The release skills run a deep pre-release audit and block on any
release-stopping finding. Because each fix changes the tree the audit reads, a
single pass rarely settles it. The repo-local /audit-loop helper runs that same
audit in a loop - audit, fix in-tree, re-gate, re-audit - until a round comes back
clean, accumulating every round as a commit on one branch and one PR. Merge that
PR, then cut the release; its audit should pass in a single pass.
The audit's mechanical parts are machinery, not prose, so they cannot be misremembered:
scripts/release_preflight.pycomputes the preconditions - clean tree, base current withorigin/main, pending fragments present, manifests in agreement, thevX.Y.Zanchor tag for the last release, deployed-docs freshness, and the upstreamvalidator-compatjob - and prints[ok]/[blocker]/[high]/[warn]markers. The skills inject its output at invocation via dynamic context, so the facts arrive with the skill body.- The saved
pre-release-auditworkflow (.claude/workflows/) runs the judgment review: one read-only reviewer per coherence dimension plus the documentation reviewer, scoped to the release delta, with a failed dispatch retried once, cross-dimension dedupe, and one verifier per finding before anything becomes a ledger candidate. It also re-verifies every open ledger row whose file has changed since the row was confirmed, so a finding the tree already repaired is closed byscripts/audit_ledger.py reconcileinstead of lingering as open. scripts/release_cut.pyperforms the cut itself:changie batchfolds the pending fragments into.changes/vX.Y.Z.mdandchangie mergereassemblesCHANGELOG.mdand bumps the three version-bearing manifests; the script then renames migration-ledger entries inside## Entries(never the authoring stub) and re-validates the release invariant - refusing to start when a precondition does not hold.
All three release skills are user-invoked only (disable-model-invocation), so a
release is always a human's timing decision.
Publication is automatic¶
Cutting the release PR is the last manual step. When a release PR (the
plugin.json version bump) merges to main,
.github/workflows/release-publish.yml fires - it triggers only on pushes that
touch plugin.json and acts only when the parsed version field changed - and
creates the vX.Y.Z git tag plus the GitHub Release. The body is that version's
changelog entries from .changes/vX.Y.Z.md - reflowed by
scripts/reflow_release_notes.py, because a Release body renders in the same
comment mode as a PR comment, where every newline becomes a <br> and the
changelog's 80-column authoring wraps would land mid-sentence - followed by
GitHub's auto-generated "What's Changed" (merged-PR list, contributors, compare
link) via --generate-notes. Runs are serialised under one concurrency group and
never cancelled, a pre-existing tag that points at a different commit fails the
run instead of being reused, and every publish ends by asserting that the tag
resolves to the intended commit. It is idempotent and re-runnable through
workflow_dispatch, so a failed run can simply be re-run:
Re-publishing an older version this way tags the commit that introduced that
version on main, not today's head, and does not take the "Latest" badge.
Two other post-merge runs are worth watching: docs-deploy.yml publishes the
documentation site from main (a red run leaves the live site stale), and the
e2e suite is local-only - run mise run e2e before a substantive cut if you
want the skill-level signal. The model-graded routing evals are local-only for the
same reason: mise run evals is deliberately outside ci and off the PR path, and
the release path is where it is meant to run.
Because the tag is created here, git describe --tags stays an accurate anchor
for the next release's diff - nothing about tagging is manual.
What does NOT need a changelog entry¶
Changes confined to CLAUDE.md, docs/, or .claude/ ship nothing in the
plugin and need no entry - this includes the documentation site itself.
Before you push¶
mise run check # fast gate: lint, typecheck, plugin-check, actions, actions-security, shell, docs:check (pre-commit superset)
mise run ci # full gate: adds fixtures, test, hooktests, version-scan, delivery-gates
mise run ci is exactly what CI runs. See AUTHORING.md
for the per-change "what to run" matrix, or use the repo-local /preflight
helper.