mcpbeat

Release

arbiterforge/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 changelog section as its notes. A release commit, if needed, routes through commit-gate; the tag and Release are never published without explicit authorization.

18k tokens
context cost
the whole folder, loaded on every use
1
files
instructions only
0
copies elsewhere
how many repositories repackaged it
138
stars on the repo
on the repository, not the skill itself

Install

one command, takes just this skill from the repository
npx skills add https://github.com/arbiterForge/codeArbiter --skill release

The instruction itself

7 sections, as written by the author

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 {{PROJECT_DIR}}/.codearbiter/release-targets.md (grammar and parser contract: {{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" "{{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 || PY=python

Every helper invocation below is then spelled "$PY" "{{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" {{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 {{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" "{{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" "{{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" "{{PLUGIN_ROOT}}/hooks/_releaselib.py" show-row $TARGET --field prefix)
CHANGELOG=$("$PY" "{{PLUGIN_ROOT}}/hooks/_releaselib.py" show-row $TARGET --field changelog)
MANIFEST=$("$PY" "{{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 variableROW="$PY {{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" "{{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 {{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 ReleaseTargetsErrorFileExistsNoBlockError (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" "{{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.
  • 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.
  • 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 {{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):
   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 {{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 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.

  • 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 {{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:

  • {{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 {{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 ,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" "{{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.

  • 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" "{{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 featminor.
  • else any fix, perf, refactorpatch.
  • test / docs / chore / ci only → 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_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 -- "{{PROJECT_DIR}}/.codearbiter/CONTEXT.md" | "$PY" "{{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.
  • 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" "{{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.)
  • Derive the release date onceRELEASE_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 {{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.
  • 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" "{{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" "{{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" "{{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" "{{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.

  • 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

  • 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" "{{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" "{{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 falsepublish_fresh; the same disagreement with tag_exists=trueabort_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" "{{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" "{{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.)
  • 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.

How to use it

Copy the folder

Take arbiterforge/release from the repository into ~/.claude/skills for personal use, or into .claude/skills inside a project.

Check the name does not clash

The agent identifies a skill by the name field in its header. Two skills with the same name cannot sit side by side — one of them will be ignored.