Skip to content

release skill

Routed skill

The single permitted path to a version tag, for any target declared in the project's .codearbiter/release-targets.md. Routed to when the user invokes /release on a non-default branch with a green suite.

How it is used

The orchestrator routes to this skill from an owning command. It is not a direct user entry point.

This is the only sanctioned path to a version tag, invoked by the release command on a non-default branch with a green suite. It carries no knowledge of any particular project: the tag prefix, the manifests, the changelog, the payload scope, and any pre-tag checks are all read from the project’s declared .codearbiter/release-targets.md, so the same skill serves a single-artifact repository and a monorepo without a repo-local variant.

It resolves the last tag within the selected target’s own series rather than using a bare git describe, because in a multi-artifact repository the nearest tag may belong to a sibling. It then derives the version bump from the Conventional Commits history in that target’s payload, rolls the qualifying commits into the declared changelog, composes an annotated tag locally, and — only once you authorize it — pushes the tag and publishes it as a public release using that same changelog section as its notes.

  1. Resolve the declared row for the target, scope the commit window to its payload, and confirm the last tag belongs to its own series.
  2. Derive the version bump mechanically from that window, confirm it against every declared manifest, and roll the qualifying commits into a new changelog section.
  3. Update the declared surfaces and run the row’s declared pre-tag checks in order — check-only, so one that mutates the tree stops the release.
  4. Compose the annotated tag locally and report the target, the version, the bump rationale, and the tag — without publishing anything yet.
  5. On your explicit authorization, push the tag, create the public release from the same changelog section, and read the result back to confirm it actually published.

A completed run leaves a pushed tag and a confirmed, non-draft public release whose notes are exactly the changelog section composed earlier. Without your authorization, the tag and changelog stay staged locally and nothing leaves the repository. A project with no declared target file enters the back-fill lane instead, which proposes a row and writes nothing unconfirmed.

