arbiterForge/codeArbiter/plugins/ca/skills/release/SKILL.md
release
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
- Source repository stars
- 138
- Declared platforms
- 0
- Static risk flags
- 3
- Last source update
- 2026-08-04
- Source checked
- 2026-08-04
Decision brief
What it does—and where it fits
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.
Not for
- Tasks that require unconfirmed production actions or broad system permissions.
- Environments where the pinned source and install steps cannot be inspected.
Compatibility matrix
Platform support, with evidence labels
| Platform | Status | Evidence | What to check |
|---|---|---|---|
| Codex | Not declared | No explicit evidence | Portability before use |
| Claude Code | Not declared | No explicit evidence | Portability before use |
| Cursor | Not declared | No explicit evidence | Portability before use |
| Gemini CLI | Not declared | No explicit evidence | Portability before use |
Installation
Inspect first. Install second.
The source command is displayed only when detected. A safe inspection prompt is always available so your agent can explain every action before execution.
npx skills add https://github.com/arbiterForge/codeArbiter --skill "plugins/ca/skills/release"Inspect the Agent Skill "release" from https://github.com/arbiterForge/codeArbiter/blob/53c788cd82ddbb50c50d657775edf5a01f10dc9e/plugins/ca/skills/release/SKILL.md at commit 53c788cd82ddbb50c50d657775edf5a01f10dc9e. List every install step, command, network request, credential, file read/write, external action, and rollback step. Explain whether it fits my task. Do not install or execute anything until I approve.
Workflow
What the source asks the agent to do
- 01
Phase 1 — Version & changelog · gate: BLOCK
Derive the bump mechanically from the commit log; do not guess it.
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).Classify the window through the tested helper, not by hand: git log $WINDOW --pretty=format:%H%n%s%n%b%n---- -- $PAYLOAD | "$PY" "${CLAUDEPLUGINROOT}/hooks/releaselib.py" classify-window. It prints the derived bump on t…BREAKING CHANGE: footer or ! after the type/scope → major. - 02
Phase 2 — Tag & report · gate: BLOCK
1. Compose the annotated tag message from the Phase 1 section plus a Released-at: $RELEASEDATE footer (the same date derived once in Phase 1) into a message file, then assert the two dates agree: "$PY" "${CLAUDEPLUGINROOT}/hooks/releaselib.py" dates-match must exit 0. (This chec…
Compose the annotated tag message from the Phase 1 section plus a Released-at: $RELEASEDATE footer (the same date derived once in Phase 1) into a message file, then assert the two dates agree: "$PY" "${CLAUDEPLUGINROOT}…Report: $TARGET, version, bump rationale, the per-commit classification, the changelog section, and the tag SHA.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. - 03
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.
Push the tag: git push origin ${TAGPREFIX}${VERSION}.Guard the notes-file first: assert its first heading matches the tag — "$PY" "${CLAUDEPLUGINROOT}/hooks/releaselib.py" notes-match ${TAGPREFIX}${VERSION} (exit 0). A stale notes-file (notesheadingmatches False) would pu…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 p… - 04
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) →…
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 n…A manifest path also listed in $GENERATEDMANIFEST 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 t…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 r… - 05
Back-fill (no declared file yet)
loadtargets raises AbsentBlockError when ${CLAUDEPROJECTDIR}/.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 t…
Detect. From the project root, run "$PY" "${CLAUDEPLUGINROOT}/hooks/releaselib.py" backfill-detect. It scans the repo root for exactly one candidate manifest (package.json, pyproject.toml, Cargo.toml, composer.json) and…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 an…Exactly one candidate of each kind (exit 0): the command prints the exact release-targets.md block it would write, already in the grammar loadtargets accepts, and it declares latest-eligible: true (HIGH-2, adversarial r…
Permission review
Static risk signals and limitations
Reads files
The documentation asks the agent to read local files, directories, or repositories.
*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 aReads files
The documentation asks the agent to read local files, directories, or repositories.
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 delimiterRuns scripts
The documentation asks the agent to run terminal commands or scripts.
*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 bRuns scripts
The documentation asks the agent to run terminal commands or scripts.
*This does NOT yet extend to a Windows-hosted row** (measured, not theoretical). `run-pre-tag` executes `$PRE_TAG` via Python's `subprocess.run(command, shell=True, …)`, and on Windows `shell=True` always dispatches through `cmd.exe` regardWrites files
The documentation asks the agent to create, modify, or delete local files.
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_consistWrites files
The documentation asks the agent to create, modify, or delete local files.
**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 Evidence record
Why each signal appears
| Signal | Value | Evidence type | Meaning |
|---|---|---|---|
| Quality score | 89/100 | Computed | Documentation, specificity, maintenance, and trust rules |
| Repository stars | 138 | Source | Repository attention, not individual Skill quality |
| Compatibility | 0 platforms | Source | Declared in the catalog source record |
| Usage guide | automated source guide | Editorial | Generated or reviewed according to the visible evidence level |
Pinned source
Provenance and original SKILL.md
- Repository
- arbiterForge/codeArbiter
- Skill path
- plugins/ca/skills/release/SKILL.md
- Commit
- 53c788cd82ddbb50c50d657775edf5a01f10dc9e
- License
- AGPL-3.0
- Collected
- 2026-08-04
- Default branch
- main
View the original SKILL.md
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:
PY=python3; { command -v python3 >/dev/null 2>&1 && python3 --version >/dev/null 2>&1; } || PY=python
command -v alone is not enough (LOW, #584): a Windows host commonly ships a python3 App Execution Alias stub at %LOCALAPPDATA%\Microsoft\WindowsApps\python3 that satisfies command -v python3 with no Python actually installed — running it opens the Microsoft Store and exits non-zero. Only actually RUNNING it (python3 --version) tells the truth; command -v merely tells you a name resolves on PATH. python3 wins whenever both it and python are genuinely present — the resolve-once order above tries it first and only falls back to python when it is absent or the stub — so a host with both interpreters gets the one this convention exists to prefer, not an arbitrary pick.
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:
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.
Split a multi-valued field this one sanctioned way (LOW, #585) — a subshell so a temporary IFS change never leaks into the rest of the lane:
(IFS=,; for a in $ARTIFACTS; do git diff --quiet -- "$a" || …; done)
Every consumer of $ARTIFACTS/$PRE_TAG/$MANIFEST splits this way, not a hand-rolled cut/awk/read -a of its own — a second splitting rule is a second place for an edge case (a value containing a literal comma, an empty field) to disagree with this one.
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:
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, once, in a subshell (( eval "$REBUILD" )) so it cannot move this lane's working directory; 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.
The interpreter convention extends to DECLARED row commands, not only to this skill's own invocations (#583 MEDIUM-2 / #584 MEDIUM-3). $PRE_TAG, $REBUILD, and $GENERATE are operator shell this lane EXECUTES — via run-pre-tag for pre-tag, and directly for rebuild/generate — exactly the same as any command spelled directly in this file, so a row hardcoding python3 fails on exactly the host the interpreter paragraph above exists for, and that failure was previously mis-diagnosed as drift rather than a missing interpreter. run-pre-tag exports PY (its own resolved interpreter) into every declared pre-tag command's environment, and this lane's own shell defines $PY before ( eval "$REBUILD" ) runs — so a POSIX-hosted row SHOULD spell "$PY" in place of a hardcoded interpreter, the same way this file does.
This does NOT yet extend to a Windows-hosted row (measured, not theoretical). run-pre-tag executes $PRE_TAG via Python's subprocess.run(command, shell=True, …), and on Windows shell=True always dispatches through cmd.exe regardless of what shell launched the outer process — a CPython subprocess behavior, not host configuration. cmd.exe performs no $VAR expansion, so a row spelled "$PY" is passed through as the literal, unrecognized token "$PY", which fails with an exit code outside the documented could-not-run set (126/127/9009) — so it reads as exit 5 (drift), the exact misdiagnosis this convention exists to prevent, not as exit 7. Until run-pre-tag resolves a POSIX-compatible shell on Windows, a Windows-hosted row MUST keep a concrete interpreter (python, not "$PY") in pre-tag, rebuild, and generate.
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_MANIFESTis 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 declaredgeneratecommand 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-excludeentries 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=falseEXPLICITLY. Omitting the flag is not declining it: GitHub defaultsmake_latestto 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.
-
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-creationinstead, 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.mdblock it would write, already in the grammarload_targetsaccepts, and it declareslatest-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.
- 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
-
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: trueline 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. -
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.mdis 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):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: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 emitspayload: ., 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 throughcommit-gateon the current branch, aschore: declare release targets(or an equivalent non-bumping type), BEFORE re-entering Pre-flight. This is expected, not a defect: achorecommit 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$TARGETexactly as "Targets" above describes. -
A second invocation reads; it does not re-detect. Once the file exists on disk,
load_targetssucceeds 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 noCONTEXT.mdyet, 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. The mechanical fallback below is keyed on the FACT being unresolvable, not on the FILE being absent (MEDIUM, #584:CONTEXT.mdpresent but silent on the default branch is a THIRD state, distinct from both "absent" and "present and resolvable" — [never-fold-unreadable-into-absent] applies here as much as anywhere else). Resolve the default branch directly whenever it cannot be read fromCONTEXT.md— the file is absent, OR it exists but carries no case-insensitive match for the default-branch fact: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. Report which of the two states held — "noCONTEXT.md" and "CONTEXT.mdexists but does not name a default branch" are different facts about the project, and the report should say which one this run hit rather than collapsing them into one silent fallback. This excuses only the default-branch FACT being unresolvable, and only for the one thing this skill actually readsCONTEXT.mdfor; a project with no.codearbiter/state at all and no interest in this narrow release-only footprint is still routed tocontext-creationfor full onboarding, per "Targets" above. -
$TARGETmust resolve to a declared row (see "Targets" above). An unrecognised target STOPs; do not guess which project was meant. -
git statusmust be clean. A dirty tree STOPs — commit or stash viacommit-gatefirst.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.
git status --porcelain -- :/ ':(exclude,top).codearbiter/gate-events.log' ':(exclude,top).codearbiter/.markers/':/and,topare 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,topmakes each exclusion root-relative, so the answer is the same from anywhere. That matters here because therebuildstep 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. -
Fetch tags before resolving
LAST_TAG(LOW, #585):git fetch --tags origin. A clone whose local tags lag the remote silently bases the whole release on a stale baseline — a tag published from elsewhere never entersLAST_TAG's comparison at all. A failed fetch is reported, not swallowed; on failure the lane MAY proceed on local tags only, and only with the user's explicit acknowledgment that the baseline may be stale. -
Resolve
LAST_TAGfrom$TARGET's series only — never baregit 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_selectreturns 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 ofLAST_TAG(or0.0.0whenLAST_TAGis<none>— the sentinel is not a version and never enters the comparison), and the highest version any declaredmanifestcurrently 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 fromLAST_TAG, this target has shipped one or more versions that were never tagged in its own series — and$WINDOW, which starts atLAST_TAG, therefore spans commits that already went out under those versions.$BASE_VERSIONfloors the VERSION against that, but nothing floors the CHANGELOG: Phase 1 step 5 rolls everyCHANGELOG:footer in$WINDOWinto 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_TAGwasv2.8.13while the manifest read2.11.0,CHANGELOG.mdalready 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_TAGversion, 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, soLAST_TAGand the manifest agree again — not something this lane infers. Once they agree, re-enter Pre-flight and$WINDOWspans 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.2without ever tagging in this series derived0.1.0and 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.0tag and a1.4.2manifest derived1.3.0from 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.0is 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$WINDOWfrom it before using it anywhere (HIGH, adversarial review 2026-07-31, run 5): every command below spells the window$WINDOW, and$WINDOWis${LAST_TAG}..HEADwhen a tag was found and bareHEADwhenLAST_TAGis<none>. Substituting the sentinel into a range is a hard failure, not a soft one —git log <none>..HEADexits 128 withfatal: 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-basedgit 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_selecthas 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. - Without the manifest half, a project that had shipped
-
Scope the release window to
$PAYLOAD: the commit set isgit log $WINDOW -- $PAYLOAD, NOT the whole repo — afeat(some-other-target)commit must not bump$TARGETor 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
versionfield 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_MANIFESTis not "updated" directly — it is regenerated by the row's declaredgeneratecommand, and the same equality assertion is what confirms the regeneration landed on the derived version. -
$ARTIFACTSfreshness — rebuild unconditionally: every release, regardless of whether the sources changed in the window, run the row's declaredrebuildcommand (when one is declared) in a subshell, so it cannot move this lane's working directory —( eval "$REBUILD" ) || { echo "STOP — the rebuild itself failed; fix the build before trusting any freshness assertion" >&2; exit 1; }— and only THEN assert every path in$ARTIFACTSis in sync (git diff --quiet -- <each artifact>). The subshell's own exit code MUST be checked, and a non-zero exit STOPs (MEDIUM, #585): nothing previously said the rebuild had to SUCCEED, and a failed build leaves the PREVIOUS artifacts in place — so the freshness assertion below would bless a stale bundle the broken build failed to update, reading a build that never ran as a build that produced nothing new. The subshell is the fix for a measured HIGH (blind exercise run 17), not a style preference: a declaredrebuildcommonly BEGINS withcd(this repository's own row iscd <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 -- $PAYLOADreturns 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. Thisevalis 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 thingrun-pre-tagdoes forpre-tagcommands. 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 throughcommit-gatebefore tagging. Scope is$TARGETonly: another target's stale bundle is that target's release problem, not this one's. A row declaring neitherrebuildnorartifactshas 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.
-
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). -
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 aCHANGELOG: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 — sofeat!:classified as a minor,feat(api)!:the same, andchore!: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/cionly → no bump. If the whole window is non-bumping, STOP — there is nothing to release.
-
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_TAGis<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 from BOTH candidate adoption files, notCONTEXT.mdalone (HIGH, #585 — blind exercise run 18: a Back-fill consumer's own first release cannot clear this step, becauseCONTEXT.mdis exactly the file Pre-flight says it does NOT have "by design", so an unflooredADOPTEDresolves empty and the entire history re-enters the footer check).ADOPTED=$(git log --diff-filter=A --format=%H -- "${CLAUDE_PROJECT_DIR}/.codearbiter/CONTEXT.md" "${CLAUDE_PROJECT_DIR}/.codearbiter/release-targets.md" | "$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py" adoption-commit).adoption-commitalready takes the OLDEST line of its stdin (git logprints newest-first; the helper takes the last line), so feeding it both files' addition history together — rather than either alone — resolves to whichever file the project adopted with FIRST: a fully-onboarded consumer'sCONTEXT.mdaddition, or a Back-fill consumer'srelease-targets.mdaddition, which the Back-fill lane above itself creates as thechore: declare release targetscommit. Either way, this is the honest moment the changelog convention arrived for THIS project — the adoption boundary was never really "whenCONTEXT.mdwas added", it was "when this skill's own footer convention started applying", and for a Back-fill consumer that is the back-fill commit, not a file that by design does not exist. Empty output means no adoption commit exists and there is nothing to floor. Otherwise offer$ADOPTEDas 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: everyfeat/fix/perf/refactorcommit (the full harvested set step 5 rolls into the changelog, not justfeat/fix) MUST carry aCHANGELOG: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 missingCHANGELOG:footer (git commit --amendif 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 oforigin/mainon 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 footerlessperfused to bump the version and vanish from the changelog silently, because the old BLOCK sentence named onlyfeat/fixwhileperfwas harvested anyway; a footerlessrefactorused 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. ACHANGELOG:footer on atest/docs/chore/cicommit 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. -
Apply the step-2 bump to
$BASE_VERSIONthrough the tested helper, never by eye (MEDIUM, #585 MEDIUM-3):VERSION=$("$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py" apply-bump "$BASE_VERSION" <word>), where<word>is step 2'sclassify-windowoutput — its FIRST line, used verbatim, never re-typed or re-derived. Before this, step 4 was the ONE piece of arithmetic left to the operator in a lane whose every other mechanical step already had a subcommand: none of the fifteen prior subcommands does this bump, and nothing downstream re-derives it — aminorwindow mis-applied as apatchstill passessemver-greater(it compares the wrong value against itself) andcheck-manifests(it compares the manifests against the same wrong value step 6a just wrote), so the file's own hard rule "afeatin the window cannot ship as apatch" had no enforcement anywhere in the lane. 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 toLAST_TAGdirectly, 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_VERSIONmust 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_VERSIONrather 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 againstLAST_TAGas well: on a first releaseLAST_TAGis<none>, which is not a version, andsemver-greatercorrectly 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_VERSIONalready 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$MANIFESTpath to the derived version, and confirming it, happens ONCE, in step 6.) -
Derive the release date once —
RELEASE_DATE=$(date +%F)— and reuse that single value for the changelog header, the Phase-2Released-at:footer, and the Phase-3 Release; never hand-type the date a second time (release_dates_consistentverifies the changelog-header date equals theReleased-at:date). Roll theCHANGELOG:footers from eachfeat/fix/perf/refactorcommit into a new## [${VERSION}] — $RELEASE_DATEsection in$CHANGELOG(the Keep-a-Changelog bracket heading this guard matches, not the barev-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 aCHANGELOG:footer from anytest/docs/chore/cicommit 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# Changelogheading 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 themedium-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$CHANGELOGitself, 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 apayload: .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. -
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
$MANIFESTto the derived version, except a path also listed in$GENERATED_MANIFEST— that one is never hand-edited; run the row's declaredgeneratecommand (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_MANIFESTpath 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-tagcommands have been read."$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/releasehash.py" check $TARGETmust 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 nopre-tagcommands reportsno-commandsand exits 0, which is deliberately distinct fromconfirmed.6d. Run them.
$PRE_TAGis the row's declaredpre-tagcommands (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 $TARGETmust 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. Each declared command runs withPYset in its own environment to this runner's own resolved interpreter, so a row may portably spell"$PY"instead of a hardcoded interpreter (see "Targets" above). Its exit codes are distinguishable: 5 a command RAN and 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 — 7 a command's interpreter or program itself could not be located or executed at all, and 8 the tree-state PROBE itself failed. 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.
On exit 7, this is NOT drift — do not apply exit 5's remedy (#585 MEDIUM-2 / #584 MEDIUM-1: "could not run" is never "ran and disagreed"). The command's interpreter or program itself could not be located, so it never actually ran and nothing was checked at all — a missing interpreter is not a policy violation to reconcile. Fix the interpreter this row names for THIS host (a common cause: a row hardcoding a specific interpreter, e.g. python3, on a host that has only python) and re-run — on a POSIX host that commonly means spelling it "$PY", but NOT on Windows; see the Windows caveat under "Targets" above. No release-edit discard is needed here, unlike exit 5: nothing was checked, so there is nothing to undo.
On exit 8, the probe failed, not a command — surface it and investigate the tree-state probe itself (not a git repository, an unreadable .git, or similar) before re-running; no verdict about any declared command exists yet, so neither exit 5's nor exit 6's remedy applies.
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. A run-pre-tag exit of 7 (a command could not run at all) or 8 (the tree-state probe itself failed) is not a BLOCK on this gate's own merits and not a genuine drift/mutation finding either — both STOP the lane for investigation, distinct from — and never diagnosed as — exit 5's drift or exit 6's mutation.
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. Exit 7 and exit 8 are neither of the above — see 6d's own exit-code paragraphs; neither means the declaration is broken, and neither authorizes a wholesale discard, because nothing has been checked yet on either path.
Phase 2 — Tag & report · gate: BLOCK
- Compose the annotated tag message from the Phase 1 section plus a
Released-at: $RELEASE_DATEfooter (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_consistentwas reachable only by importing the module, which this skill never tells anyone to do.) Do not rungit tagyet — 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 reachclassifywith no tag written by this invocation. The tag command itself appears below, in thepublish_freshbranch, and nowhere else. Resolve<tag_exists>and<tag_sha>from the LOCAL ref this way, and never from a baregit rev-parse ${TAG_PREFIX}${VERSION}(HIGH-1, adversarial review 2026-07-31): an annotated tag's own object id — whatgit rev-parseon a bare tag name returns — is not the commit it names, so feeding that value straight intoclassifybelow would classify a perfectly healthy tag asabort_mismatchand 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-refexits non-zero for a tag that does not exist yet, which is the common case here, where naming it directly would break aset -elane; dumping the whole set and lettingpeel-tagfind (or not find) the one line puts the PIPELINE's exit status inpeel-tag's hands rather thangit show-ref's, which is what makes it safe underset -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 -ditself 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,classifyshort-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 soclassifyreceives 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>isgit 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 theversionfield of the row's first declaredmanifest, 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, sojq,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 apyproject.tomlit raisesJSONDecodeErrorand exits 1 (MEDIUM, run 7). This reads the FIRST declared manifest, while Pre-flight's$BASE_VERSIONreads 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 claimedclassifycatches a partial bump. It does not, on the path that matters.classify_publish_stateshort-circuits onif 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 withtag_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 makesclassifyreturnabort_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>isfalsewhenever<tag_exists>isfalse(classify short-circuits past it before it is ever consulted), otherwisegh release view ${TAG_PREFIX}${VERSION} --json isDraft --jq '.isDraft'with its answer inverted: that command printstruefor a DRAFT release, so<release_nondraft>isfalsewhen it printstrue, andtruewhen it printsfalse(HIGH, run 4). Pass the bare literaltrueorfalseand nothing else — feeding the raw--jsonobject{"isDraft":false}straight through makes every value coerce falsey, which silently rendersalready_publishedunreachable rather than failing loudly. If thatghcall fails for any reason other than a genuinely absent Release — no remote configured, no network,ghunauthenticated — STOP and surface it rather than passing a guessed value:ghreports "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 withclassify_publish_statevia"$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-mfor multi-line content, never an interactive editor; this is the sole point in the whole lane at which a tag is created.--cleanup=verbatimis load-bearing, not stylistic (HIGH, adversarial review 2026-07-31, run 4; issue #569):git tag's DEFAULT cleanup mode isstrip, 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/### Changedgrouping, 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, notgit 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-matchtakes a path, and a shell-specific stdin spelling would not survive the move to a Windows consumer.)release_dates_consistentcannot catch this on its own — it reads the changelog section from a separate argument and only needs theReleased-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 inresume_publishshortens, 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 somethingclassify_publish_statecan tell fromtag_sha/head_sha/release_is_nondraftalone; distinguishing it would need an addedgit ls-remoteround-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.) - Report:
$TARGET, version, bump rationale, the per-commit classification, the changelog section, and the tag SHA. - 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:
- Push the tag:
git push origin ${TAG_PREFIX}${VERSION}. - 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_matchesFalse) 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.--latestfollows the declared row: assert it only when$TARGET's row declareslatest-eligible: true, and only when this tag is also the newest release across every declared series (compare againstgh release list; vacuously satisfied when only one series is declared); 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_NAMEis the row's declareddisplay-name, or$TARGETitself 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### Addedif 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. - 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
ghis missing, unauthenticated, or the call fails, STOP and print the exactgh release createcommand so publication can be finished by hand rather than left half-done. - 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 createcan partially succeed (tag pushed, Release rejected for an empty notes-file or a permissions/--verify-tagrace), and an unverified publish is not a published release. Report the Release URL only once the read-back confirms it. - 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 undertags, 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 andgit 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_MANIFESTskips 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 throughcommit-gateon 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:
- Leave the bad tag and its Release exactly where they are. Do not
git push --forcethe tag, do notgit push --delete, do notgh release delete. The bad version staying visible is what lets a consumer tell which payload they got. - Fix the defect on a branch and land it through the normal PR path.
- Run
/release $TARGETagain. 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. - Mark the bad release so nobody installs it on purpose:
gh release edit <bad-tag> --prereleasedemotes 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. - 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
$TARGETto 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-gateenforces 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 resolveLAST_TAGwith baregit describe --tags, and MUST take$TAG_PREFIXfrom 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 declaredgeneratecommand instead. - MUST rebuild
$ARTIFACTS(via the row's declaredrebuild, 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-tagcheck, 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
--latestfor any target whose row does not declarelatest-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, and apply it through
apply-bump, never by eye. Beforeapply-bumpexisted, this rule's own "afeatin the window cannot ship as apatch" had no enforcement anywhere in the lane — step 4 was the one arithmetic step still left to the operator, and nothing downstream re-derived it to catch a mis-applied bump. - 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 --forceon a tag, nogit push --delete, nogh 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.
Alternatives
Compare before choosing
arbiterForge/codeArbiter
release
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
arbiterForge/codeArbiter
release
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
arbiterForge/codeArbiter
release
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
ffroliva/gflow-cli
release
Cut a new gflow-cli release — bump version, update CHANGELOG, tag, push, and back-merge.