GateWhenEffect
declared-target resolutionbefore any read or writeevery fact about the release comes from the project’s declared release-targets file; an unrecognised or ambiguous target name stops rather than resolving to a guess
version derivationbefore taggingthe version bump is derived mechanically from the target’s own payload commits, not guessed, and must agree with every declared manifest before anything is tagged
check-only pre-tag commandsafter surfaces are updated, before the tagthe row’s declared checks run in order and stop at the first failure; a check that mutates the working tree blocks, because a check is not allowed to be a fixer
publication authorizationafter the local tag is composedpushing the tag and creating the public release both wait for your explicit go-ahead — nothing about the tag composition authorizes publishing it
Source — plugins/ca/skills/release/SKILL.md (v2.11.0)
---
name: release
description: The single permitted path to a version tag, for any target declared in the project's .codearbiter/release-targets.md. Routed to when the user invokes /release on a non-default branch with a green suite. Takes the declared target as its one argument, derives the SemVer bump from Conventional-Commits history since that target's last tag, rolls the commits into that target's CHANGELOG, writes an annotated tag in that target's namespace, and on authorization publishes it as a GitHub Release with the changelog section as its notes. A release commit, if needed, routes through commit-gate; the tag and Release are never published without explicit authorization.
---
# release
The single permitted path to a version tag. Routed to when the user invokes `/release [target]`. Derive the bump from the commit log, update the changelog, tag — nothing more.
**One command, any number of declared targets.** A project declares one or more release targets in `${CLAUDE_PROJECT_DIR}/.codearbiter/release-targets.md` (grammar and parser contract: `${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py`'s module docstring). `/release` takes the target's name as its only argument. When `$TARGET` is omitted and the declared file names exactly one target, that target is used — a single-target project's bare `/release` behaves exactly as it always has. When more than one target is declared, `$TARGET` is required; STOP and ask rather than guessing which one a bare invocation meant. **Resolve the omitted-single-target case mechanically, never by assumption** (MEDIUM, adversarial review 2026-07-31: `tag-prefix` itself takes `$TARGET` as a REQUIRED positional argument and has no way to express "the implicit one", so naming it here was not itself enough — the mechanical step that turns an omitted target into a concrete name before `tag-prefix` is ever called has to be spelled out too): run `"$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py" list-targets` first — the sanctioned enumeration, through the same tested grammar `tag-prefix` already reads, rather than a by-eye scan of the delimiter block. Exactly one printed line confirms which name `$TARGET` is; more than one is the multi-target STOP above, restated by the tool rather than assumed. There is deliberately no second command per target: N commands would be N public surfaces to govern, catalog, and carry, for one operation whose only difference is which declared row it reads.
Every phase below is written once, against that row. Nothing in this skill is per-target prose.
**Interpreter convention, stated once and applying to every helper invocation in this file** (A-3.6). `python3` is not universally present — a Windows consumer commonly has `python` on PATH and no `python3` at all, and a literal `python3` spelling fails on every invocation at once there.
**Resolve the interpreter ONCE, by presence, before the first invocation:**
```sh
PY=python3; command -v python3 >/dev/null 2>&1 || PY=python
```
Every helper invocation below is then spelled `"$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/<script>" <args>` literally, one spelling throughout — including the inline `"$PY" -c "…"` snippets. Two spellings for one thing invites reading the difference as meaningful (blind exercise run 14 flagged exactly that when only three steps used `"$PY"` and fifteen still said `python3`).
**Both quotes are load-bearing, and the second one is the easier to lose** (HIGH-1, blind exercise run 16). Quoting only the interpreter — `"$PY" ${CLAUDE_PLUGIN_ROOT}/hooks/<script>` — leaves the script path exposed to word splitting, and a plugin root containing a space is an ordinary Windows install (`C:\Users\First Last\.claude\plugins\…`, since an account name with a space is unremarkable). On such a host the path splits at the space, Python is handed a truncated filename, and EVERY step of this lane fails at once: target resolution, `last-tag`, `classify-window`, `check-manifests`, `classify`, `notes-match`. The operator's only diagnostic is `can't open file '…\First'`, which names nothing recognisable. The same applies to any `${CLAUDE_PROJECT_DIR}`-rooted path passed as an argument. The one deliberate exception is `$PAYLOAD`, which is a git pathspec that MUST word-split — see its own note under "Targets".
**MUST NOT spell them `python3 "<script>" … || python "<script>" …`.** `||` branches on the EXIT CODE, and it cannot distinguish "no such interpreter" from "the helper ran and told you something". This lane's helpers answer in exit codes by design — `run-pre-tag` returns 5 for drift and 6 for a mutating check, `semver-greater` and `check-manifests` each separate "no" from "could not compare" — so the `||` form re-runs the whole command on every one of those answers and then reports the SECOND run's code. For `run-pre-tag` that means executing the project's declared pre-tag commands twice and losing the verdict of the first. The fallback must key on whether the interpreter EXISTS, which is what `command -v` tests, not on what it said.
## Targets
Resolve `$TARGET`'s row from the declared file FIRST and use it throughout — never a hardcoded table. An unparseable declared file (any parser-contract violation on a file that DOES exist — including one that exists but carries no delimiter block at all, `FileExistsNoBlockError`) → STOP and surface the parse error; never guess a row's shape, and never treat an existing-but-broken file as an opportunity to back-fill it (see "Back-fill" below for why that distinction is mechanical, not a judgment call). A genuinely ABSENT declared file — nothing on disk at all, the one state `AbsentBlockError` alone names — enters the "Back-fill" lane below instead of stopping outright; that lane never runs against a file that already exists, in any state. Resolve `$TAG_PREFIX` through the shared mechanism, never typed from memory: `TAG_PREFIX=$("$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py" tag-prefix $TARGET)`. Where a hosted publish lane's own namespace resolution is ALSO wired to read this declared file — rather than carrying a separate, hardcoded copy of the same facts — the command and the lane cannot disagree; where it is not (yet) wired that way, the two can drift, and reconciling them is a workflow-authoring task this skill cannot enforce from the command side alone.
**Read the row through the helper, never by eye** (HIGH, blind exercise run 14). The same rule that governs the target list governs its fields: `"$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py" show-row $TARGET` prints one shell-quoted `NAME='value'` line per declared field, through the same tested grammar `list-targets` and `tag-prefix` use, and named for the variables this skill spells. A field the row does not declare prints with an empty value rather than being omitted, so "not declared" and "I did not look" stay distinguishable.
Read one field at a time with `--field`, into a normal command substitution, spelled in full each time:
```sh
TAG_PREFIX=$("$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py" show-row $TARGET --field prefix)
CHANGELOG=$("$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py" show-row $TARGET --field changelog)
MANIFEST=$("$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py" show-row $TARGET --field manifest)
```
…and so on for `generated-manifest`, `generate`, `artifacts`, `rebuild`, `pre-tag`, `provenance-manifest`, `latest-eligible`, `display-name`. Multi-valued fields print comma-separated; an undeclared one prints empty.
**MUST NOT collapse the repetition into a command held in a variable**`ROW="$PY ${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py show-row $TARGET"` followed by `$($ROW --field prefix)` reads as the obvious tidy-up and reintroduces, in the one block that reads EVERY field, the exact defect the quoting above removes (HIGH-1, blind exercise run 16). An unquoted `$ROW` is subject to word splitting, which is what makes it run as a command at all — so the interpreter path inside it cannot be protected, and a plugin root containing a space (`C:\Users\First Last\.claude\plugins\…` is an ordinary Windows install) splits mid-path and fails every field read at once. Quoting `"$ROW"` does not rescue it either; that spelling looks for a single executable whose filename is the entire string. The verbosity is the price of the property.
**MUST NOT read the row with `eval`.** A bare `eval "$(… show-row …)"` executes the declared values: `rebuild: cd x && npm run build` parses as the assignment `REBUILD=cd` followed by the command `x`, with `&& npm run build` waiting behind it — and `eval` still exits 0, because plain assignments follow. Blind exercise run 15 hit exactly that. These values are operator-authored shell that this lane runs only AFTER step 6c confirms a human has read them; executing a fragment of them while merely READING the row runs them before the gate that exists for them. `show-row`'s bare form is shell-quoted so the mistake is now inert, but `--field` needs no `eval` at all and is the sanctioned spelling.
**`$PAYLOAD` is a git PATHSPEC, not a path.** Assign it from the dedicated subcommand, NOT from `show-row`'s `payload` field, and pass it unquoted after `--` so its parts stay separate words:
```sh
PAYLOAD=$("$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py" payload-pathspec $TARGET)
```
`payload` minus `payload-exclude` cannot be spelled as a plain path: `git log -- <path>` has no subtraction, and the `:(exclude)` form that does appears nowhere an operator would infer it. A row that declares an exclude otherwise silently counts the excluded commits in its own bump and changelog — `show-row --field payload` returns the raw field and is the WRONG source for this one variable.
From the resolved row:
| field | meaning |
|---|---|
| `$TAG_PREFIX` (`prefix`) | the tag namespace this target publishes under |
| `$DISPLAY_NAME` (`display-name`) | optional; the human-readable name used in the Phase-3 Release title. Defaults to `$TARGET` itself when a row declares none |
| `$MANIFEST` (`manifest`) | one or more version-carrying files; every one is asserted equal to the derived version |
| `$GENERATED_MANIFEST` (`generated-manifest`) | optional subset of `$MANIFEST`; never hand-edited — regenerated by `$GENERATE` instead |
| `$GENERATE` (`generate`) | optional command that regenerates every path in `$GENERATED_MANIFEST`; run before the Phase-1 manifest-equality assertion |
| `$CHANGELOG` (`changelog`) | the file the Phase-1 section is rolled into |
| `$PAYLOAD` (`payload`, minus `payload-exclude`) | the commit-window and rebuild-freshness scope |
| `$ARTIFACTS` (`artifacts`) | committed built bundles asserted clean after `$REBUILD` runs |
| `$REBUILD` (`rebuild`) | optional command that regenerates every path in `$ARTIFACTS`; Pre-flight runs it unconditionally. Invoked in two later steps and previously missing from this table entirely |
| `$PRE_TAG` (`pre-tag`) | check-only commands run in declared order before tagging (DECISION-0034) |
| `$PROVENANCE_MANIFEST` (`provenance-manifest`) | optional; Phase 3 step 5 skips (and says so) when absent |
| `--latest` eligibility (`latest-eligible`) | at most one declared target may claim it |
An unrecognised `$TARGET` — no row of that name — STOPs; do not guess which project was meant. With no declared file at all, this skill's own "Back-fill" lane below handles it at release time; `context-creation` (full onboarding) is the sanctioned way to create one ahead of a release. Neither ever invents a row from a guess.
Traps worth stating rather than discovering, general to any row rather than specific to one target:
- **A row MAY declare more than one `manifest`.** Assert every one of them equals the derived version in Phase 1 — a target whose secondary manifest lags its primary one ships a tag that installs a version string the tag does not name.
- **A manifest path also listed in `$GENERATED_MANIFEST` is never hand-edited.** It is regenerated output — some other build or packaging step produces it from a primary manifest or source of truth — so "update the manifest to the derived version" means running the row's declared `generate` command for that one path, then letting the SAME equality assertion every other manifest path gets confirm it landed on the derived version. Hand-writing a generated manifest defeats its own generator and can leave it silently inconsistent with whatever it is supposed to mirror.
- **A row's `payload-exclude` entries are excluded from the commit window and the rebuild-freshness scope, not merely cosmetic** — a payload that ships no policy or build artifact under an excluded directory must not gate the release on changes there.
- **At most one declared target may set `latest-eligible: true`, and every other target's Phase-3 publish MUST pass `--latest=false` EXPLICITLY.** Omitting the flag is not declining it: GitHub defaults `make_latest` to true for any non-prerelease, so a target that simply does not ask for the badge still takes it — measured in this repository's own history, where a sibling's release displaced the primary target's badge for exactly this reason. A hosting service has one repo-wide "Latest"; a declared file may name several series.
## Back-fill (no declared file yet)
`load_targets` raises `AbsentBlockError` when `${CLAUDE_PROJECT_DIR}/.codearbiter/release-targets.md` does not exist on disk at all — the ONE gap this skill does not merely STOP on. **This is mechanically distinct from an EXISTING file that merely carries no delimiter block, which raises the sibling `FileExistsNoBlockError` instead** (HIGH-1, adversarial review 2026-07-31) — `parse_release_targets` sees text only and cannot itself tell "no file" from "a file with no block" apart, so `load_targets`, the one function that knows whether `open()` actually succeeded, makes the distinction and raises the two as siblings under `ReleaseTargetsError` rather than one subclassing the other. Every OTHER `ReleaseTargetsError``FileExistsNoBlockError` (exists, no block), or a malformed, empty, duplicate, or otherwise unparseable EXISTING file — still STOPs outright per "Targets" above; this lane triggers ONLY on `AbsentBlockError` and never runs against a file that already exists, in any state, however broken — a broken declaration is a different failure from a missing one, and detecting a shape to paper over it would silently discard the operator's own (bad) declaration. From the CLI this same distinction is an exit code, not free text to parse: `tag-prefix` and `list-targets` both exit `3` for the genuinely-absent case (the lane's ONE trigger) and `4` for every other declared-file error.
1. **Detect.** From the project root, run `"$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py" backfill-detect`. It scans the repo root for exactly one candidate manifest (`package.json`, `pyproject.toml`, `Cargo.toml`, `composer.json`) and exactly one candidate changelog (`CHANGELOG.md`, `CHANGES.md`, `HISTORY.md`).
- **Zero, or more than one, candidate of either kind (non-zero exit):** the repo is genuinely ambiguous — several plausible manifests or none, several changelogs or none. STOP here; this lane never guesses among candidates and never invents one from nothing. Route the user to `context-creation` instead, which resolves the same ambiguity through full elicitation rather than a bare top-level scan.
- **Exactly one candidate of each kind (exit 0):** the command prints the exact `release-targets.md` block it would write, already in the grammar `load_targets` accepts, and it declares `latest-eligible: true` (HIGH-2, adversarial review 2026-07-31): this lane can only ever propose ONE row — that is what "exactly one candidate of each" means — so the project it is proposing a row for is, at this moment, single-target. The "at most one declared target may claim it" hard rule in "Targets" above exists to stop SIBLING series stealing the badge from one another; applied blindly to a project's own first, only release it would instead publish that release demoted out of the Latest position by default, with nothing in this lane prompting anyone to notice. Declaring the key explicitly, rather than leaving the rule to somehow infer "solo project" later, is also the more honest choice for a project that adds a SECOND target down the line: the very next step shows this line to the operator VERBATIM before anything is written, so it is something they read and can strike, not a behavior that silently changes the day a second `[target]` block is hand-added.
2. **Present, and require explicit confirmation before doing anything else.** Show the printed block to the user VERBATIM. Do NOT write it, and do NOT proceed to Pre-flight or any phase below, until the user explicitly confirms the detected shape is correct — including the `latest-eligible: true` line above, which the operator may strike before confirming if this project's badge should live elsewhere. A refusal STOPs the lane — nothing is written, and nothing is proposed a second time without a fresh detection pass.
3. **Persist, only on confirmation — and re-check existence immediately before writing, regardless of how this lane was entered.** Before minting any marker or writing anything, confirm no file exists yet at `${CLAUDE_PROJECT_DIR}/.codearbiter/release-targets.md`. If one now exists — a race since Detect ran, or this lane reached from anywhere other than the documented AbsentBlockError trigger — STOP without writing and surface it; never overwrite an existing file at this path under any circumstance, belt-and-braces on top of the trigger distinction above rather than trusting it alone. Only once that is confirmed, `release-targets.md` is a marker-gated protected-state file: immediately before writing, mint the authoring marker at the path the write-guard hooks check (project root = git top level):
```bash
mkdir -p "$(git rev-parse --show-toplevel)/.codearbiter/.markers"
touch "$(git rev-parse --show-toplevel)/.codearbiter/.markers/release-targets-authoring"
```
Write the confirmed block verbatim to `${CLAUDE_PROJECT_DIR}/.codearbiter/release-targets.md`, then remove the marker — it is honored for 30 minutes and exists for this one authoring pass only:
```bash
rm -f "$(git rev-parse --show-toplevel)/.codearbiter/.markers/release-targets-authoring"
```
**This write itself dirties the tree, inside `$PAYLOAD`'s own window** (HIGH-3, adversarial review 2026-07-31: a single-artifact detection emits `payload: .`, so the new file sits inside the window Pre-flight is about to scope), and Pre-flight below STOPs on a dirty tree. Commit it through `commit-gate` on the current branch, as `chore: declare release targets` (or an equivalent non-bumping type), BEFORE re-entering Pre-flight. This is expected, not a defect: a `chore` commit contributes no bump and rolls into no changelog section (Phase 1 step 2), so this one extra commit changes neither the derived version nor what ships in `$CHANGELOG` — it is accounted for here, not discovered later. Then re-enter Pre-flight, which now finds the file and resolves `$TARGET` exactly as "Targets" above describes.
4. **A second invocation reads; it does not re-detect.** Once the file exists on disk, `load_targets` succeeds and this back-fill lane never runs again for this project — detection above fires ONLY when the file is genuinely absent, never once a row has been confirmed and persisted.
## Pre-flight
**A project may declare more than one independently-versioned release target** in `${CLAUDE_PROJECT_DIR}/.codearbiter/release-targets.md`, each with its own tag series, payload path, manifest(s), and changelog. A sibling target's tag or commit MUST NOT influence `$TARGET`'s version, window, or changelog — that isolation is what per-row scoping buys, and it is the single most common way a release goes wrong.
Read these, or STOP and surface the gap — never guess:
- `${CLAUDE_PROJECT_DIR}/.codearbiter/CONTEXT.md`, when it exists — the default-branch name and project context. **A consumer that reached this skill only through the Back-fill lane above has no `CONTEXT.md` yet, by design** (HIGH-2, adversarial review 2026-07-31): that lane's entire purpose is letting a release-only consumer skip full onboarding, so its absence here is not itself a STOP — re-imposing onboarding at this point would defeat the lane that just let the consumer skip it. Resolve the default branch directly instead when `CONTEXT.md` is absent: `git symbolic-ref refs/remotes/origin/HEAD --short 2>/dev/null | sed 's@^origin/@@'`, or ask the user which branch is the default if that resolves to nothing. This excuses only `CONTEXT.md`'s ABSENCE, and only for the one fact this skill actually reads it for; a project with no `.codearbiter/` state at all and no interest in this narrow release-only footprint is still routed to `context-creation` for full onboarding, per "Targets" above.
- `$TARGET` must resolve to a declared row (see "Targets" above). An unrecognised target STOPs; do not guess which project was meant.
- `git status` must be clean. A dirty tree STOPs — commit or stash via `commit-gate` first.
**This governance layer's own scratch state is exempt, and only that** (HIGH, blind exercise runs 15 and 17). Two paths under `${CLAUDE_PROJECT_DIR}/.codearbiter/` are written by the layer itself during the very run being checked, and neither is ever part of a release:
- `gate-events.log` — the hooks append to it on essentially every command, including the commands this lane runs, so a repo-wide check can never pass during an active session and a compliant traversal STOPs on a file the act of checking just wrote.
- `.markers/` — step 6c's own stated remedy (`releasehash.py record`) writes a per-machine confirmation marker here. Exempting the log alone made that remedy dirty the tree in a way the Phase 1 gate then refused, blocking a release where nothing was wrong, at the last gate, after the changelog and every manifest had already been written. It is masked in a repo that happens to gitignore the directory and NOT masked in a project that reached this lane through Back-fill — which is precisely the project this lane exists for.
```sh
git status --porcelain -- :/ ':(exclude,top).codearbiter/gate-events.log' ':(exclude,top).codearbiter/.markers/'
```
**`:/` and `,top` are load-bearing, not decoration** (HIGH-1, blind exercise run 17). A bare `.` scopes the check to the CURRENT directory and the `:(exclude)` term is cwd-relative too, so from a subdirectory this command prints nothing and exits 0 on a genuinely dirty tree — it excludes a path that does not exist while filtering away the real dirt outside the subtree. `:/` anchors to the repository root and `,top` makes each exclusion root-relative, so the answer is the same from anywhere. That matters here because the `rebuild` step below can leave the shell in a subdirectory.
A release must still not carry uncommitted work in anything it ships or asserts against, so every other path — the payload, the manifests, the changelog, the artifacts — stays in scope exactly as before. If your project's declared row treats either of these paths as a release surface, do not exempt it.
- The current branch MUST NOT be `main`, `master`, or the default branch. Release lands through the normal branch/PR path; if HEAD is the default branch, STOP.
- **Resolve `LAST_TAG` from `$TARGET`'s series only** — never bare `git describe --tags --abbrev=0`, which returns the nearest tag by commit-graph *ancestry* and, once more than one series exists, routinely resolves to another target's tag, silently basing the entire release on the wrong baseline. Resolve it through the tested helper, never a hand-rolled grep: `LAST_TAG=$(git tag -l | "$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py" last-tag $TAG_PREFIX)`. `last_tag_select` returns the highest `$TAG_PREFIX`-prefixed SemVer tag, excluding pre-releases (`-beta`/`-rc`/`-alpha`), or `<none>`; its series isolation is a property of an ANCHORED match, so no series can resolve another's tag. No matching tag → `LAST_TAG=<none>` and the full history is the window. A series with no tag yet is normal, not an error.
**`$BASE_VERSION` — one base, computed the same way in every case** (HIGH, adversarial review 2026-07-31, runs 6 and 7). It is the MAXIMUM of two things: the bare version of `LAST_TAG` (or `0.0.0` when `LAST_TAG` is `<none>` — the sentinel is not a version and never enters the comparison), and the highest version any declared `manifest` currently carries, taken across EVERY declared path rather than the first, since a row may declare several and only the maximum is a safe floor. Read those manifests HERE, before anything is bumped.
**When the manifest is AHEAD of `LAST_TAG`, STOP** (HIGH, blind exercise run 14). If the winning maximum came from a manifest rather than from `LAST_TAG`, this target has shipped one or more versions that were never tagged in its own series — and `$WINDOW`, which starts at `LAST_TAG`, therefore spans commits that already went out under those versions. `$BASE_VERSION` floors the VERSION against that, but nothing floors the CHANGELOG: Phase 1 step 5 rolls every `CHANGELOG:` footer in `$WINDOW` into one new section, so the release would re-publish every entry already sitting under the untagged versions, and Phase 1 step 3 would BLOCK on missing footers in commits that shipped months ago — whose only stated remedy, amending or rebasing them, is not available for published history.
Measured on this repository at run 14: `LAST_TAG` was `v2.8.13` while the manifest read `2.11.0`, `CHANGELOG.md` already carried `[2.9.1]` through `[2.11.0]`, and 38 of the 51 footer-less commits predated the published `[2.11.0]` section. The lane could not cut a release by following itself.
So: report the gap (`LAST_TAG` version, the higher manifest version, and the declared changelog's newest section), and STOP. The reconciliation is a maintainer action taken deliberately — tag the missing versions in this series at the commits they shipped from, so `LAST_TAG` and the manifest agree again — not something this lane infers. Once they agree, re-enter Pre-flight and `$WINDOW` spans only unreleased work, which is what every step below assumes.
Both halves are load-bearing, and each was found by a separate run against a separate project shape:
- Without the manifest half, a project that had shipped `1.4.2` without ever tagging in this series derived `0.1.0` and the bump wrote that over its manifest, walking the project's own version backward with every gate passing — the manifest-equality assertion included, because the bump had just made it equal (run 6).
- Without taking the MAXIMUM, a project holding a `v1.2.0` tag and a `1.4.2` manifest derived `1.3.0` from the tag alone and then hard-stopped against its own manifest — a BLOCK on a legitimate release, with the fix nowhere in the file (run 7).
`0.0.0` is a placeholder that contradicts data already on disk, and the tag alone is only half the data. Deriving from the maximum honestly skips any versions the project already claimed but never tagged. **`<none>` is a sentinel, not a revision — derive `$WINDOW` from it before using it anywhere** (HIGH, adversarial review 2026-07-31, run 5): every command below spells the window `$WINDOW`, and `$WINDOW` is `${LAST_TAG}..HEAD` when a tag was found and bare `HEAD` when `LAST_TAG` is `<none>`. Substituting the sentinel into a range is a hard failure, not a soft one — `git log <none>..HEAD` exits 128 with `fatal: bad revision`. This is not an edge case: a consumer that has just declared its first target through the Back-fill lane has, by construction, no tag in that series, so the very first release of every back-filled project lands here. In shell: `if [ "$LAST_TAG" = "<none>" ]; then WINDOW=HEAD; else WINDOW="${LAST_TAG}..HEAD"; fi`. **Residual (MEDIUM, adversarial review 2026-07-31), documented rather than silently accepted:** this replacement fixes ancestry-based `git describe`'s failure mode in one direction (a sibling series' tag can no longer leak in) but has no ancestry awareness of its own in the OTHER direction — it resolves by highest SEMVER across every tag in the series, commit-graph reachability from HEAD notwithstanding. A tag pushed once from a branch of this series that was later abandoned permanently still counts as "highest tag in the series" forever after, raising the baseline for every subsequent release even though no released history actually contains it. `last_tag_select` has no way to detect that case; a project that hits it must remove the stray tag by hand (never simply retarget or delete a PUBLISHED one — see "Recovering from a bad release" below) rather than expect this helper to route around it.
- **Scope the release window to `$PAYLOAD`:** the commit set is `git log $WINDOW -- $PAYLOAD`, NOT the whole repo — a `feat(some-other-target)` commit must not bump `$TARGET` or land in its changelog, and vice versa. This payload-scoped set must be non-empty; if empty, STOP — nothing to release for `$TARGET`.
- **Manifest read:** read the `version` field of every path in `$MANIFEST` — a row may declare more than one. Phase 1 asserts the derived bump equals each of them and updates them — a tag whose version runs ahead of a manifest ships nothing, since a plugin/package installer typically no-ops on an unchanged version string. A path also listed in `$GENERATED_MANIFEST` is not "updated" directly — it is regenerated by the row's declared `generate` command, and the same equality assertion is what confirms the regeneration landed on the derived version.
- **`$ARTIFACTS` freshness — rebuild unconditionally:** every release, regardless of whether the sources changed in the window, run the row's declared `rebuild` command (when one is declared) **in a subshell, so it cannot move this lane's working directory**`( eval "$REBUILD" )` — and assert every path in `$ARTIFACTS` is in sync afterward (`git diff --quiet -- <each artifact>`). The subshell is the fix for a measured HIGH (blind exercise run 17), not a style preference: a declared `rebuild` commonly BEGINS with `cd` (this repository's own row is `cd <subdir> && npm run build`), the shell an operator runs this lane in persists between steps, and nothing here previously said to come back. From the subdirectory that leaves you in, three later gates fail silently rather than loudly — `git log $WINDOW -- $PAYLOAD` returns zero commits and fires the false "nothing to release" STOP on a full window; `git diff --quiet -- <artifact>` exits 0 without ever resolving the artifact, so the freshness gate passes while blind; and the clean-tree check reads a dirty tree as clean. Two of those block a release that should have succeeded and the third is a safety gate that stops looking at the thing it guards. **This `eval` is not the one the Targets section bans.** That rule forbids evaluating a row's values while merely READING the row, which runs operator shell before the gate that exists for it. Here the value is being deliberately EXECUTED as the command it was declared to be, at the step that executes it — the same thing `run-pre-tag` does for `pre-tag` commands. Reading is not execution; the ban is on confusing the two, not on ever running a declared command. A non-empty diff means a shipped bundle is stale — a release blocker, because a target ships the built file, not its source; commit the rebuild through `commit-gate` before tagging. Scope is `$TARGET` only: another target's stale bundle is that target's release problem, not this one's. A row declaring neither `rebuild` nor `artifacts` has nothing to assert here. (The old form gated the rebuild on an in-window source change and so missed a bundle that went stale *before* the window.)
## Phase 1 — Version & changelog · gate: BLOCK
Derive the bump mechanically from the commit log; do not guess it.
1. Read every commit in the `$PAYLOAD`-scoped window: `git log $WINDOW --pretty=format:%H%n%s%n%b%n---- -- $PAYLOAD` (the path scope is load-bearing — it excludes every sibling's commits from the bump and changelog).
2. **Classify the window through the tested helper, not by hand:** `git log $WINDOW --pretty=format:%H%n%s%n%b%n---- -- $PAYLOAD | "$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py" classify-window`. It prints the derived bump on the first line, then one `[NEEDS-TRIAGE] <short-sha> <subject>` line per bumping commit missing a `CHANGELOG:` footer — step 3's report, in step 3's exact shape. **Exit 0** clean; **exit 1** at least one footer is missing (step 3's BLOCK); **exit 2** the whole window is non-bumping (the STOP below). It classifies and reports; the decision stays here, in this skill.
Hand-rolling this parse is how it goes wrong, and not hypothetically (HIGH-adjacent, adversarial review 2026-07-31, run 11): an exercising agent wrote `subject.split('(')[0].split(':')[0].rstrip('!')`, which strips the breaking marker *before* anything checks for it — so `feat!:` classified as a minor, `feat(api)!:` the same, and `chore!:` as no release at all. A breaking change ships as a minor, or does not ship. Two operators writing two parses produce two different gates on the check that decides whether a release may proceed, which is not a gate.
The rules it implements, for reference — the helper is authoritative, this list is the explanation:
- `BREAKING CHANGE:` footer or `!` after the type/scope → **major**.
- else any `feat`**minor**.
- else any `fix`, `perf`, `refactor`**patch**.
- `test` / `docs` / `chore` / `ci` only → no bump. If the whole window is non-bumping, STOP — there is nothing to release.
3. **Verify footer completeness for the WHOLE window before touching anything else — never after.** **On a FIRST release, floor the window at the adoption commit before checking anything** (A-5.5): when `LAST_TAG` is `<none>` the window is the entire history, and every commit authored BEFORE this project adopted codeArbiter predates the changelog convention entirely — none of them carries a footer and none ever can, so an unfloored check emits one `[NEEDS-TRIAGE]` line per pre-adoption commit and blocks a release where nothing is wrong. A project adopting at its 500th commit gets a 500-line block. Resolve the boundary mechanically: `ADOPTED=$(git log --diff-filter=A --format=%H -- "${CLAUDE_PROJECT_DIR}/.codearbiter/CONTEXT.md" | "$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py" adoption-commit)`. Empty output means no adoption commit exists and there is nothing to floor. Otherwise **offer** `$ADOPTED` as the changelog baseline and let the user override it — it is a proposal, not a silent narrowing, because only they know whether their pre-adoption history was already being changelogged by hand. Commits at or after the boundary are held to the footer rule in full. Then, over the floored window: every `feat`/`fix`/`perf`/`refactor` commit (the full harvested set step 5 rolls into the changelog, not just `feat`/`fix`) MUST carry a `CHANGELOG:` footer. **A missing footer on any one of them is a BLOCK**, not a soft finding: surface EVERY offending commit as its own `[NEEDS-TRIAGE]` line in the release report presented to the user (one line per commit missing the footer, `<short-sha> <subject>` — never a single collapsed `[NEEDS-TRIAGE]` when more than one commit is at fault, and never written to any file: the report is its only destination) and STOP here — never auto-fill it, and never tag a changelog that silently drops a user-visible change (MEDIUM, adversarial review 2026-07-31: `[NEEDS-TRIAGE]`'s shape and destination were previously unstated, and "surface the `[NEEDS-TRIAGE]`" read singular where a window can carry several offenders). **The remedy is the operator's to choose, stated so the STOP is not a dead end:** amend the offending commit's message to add the missing `CHANGELOG:` footer (`git commit --amend` if it is HEAD, an interactive rebase onto it otherwise — **check first whether the commit is already published — `git merge-base --is-ancestor <sha> origin/$DEFAULT_BRANCH`**. For a commit that is NOT yet published, either is fine. For one that IS, neither is: rewriting it needs a force-push over shared history, which this skill's hard rules forbid outright, and the long tail of already-merged commits is the NORMAL state of a window, not an edge case (HIGH-2, blind exercise run 18 — measured 38 of 57 footer-less commits already ancestors of `origin/main` on the repository that ships this skill). A published commit's missing footer is classified from its message as it stands, exactly as the manifest-ahead STOP below already says: its stated remedy is not available for published history), or, if a listed commit genuinely has nothing user-facing to say, reclassify its type instead of leaving it half-classified; then re-run this step against the corrected window. Never invent the footer's text on the commit author's behalf — STOP again and ask if a commit's intent is unclear. Doing this check BEFORE step 4 bumps any file (adversarial review 2026-07-31) closes two asymmetries the old ordering left open: a footerless `perf` used to bump the version and vanish from the changelog silently, because the old BLOCK sentence named only `feat`/`fix` while `perf` was harvested anyway; a footerless `refactor` used to bump, compose an empty changelog section, and trip no BLOCK at all, because it was neither harvested nor named as bumping the changelog. Checking here, before any manifest is touched, also means a BLOCKed release never leaves a manifest bumped with nothing to show for it — the old ordering bumped the manifest first and checked footers only when composing the changelog afterward. A `CHANGELOG:` footer on a `test`/`docs`/`chore`/`ci` commit is never silently discarded either — see step 5's harvesting rule, which rolls it in rather than dropping it; it does not, by itself, change this step's BLOCK condition.
4. Apply the step-2 bump to **`$BASE_VERSION`** and assign the result to **`$VERSION`** -- `VERSION=<the bumped value>`. Every command below spells the tag `${TAG_PREFIX}${VERSION}`; before run 15 they all spelled it `${TAG_PREFIX}${VERSION}`, a placeholder no step ever assigned, so the tag, push and Release commands were uncopyable as written. (Pre-flight computed `$BASE_VERSION`; never to `LAST_TAG` directly, and never to a manifest directly — either one alone is half the input.) Then confirm the result **through the tested helper, not by eye**: `"$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py" semver-greater <derived> $BASE_VERSION` must exit 0. Exactly ONE comparison, against the one base — exit 1 means equal-or-lesser, exit 2 means a value is not valid SemVer, which is a different answer rather than a stricter one. **If it exits 1, the derivation used the wrong base — re-derive from `$BASE_VERSION` rather than raising the bump** (HIGH, run 7: this step previously said only "must exit 0", so an operator who derived from the tag alone hit a BLOCK on a perfectly legitimate release with no stated way out; step 3 names its remedy and this step did not). Note this is deliberately NOT a comparison against `LAST_TAG` as well: on a first release `LAST_TAG` is `<none>`, which is not a version, and `semver-greater` correctly exits 2 on it — so a second comparison against the raw tag is unrunnable on precisely the path the Back-fill lane exists to serve. `$BASE_VERSION` already accounts for the tag. Present the version and the per-commit classification to the user for confirmation. (MEDIUM, adversarial review 2026-07-31: this step used to also say "assert it equals the version in every path declared in `$MANIFEST` — if a manifest lags the derived bump, bump it now", which is unsatisfiable as an ASSERTION — a manifest lags the derived bump BY CONSTRUCTION at this point in the flow, before step 6 below has touched it, so the assertion could only ever fail on a correctly-flowing release. It meant "make it equal", and step 6 already does exactly that and confirms it landed; stating the same action twice, once as a contradictory assertion and once as the real instruction, is one action described two ways. Bringing every `$MANIFEST` path to the derived version, and confirming it, happens ONCE, in step 6.)
5. Derive the release date **once**`RELEASE_DATE=$(date +%F)` — and reuse that single value for the changelog header, the Phase-2 `Released-at:` footer, and the Phase-3 Release; never hand-type the date a second time (`release_dates_consistent` verifies the changelog-header date equals the `Released-at:` date). Roll the `CHANGELOG:` footers from each `feat` / `fix` / `perf` / `refactor` commit into a new `## [${VERSION}] — $RELEASE_DATE` section in `$CHANGELOG` (the Keep-a-Changelog bracket heading this guard matches, not the bare `v`-prefixed form), grouped Added (`feat`) / Fixed (`fix`) / Performance (`perf`) / Changed (`refactor`, matching this repository's own changelog convention for non-user-facing-but-notable work). **Also harvest a `CHANGELOG:` footer from any `test`/`docs`/`chore`/`ci` commit that carries one**, into the same section's Changed group: its type says "not user-visible" but its footer is the more specific, deliberately-authored signal, and dropping it because of the type would be exactly the silent discard this guard exists to prevent (measured against this repository's own history, where such footers are a recurring, intentional pattern, not a mistake to reject). This harvesting rule does not by itself force a release — if the whole window is non-bumping, step 2's STOP still applies even when a non-bumping commit in it carries a footer; widening what a bare footer alone can trigger is out of scope here. Prior sections stay intact. Create the file with a `# Changelog` heading if absent. The changelog is a user-facing deliverable: apply `${CLAUDE_PLUGIN_ROOT}/includes/anti-slop-design/core.md` §3.A (no prose-separator em-dashes in the entry prose) and §3.B (copy self-audit), and the `medium-documents` §7.A.1 changelog guidance, to each rolled entry. **Home for the composed section (MEDIUM, adversarial review 2026-07-31, previously unspecified):** besides landing in `$CHANGELOG` itself, this same section text is what Phase 2's `<message-file>` and Phase 3's `<Phase-1 section file>` both read back — write it to a scratch file created OUTSIDE the working tree (e.g. `mktemp`), never anywhere under the repo. A copy left inside the tree would either dirty the clean-tree state Pre-flight already required (this skill never re-runs Pre-flight mid-phase to notice) or, under a `payload: .` row, be swept straight into the very release window it is composing. Discard the scratch file once Phase 3 no longer needs it; it is working state, not a deliverable.
6. **Sync `$TARGET`'s release surfaces to the repo — mechanically derived, never typed.** In this order; each sub-step depends on the one before it.
**6a. Update the manifests.** Set every path in `$MANIFEST` to the derived version, **except a path also listed in `$GENERATED_MANIFEST`** — that one is never hand-edited; run the row's declared `generate` command (when declared) to regenerate it instead.
**6b. Assert every declared manifest actually landed on the derived version.** `"$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py" check-manifests $TARGET <derived>` must exit 0. Exit 1 names each path that disagrees; exit 2 means a manifest could not be parsed, which is a different answer from "disagrees" and not a stricter one. This is the lane's ONLY runnable all-paths equality guard — a row may declare several manifests, and a partial bump otherwise reaches a tag silently (HIGH, run 12). A regenerated `$GENERATED_MANIFEST` path is confirmed here the same way every other manifest path is; there is no separate check for it.
**This assertion runs AFTER 6a, never before it.** The two used to sit in the opposite order in this step's prose, so an agent following it top-to-bottom ran the equality guard against manifests it had not updated yet and got a failure the lane had itself caused.
**6c. Confirm the declared `pre-tag` commands have been read.** `"$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/releasehash.py" check $TARGET` must exit 0. These commands are operator-authored shell that this lane then EXECUTES, so a change nobody has read is the case the check exists for. Exit 1 means they changed since the last confirmation, exit 2 means they have never been confirmed; both are resolved the same way — read them, then `"$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/releasehash.py" record $TARGET`. Recording without reading converts the gate into a rubber stamp, which is worse than not having it. A row declaring no `pre-tag` commands reports `no-commands` and exits 0, which is deliberately distinct from `confirmed`.
**6d. Run them.** `$PRE_TAG` is the row's declared `pre-tag` commands (DECISION-0034: check-only, never a fixer). **Run them through the shipped runner, not by hand:** `"$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py" run-pre-tag $TARGET` must exit 0. It executes them in declared order, stops at the first non-zero exit, and asserts a clean tree after each one — the three rules this step used to state as prose for an agent to remember and apply in the right order. Its exit codes are distinguishable: **5** a command reported drift, **6** a command exited 0 but MUTATED the tree — the check-only rule broken by the declaration itself, which is the case the tree assertion exists for. The assertion is "this command changed nothing NEW", snapshotted before the first command and compared after each one, so this step deliberately runs AFTER step 5's changelog roll and the manifest bump: a badge or catalog check compares a surface against the NEW version and would pass vacuously against the old one. It reports every path a command added, edited, **or reverted**, and it runs every command in the project root rather than whatever directory the caller happened to be in.
**On exit 5, do not simply re-run** (HIGH, adversarial review 2026-07-31, run 9). Reconcile the drift the command reported — a pre-tag command is a check, never a fixer — and then **discard this run's uncommitted release edits before starting over**: the manifest bump is already on disk, and leaving it there makes it the NEXT run's `$BASE_VERSION` floor, so the restart derives a HIGHER version and leaves the section this run already wrote stranded in `$CHANGELOG` under a version that was never tagged. Nothing downstream catches that — `notes-match` only checks the new section's own heading. Commit the reconciliation ALONE, then re-run from Pre-flight. A target's own extra release surfaces — a version or count badge, a catalog table, anything else that must track the tag — are exactly what a declared `pre-tag` check exists to assert consistent; this skill runs whatever the row declares and does not assume what any target's surfaces are. A row declaring no `pre-tag` commands has nothing further to check here.
7. **Commit the release edits before leaving this phase — this is not conditional** (HIGH, adversarial review 2026-07-31, run 10). Steps 5 and 6 rolled `$CHANGELOG` and bumped every path in `$MANIFEST`; those edits are uncommitted, and `git tag` in Phase 2 names a COMMIT, not the working tree. Tagging with them outstanding produces a tag whose payload still carries the OLD version and no new changelog section, while its own message quotes the section that never shipped — and every guard in the lane passes, because `dates-match` compares two scratch files and `notes-match` reads only a heading, so none of them looks at the tagged tree at all. This step previously read "*if* the changelog edit … *needs* to land as a commit", which a literal traversal answers "no". Route the commit through `commit-gate`; do not reimplement the commit path here. Then assert the tree is clean before Phase 2 — **using Pre-flight's exempted spelling above, not the bare one** (HIGH-2, blind exercise run 16): the same `git status --porcelain` invocation Pre-flight's clean-tree bullet spells, with the same exclusions, must print nothing. This step is reached only AFTER steps 5 and 6a have written the changelog and every declared manifest, and each of those writes fires the hook that appends to the audit log — so at this exact point a bare `git status --porcelain` is GUARANTEED non-empty, and asserting it would hard-stop every release at the last gate of Phase 1. `commit-gate` cannot rescue it: that skill stages by explicit path and unstages anything outside the intended set, so the log is never swept into the release commit, by design. It is the same exemption Pre-flight makes, for the same reason, and it must be spelled the same way in both places.
Gate: version confirmed, strictly monotonic within `$TARGET`'s series, matching the commit log, and equal to every path in `$MANIFEST`; `$CHANGELOG` updated; every declared `pre-tag` check passes; **the release edits are COMMITTED and the tree is clean under step 7's exempted spelling — Pre-flight's, not the bare form**. BLOCK if the classification disagrees with the log, the window is non-bumping, a bumping commit's `CHANGELOG:` footer is missing, any `pre-tag` check fails or reports a mutation, or the tree is dirty on exit.
**Exit 6 is a broken DECLARATION, not a dirty tree, and its remedy is not the same as exit 5's** (HIGH, adversarial review 2026-07-31, run 11). A command that mutates the tree does so on every invocation, so "revert and re-run this step" cannot converge — it loops without bound, and the first thing it reverts is the changelog section step 5 just composed, which exists nowhere else but the scratch file. On a FIRST release it cannot even be attempted: the lane creates `$CHANGELOG` from nothing, and `git checkout --` errors on a path git has never seen. So: **fix the declaration first.** Make the offending command check-only — assert, and exit non-zero on drift, per DECISION-0034 — or remove the row entry. Only then discard this run's edits wholesale (`git checkout --` for files that existed before, `rm` for any the lane created) and restart from Pre-flight. Discarding is safe precisely because nothing has been committed or tagged yet. The clean-tree condition is what makes the tag name a commit that actually contains this release.
## Phase 2 — Tag & report · gate: BLOCK
1. Compose the annotated tag message from the Phase 1 section plus a `Released-at: $RELEASE_DATE` footer (the same date derived once in Phase 1) into a message file, then assert the two dates agree: `"$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py" dates-match <Phase-1 section file> <message-file>` must exit 0. (This check was named in the prose but had no CLI entry point until run 4, so nobody following the skill could actually run it — `release_dates_consistent` was reachable only by importing the module, which this skill never tells anyone to do.) **Do not run `git tag` yet — classify first, then tag** (MEDIUM, adversarial review 2026-07-31, run 3): writing the ref before resolving the state is what the classification below exists to authorize, so an agent following this step in written order must reach `classify` with no tag written by this invocation. The tag command itself appears below, in the `publish_fresh` branch, and nowhere else. **Resolve `<tag_exists>` and `<tag_sha>` from the LOCAL ref this way, and never from a bare `git rev-parse ${TAG_PREFIX}${VERSION}`** (HIGH-1, adversarial review 2026-07-31): an annotated tag's own object id — what `git rev-parse` on a bare tag name returns — is not the commit it names, so feeding that value straight into `classify` below would classify a perfectly healthy tag as `abort_mismatch` and hard-stop a release that needed no stopping at all. Peel it through the same tested helper a hosted publish lane already uses for the identical problem against the REMOTE tag, pointed here at LOCAL refs instead: `TAG_SHA=$(git show-ref --tags -d | "$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py" peel-tag ${TAG_PREFIX}${VERSION})`. Dump every local tag (`git show-ref --tags -d`, no tag argument) rather than naming the one tag directly — `git show-ref` exits non-zero for a tag that does not exist yet, which is the common case here, where naming it directly would break a `set -e` lane; dumping the whole set and letting `peel-tag` find (or not find) the one line puts the PIPELINE's exit status in `peel-tag`'s hands rather than `git show-ref`'s, which is what makes it safe under `set -e`. (Correction, run 6: this previously claimed the dump form "always exits 0". It does not — with zero tags in the repository, the exact state a first release is in, `git show-ref --tags -d` itself exits 1 and prints nothing. The recommendation stands; the reason given for it was wrong.) Empty output means the tag does not exist yet (`<tag_exists>=false`, `<tag_sha>=` any value, `classify` short-circuits past it); any other output is the commit sha to pass as `<tag_sha>`. **Quote it — `"$TAG_SHA"`, always** (MEDIUM, adversarial review 2026-07-31, run 5): on the fresh-publish path, which is the COMMON case, that variable is empty, and an unquoted empty argument does not become an empty positional — it disappears, shifting the remaining five left so `classify` receives five arguments and exits 2. The failure is at least loud rather than a wrong verdict, but it fires on the ordinary path, not an edge case. **Source the other four arguments explicitly — none of them is yours to invent** (MEDIUM, adversarial review 2026-07-31, run 3: the prose named all six but sourced only two, leaving three to be supplied from agent judgment and one unobtainable): `<head_sha>` is `git rev-parse HEAD`; `<tag_version>` is the version DERIVED in Phase 1, reused verbatim and never re-parsed from a tag or a file; `<manifest_version>` is the `version` field of the row's first declared `manifest`, extracted with that FORMAT'S OWN parser rather than a line-grep (MEDIUM, run 5: every other argument here names a command and this one named none, so `jq`, `grep`, and a JSON parser could each return something different on a nested manifest). The grammar permits a row to declare manifests in different formats, so the command follows the FILE rather than a default: `"$PY" -c "import json,sys;print(json.load(open(sys.argv[1]))['version'])" <manifest>` for JSON, `"$PY" -c "import tomllib,sys;print(tomllib.load(open(sys.argv[1],'rb'))['project']['version'])" <manifest>` for TOML, the equivalent for anything else declared. Naming only the JSON form was itself the defect one run later — applied to a `pyproject.toml` it raises `JSONDecodeError` and exits 1 (MEDIUM, run 7). **This reads the FIRST declared manifest, while Pre-flight's `$BASE_VERSION` reads the MAXIMUM across all of them.** That difference is deliberate and is safe ONLY because step 6 bumps every declared path to the derived version — which step 6 now ASSERTS mechanically, because nothing else does. **Correction (HIGH, adversarial review 2026-07-31, run 12): this paragraph previously claimed `classify` catches a partial bump. It does not, on the path that matters.** `classify_publish_state` short-circuits on `if not tag_exists: return "publish_fresh"` before it ever compares versions, so that catch fires only when a tag already EXISTS — the resume path. On a fresh publish, which is every ordinary release and every first release, a lagging secondary manifest passes unnoticed, and the Traps section's own named consequence lands: a tag that installs a version string the tag does not name. Measured: `classify false "" <head> 1.4.3 1.1.0 false``publish_fresh`; the same disagreement with `tag_exists=true``abort_mismatch`. And **re-read from the file NOW — never the value Pre-flight read** (HIGH, adversarial review 2026-07-31, run 4): Pre-flight read that file before step 6 bumped it, so the carried value is the OLD version, and passing it makes `classify` return `abort_mismatch` — a terminal STOP — on a release where nothing whatsoever is wrong. `<tag_version>` is carried and `<manifest_version>` is re-read; the two arguments are sourced differently on purpose. And `<release_nondraft>` is `false` whenever `<tag_exists>` is `false` (classify short-circuits past it before it is ever consulted), otherwise `gh release view ${TAG_PREFIX}${VERSION} --json isDraft --jq '.isDraft'` **with its answer inverted**: that command prints `true` for a DRAFT release, so `<release_nondraft>` is `false` when it prints `true`, and `true` when it prints `false` (HIGH, run 4). Pass the bare literal `true` or `false` and nothing else — feeding the raw `--json` object `{"isDraft":false}` straight through makes every value coerce falsey, which silently renders `already_published` unreachable rather than failing loudly. **If that `gh` call fails for any reason other than a genuinely absent Release** — no remote configured, no network, `gh` unauthenticated — **STOP and surface it rather than passing a guessed value**: `gh` reports "no git remotes found" and "release not found" as the same non-zero exit, and this argument decides the one branch where the answer determines whether an already-published release is republished. **If the tag already exists, do not flatly abort — classify the state** with `classify_publish_state` via `"$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py" classify <tag_exists> <tag_sha> <head_sha> <tag_version> <manifest_version> <release_nondraft>`: `publish_fresh` (the tag does not exist) → **write the ref now, and only here**: `git tag -a ${TAG_PREFIX}${VERSION} -F <message-file> --cleanup=verbatim` — never `-m` for multi-line content, never an interactive editor; this is the sole point in the whole lane at which a tag is created. **`--cleanup=verbatim` is load-bearing, not stylistic** (HIGH, adversarial review 2026-07-31, run 4; issue #569): `git tag`'s DEFAULT cleanup mode is `strip`, which deletes every line beginning with `#` as a comment. A Keep-a-Changelog section is composed entirely of `#`-prefixed headings, so the default silently destroys the `## [${VERSION}]` heading and every `### Added`/`### Fixed`/`### Performance`/`### Changed` grouping, leaving an undifferentiated bullet list that no longer says which version it describes. This has already happened to a real published tag in the repository that ships this skill. Because a published tag is immutable, a mangled message can never be repaired — only superseded by a new version. Then **verify it round-tripped, rather than trusting the flag** — dump what git actually STORED to a scratch file beside the message file and check its heading: `git cat-file tag ${TAG_PREFIX}${VERSION} > <stored-file>` — the raw tag object, not `git tag -l --format='%(contents)'`, which returns a RECONSTRUCTION that appends a trailing newline and so cannot detect a byte-level difference even in principle (LOW, run 5) — followed by `"$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py" notes-match ${TAG_PREFIX}${VERSION} <stored-file>`, which must exit 0. (A file, not a pipe: `notes-match` takes a path, and a shell-specific stdin spelling would not survive the move to a Windows consumer.) `release_dates_consistent` cannot catch this on its own — it reads the changelog section from a separate argument and only needs the `Released-at:` line, both of which survive stripping — so a heading check against the ACTUAL stored message is the only guard that sees it; `abort_mismatch` (tag points at a non-HEAD commit, or its version disagrees with the manifest) → STOP; `already_published` (a non-draft Release already exists on the tag) → nothing to do; `resume_publish` (tag already at HEAD with the matching version but no Release) → skip re-tagging and resume at Phase 3 to create the missing Release — **this still requires explicit user authorization, exactly as a fresh publish does** (HIGH-4, adversarial review 2026-07-31): the local tag having been composed in a PRIOR invocation of Phase 2 is not itself authorization for Phase 3, which gates on it independently below; nothing about landing in `resume_publish` shortens, implies, or waives the Phase-3 STOP. (Whether the tag has actually been PUSHED to the remote — as opposed to existing only locally, the state Phase 2 itself is designed to leave behind — is not something `classify_publish_state` can tell from `tag_sha`/`head_sha`/`release_is_nondraft` alone; distinguishing it would need an added `git ls-remote` round-trip threaded through every classification call and CLI site for a case this authorization restatement already closes without one, so it is not implemented here.)
2. Report: `$TARGET`, version, bump rationale, the per-commit classification, the changelog section, and the tag SHA.
3. MUST NOT push the tag or create the GitHub Release here. Publication is Phase 3, a separate step the user authorizes after reading the report.
Gate: the annotated tag exists locally and the report is delivered. Nothing is published.
## Phase 3 — Publish · gate: STOP
The tag and the GitHub Release publish together, and only after the user explicitly authorizes publication. This phase does not run until then; absent authorization, nothing leaves the local repo.
**If this project has a hosted release workflow, prefer it when available.** A hosted dispatch lane carrying the same declared targets enforces structurally what the steps below can only ask you to do: a read-only preflight that holds no write token resolves exactly one target, merge readiness must be green for the exact commit being tagged, and the publish is idempotent by construction. Where one exists, dispatch it from the default branch with `$TARGET`'s version in that target's confirmation input and every other input blank. A project with no hosted workflow — every fresh consumer, until it builds one — performs the steps below locally: same guards, weaker enforcement, so follow them exactly.
On authorization:
1. Push the tag: `git push origin ${TAG_PREFIX}${VERSION}`.
2. **Guard the notes-file first:** assert its first heading matches the tag — `"$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py" notes-match ${TAG_PREFIX}${VERSION} <Phase-1 section file>` (exit 0). A stale notes-file (`notes_heading_matches` False) would publish the wrong changelog section under the right tag — STOP on mismatch. Then create the GitHub Release from the **same changelog section composed in Phase 1** — reuse it as the notes, never re-derive or hand-write them. **`--latest` follows the declared row:** assert it only when `$TARGET`'s row declares `latest-eligible: true`, and only when this tag is also the newest release across every declared series (compare against `gh release list`); every other target passes `--latest=false`. GitHub has one repo-wide "Latest"; a declared file may name several series, so a target claiming it wrongly hides another's current release from every visitor. `gh release create ${TAG_PREFIX}${VERSION} --title "<title>" --notes-file <Phase-1 section file> --latest[=false] --verify-tag`. The title convention is `<$DISPLAY_NAME> ${VERSION}: <summary>``$DISPLAY_NAME` is the row's declared `display-name`, or `$TARGET` itself when the row declares none — with no em-dash separator. **`<summary>` is derived, not invented** (MEDIUM, adversarial review 2026-07-31, run 3: it appeared exactly once in this file and was never defined, so it was whatever the agent made up): take the single highest-precedence entry from the Phase 1 section — the first bullet under `### Added` if the window bumped minor, otherwise the first bullet under `### Fixed`, else the first bullet of the first non-empty group — and compress it to a noun phrase under ten words, in the entry's own words. If that yields nothing usable because the section has one group with one terse bullet, use that bullet verbatim. Never write a summary that names a change absent from the section.
3. Handle edge cases explicitly, never silently: if a Release for the tag already exists, report it and skip creation (the tag push may already have landed); if `gh` is missing, unauthenticated, or the call fails, STOP and print the exact `gh release create` command so publication can be finished by hand rather than left half-done.
4. **Verify publication — never assume it.** Read the Release back: `gh release view ${TAG_PREFIX}${VERSION} --json url,isDraft,tagName`. STOP unless it returns a **non-draft** Release on the correct tag; `gh release create` can partially succeed (tag pushed, Release rejected for an empty notes-file or a permissions/`--verify-tag` race), and an unverified publish is not a published release. Report the Release URL only once the read-back confirms it.
5. **Record the tag's provenance, when the row declares one.** A git tag is a mutable ref, and the commit a tag was *originally* published at is not recoverable from the API once it moves — a moved tag looks exactly like a tag that was always there. So, when `$TARGET`'s row declares `$PROVENANCE_MANIFEST`, write it down: add the new tag to that file under `tags`, as `{"object_sha": <the ref's sha>, "object_type": "tag", "commit_sha": <the commit it dereferences to>}`. Read both mechanically from the remote you just pushed to, never from local state: `git ls-remote --tags origin ${TAG_PREFIX}${VERSION}` for the ref sha and `git rev-parse ${TAG_PREFIX}${VERSION}^{commit}` for the commit. If this project runs an automated tag-drift check in CI against that file, an unrecorded tag is an unguarded tag there. **A row declaring no `$PROVENANCE_MANIFEST` skips this step — say so explicitly in the report** rather than silently doing nothing (a skipped step and a forgotten one must never look the same to the person reading the report). The entry rides in a normal commit through `commit-gate` on the release branch.
Gate: with authorization, the tag is pushed AND a non-draft GitHub Release on that tag is confirmed by read-back (or, on failure, the exact manual command was surfaced and the half-finished state named), AND, when the row declares `$PROVENANCE_MANIFEST`, the tag's provenance is recorded there (or the report explicitly says the row declares none). A failed or unverified publish is NOT a passing gate. Without authorization, nothing is published.
## Recovering from a bad release
**A published tag is immutable. Correction means publishing a NEW version — never moving, re-pointing, or deleting the old one.** Where this project records that as a maintainer ruling (an ADR, an issue, a decision log entry — commonly paired with a hosting-service tag-protection setting and a deliberate **no break-glass role**), that ruling stands; the doctrine below does not depend on one existing to be true.
This is not a style preference. Consumers are instructed to pin an exact tag, so the tag *is* the identity of a payload that review, CI, and a published changelog have all vouched for. Retargeting a published tag does not fix it for anyone who already installed it; it silently changes what everyone who installs it *next* gets, under a version whose verification history now describes different code. Deleting it is worse — every pinned install breaks at once, with no version left to roll back to.
When a release is wrong:
1. **Leave the bad tag and its Release exactly where they are.** Do not `git push --force` the tag, do not `git push --delete`, do not `gh release delete`. The bad version staying visible is what lets a consumer tell which payload they got.
2. Fix the defect on a branch and land it through the normal PR path.
3. Run `/release $TARGET` again. The bump is derived from that target's commit log as usual, so the fix ships as the next patch (or higher) version in the same series.
4. Mark the bad release so nobody installs it on purpose: `gh release edit <bad-tag> --prerelease` demotes it out of the Latest position, and a note at the top of its body should name the superseding version. Editing release *notes* is fine — it changes no code and moves no ref.
5. If the bad release is actively harmful (a leaked secret, a destructive bug), say so in the new release's notes and in the old release's body. An advisory is the sanctioned way to un-recommend a version; a moved tag is not.
The one case that is **not** a correction: a tag pushed by mistake with **no** GitHub Release and no possibility of a consumer having fetched it. Even then, prefer superseding it. If it must be removed, that is a maintainer action taken deliberately and announced, and, when `$TARGET`'s row declares `$PROVENANCE_MANIFEST`, that file must be updated in the same PR so a drift audit does not report a deletion it was told to expect.
**If this project runs an automated tag-immutability drift check in CI, the manifest is the witness, not the suspect, when it reports drift.** Such a check compares live tag refs against a committed provenance manifest; going red means a published ref moved. The fix is to restore the ref to its recorded sha and find out who moved it. Editing the provenance manifest to match the moved sha would "fix" the check by deleting the evidence — never do that to silence a red run. The only legitimate provenance-manifest edits are recording a newly published tag (Phase 3 step 5, when the row declares one) and a deliberate, announced removal. A project with no such CI check, or no declared `$PROVENANCE_MANIFEST`, still keeps the doctrine above in full — the check is a detection layer for the rule, not the rule itself.
## Hard rules
- MUST resolve `$TARGET` to exactly one declared row before anything else, and MUST STOP on an unrecognised target rather than guessing which project was meant.
- MUST NOT tag on a red suite — `commit-gate` enforces green on every commit reaching HEAD; do not re-run it, but do not tag if the last suite was red.
- MUST NOT write to `main`, `master`, or the default branch, and MUST NOT force-push. Releases land through the normal branch/PR path.
- MUST NOT push the tag or create the GitHub Release without explicit user authorization; they publish together in Phase 3, even after the local tag composes.
- MUST scope tag resolution, the commit window, and the bump derivation to `$TARGET`'s series and `$PAYLOAD`; another target's tag or commit MUST NOT influence this release's version, window, or changelog. MUST NOT resolve `LAST_TAG` with bare `git describe --tags`, and MUST take `$TAG_PREFIX` from the declared file rather than typing it.
- MUST assert the derived version equals every path declared in `$MANIFEST`. MUST NOT hand-edit a path also listed in `$GENERATED_MANIFEST` — regenerate it via the row's declared `generate` command instead.
- MUST rebuild `$ARTIFACTS` (via the row's declared `rebuild`, when one exists) and assert every committed bundle is clean before tagging; a target ships the built file, not its source.
- MUST run every declared `pre-tag` check, in declared order, and BLOCK on a non-zero exit (DECISION-0034) — this is how a target's own extra release surfaces (a badge, a catalog table) get asserted consistent; this skill assumes nothing about what any target's surfaces are beyond what the row declares.
- MUST verify the published Release by read-back (`gh release view` → non-draft, correct tag); a failed or unverified publish is not a passing gate.
- MUST NOT assert `--latest` for any target whose row does not declare `latest-eligible: true`, and not even then unless the tag is the newest release across every declared series.
- MUST use the Phase-1 changelog section verbatim as the GitHub Release notes — never re-derive or hand-write them.
- MUST NOT guess the version — derive it from the commit log. A `feat` in the window cannot ship as a `patch`.
- MUST NOT auto-fill a missing `CHANGELOG:` footer, and MUST NOT tag past one — a missing footer on a bumping commit is a Phase-1 BLOCK, surfaced as `[NEEDS-TRIAGE]` and stopped.
- MUST NOT tag a non-bumping window — `test`/`docs`/`chore`/`ci`-only sets do not release.
- **MUST NOT move, retarget, delete, or re-point a published tag**, in any declared target's tag namespace, for any reason — no `git push --force` on a tag, no `git push --delete`, no `gh release delete`. A bad release is corrected by publishing a NEW version; see "Recovering from a bad release". There is no break-glass path.
- MUST record every newly published tag in the row's declared `$PROVENANCE_MANIFEST`, when one is declared, and MUST NOT edit an existing entry to silence a red tag-immutability drift check — a red run means a ref moved, and the manifest is the evidence of where it belonged.

View in repo