本文へ移動
cccskills
無料GitHub で公開

ca-release

Prepare a declared release target, or preview it with --dry-run. Derive its version and require authorization before publication.

インストール方法を見る

含まれるファイル(1)

  • SKILL.md139.1 KB

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 $PROJECT_ROOT/.codearbiter/release-targets.md (grammar and parser contract: 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.

Execution-shell contract. Every fenced sh command and every inline shell fragment in this workflow runs inside one POSIX-compatible shell session. On Windows, resolve and enter Git for Windows' own bash.exe before the interpreter step below; MUST NOT paste the sh snippets into PowerShell and treat PowerShell's exit status as their verdict. A native PowerShell session may be used only to locate and launch Git Bash, for example by resolving git.exe, taking its installation root, and invoking bash.exe --noprofile --norc from that installation's bin directory. Reject WSL aliases and WindowsApps stubs because they execute in a different filesystem. If no POSIX-compatible shell is available, STOP; do not translate the workflow ad hoc into a second command language.

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
PYTHONDONTWRITEBYTECODE=1; export PYTHONDONTWRITEBYTECODE
PYTHONUTF8=1; PYTHONIOENCODING=utf-8; export PYTHONUTF8 PYTHONIOENCODING
PROJECT_ROOT=$(pwd)
cd "$PROJECT_ROOT" || { printf 'STOP — cannot enter the resolved project root.\n' >&2; exit 1; }

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.

Set PYTHONDONTWRITEBYTECODE and resolve then enter $PROJECT_ROOT before the first invocation as shown. The host-conditional root uses Claude's harness pointer when available and the session cwd on Codex/Pi. It applies to every helper below and prevents a read-only or dry-run inspection from creating __pycache__ beside a vendored helper. Every helper invocation below is then spelled "$PY" "[hooks/<script>](../../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" [hooks/<script>](../../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_ROOT-rooted path passed as an argument. Payload pathspecs are not an exception: load their line-delimited records into positional parameters and expand "$@" as specified 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.

Executable local guards. Define these functions in each shell that enters this workflow, including a fresh hosted or recovery shell. They read state; they do not authorize a write, replace commit-gate, or grant publication permission. Every call below must succeed before its following operation can run.

release_require_clean_tree() (
  tree_status=0
  tree_output=$("$PY" "${PLUGIN_ROOT}/hooks/_releaselib.py" clean-tree-status "$TARGET") || tree_status=$?
  if [ "$tree_status" -ne 0 ]; then
    printf 'STOP — release-tree inspection failed (exit %s); an empty stdout is not a clean tree.\n' "$tree_status" >&2
    exit "$tree_status"
  fi
  if [ -n "$tree_output" ]; then
    printf '%s\n' "$tree_output" >&2
    printf 'STOP — the release tree is dirty.\n' >&2
    exit 1
  fi
  exit 0
)

The clean-tree success predicate is exit 0 AND empty porcelain stdout. A failed or timed-out probe never establishes cleanliness, even when it prints nothing. Use release_require_clean_tree || exit "$?" at every clean-tree gate; never test only -z "$(… clean-tree-status …)". Preserve the underlying helper's contract: clean and dirty successful probes both exit 0, while probe failure is nonzero. The wrapper distinguishes these outcomes without changing that API.

release_require_commit_path() (
  if [ ! -f "$PROJECT_ROOT/.codearbiter/tech-stack.md" ]; then
    printf 'STOP — commit-gate requires .codearbiter/tech-stack.md; declare it through context-creation before writing.\n' >&2
    exit 1
  fi
  if [ -z "${DEFAULT_BRANCH:-}" ]; then
    printf 'STOP — resolve and confirm the default branch before writing.\n' >&2
    exit 1
  fi
  branch=$(git -C "$PROJECT_ROOT" symbolic-ref --quiet --short HEAD) || {
    printf 'STOP — release preparation requires an attached non-default branch.\n' >&2
    exit 1
  }
  case "$branch" in
    main|master|"$DEFAULT_BRANCH")
      printf 'STOP — commit-gate refuses branch %s; use a non-default feature branch.\n' "$branch" >&2
      exit 1 ;;
  esac
  git -C "$PROJECT_ROOT" status --porcelain >/dev/null || {
    printf 'STOP — Git cannot inspect this repository; do not write release files.\n' >&2
    exit 1
  }
)

This pre-write check is only a prerequisite refusal. It does not certify the contents of tech-stack.md, execute its tests, or waive any later commit-gate check. Resolve $DEFAULT_BRANCH using the documented fact/remote/user fallback before calling it; do not infer a default from the current branch name.

If a previously published tag lacks its declared provenance receipt, resolve the target row, then use Receipt-only closeout below. That path does not run Phase 1 or Phase 2 and does not require the historical released commit to remain the current default-branch HEAD. It never publishes again.

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). The parser accepts only a safe Git tag prefix: option-like, whitespace-bearing, control-character, ref-syntax, empty-component, leading-dot, trailing-dot, and .lock components fail before any tag command can run. 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 scalar 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)
CHANGELOG_RECONCILIATIONS=$("$PY" "${PLUGIN_ROOT}/hooks/_releaselib.py" show-row $TARGET --field changelog-reconciliations)
VERSION_POLICY=$("$PY" "${PLUGIN_ROOT}/hooks/_releaselib.py" show-row $TARGET --field version-policy)
INITIAL_VERSION=$("$PY" "${PLUGIN_ROOT}/hooks/_releaselib.py" show-row $TARGET --field initial-version)
RELEASE_BUILD=$("$PY" "${PLUGIN_ROOT}/hooks/_releaselib.py" show-row $TARGET --field release-build)
VERSION_POLICY=${VERSION_POLICY:-semver}

…and so on for scalar generate, rebuild, provenance-manifest, latest-eligible, and display-name. Read every multi-valued field with list-field <target> <field>, which emits one declared item per line in declaration order. In particular, print list-field "$TARGET" pre-tag before recording the executable-input confirmation, and use it for the report; a valid shell command may contain a comma, so show-row --field pre-tag is display-only compatibility output and MUST NOT be reparsed as a list. An undeclared list prints no records. Default only an empty version-policy to semver; never default an invalid non-empty policy or invent initial-version. An undeclared changelog-reconciliations field means no reconciliation ledger exists; never infer one from repository contents.

Traverse a multi-valued field from its line-record file. Materialize only outside the working tree, then load each record without word splitting:

ARTIFACTS_FILE=$(mktemp)
"$PY" "${PLUGIN_ROOT}/hooks/_releaselib.py" list-field "$TARGET" artifacts > "$ARTIFACTS_FILE"
while IFS= read -r a; do git diff --quiet -- "$a" || …; done < "$ARTIFACTS_FILE"

Use the same one-record-per-line shape for manifest, generated-manifest, release-assets, and report-time pre-tag. Never use comma splitting, cut, awk, an unquoted command substitution, or the comma-flattened compatibility value from show-row; those forms cannot preserve a declared command containing a comma. Path-valued list fields reject literal commas so older compatibility displays also cannot collapse two accepted paths into one.

MUST NOT collapse the repetition into a command held in a variable — ROW="$PY [hooks/_releaselib.py](../../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.

The payload is a list of git PATHSPEC arguments, not one shell word. Materialize it from the dedicated subcommand, NOT from show-row's payload field. The helper emits one argument per line, and the scratch file preserves spaces inside an argument:

PAYLOAD_PATHS_FILE=$(mktemp)
"$PY" "${PLUGIN_ROOT}/hooks/_releaselib.py" payload-pathspec "$TARGET" > "$PAYLOAD_PATHS_FILE"
[ -s "$PAYLOAD_PATHS_FILE" ] || { echo "STOP — the payload pathspec set is empty" >&2; exit 1; }

Before each payload-scoped Git command, load those lines into positional parameters without reparsing them: set --; while IFS= read -r pathspec; do set -- "$@" "$pathspec"; done < "$PAYLOAD_PATHS_FILE", then pass "$@" after --. Do not use an unquoted scalar or command substitution: a valid payload such as app dir/ would split into app and dir/, while quote characters printed by a helper would remain literal data rather than becoming shell syntax. Remove only this positively identified scratch file when the invocation ends.

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 argument set.

From the resolved row:

fieldmeaning
$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
$CHANGELOG_RECONCILIATIONS (changelog-reconciliations)optional strict JSON ledger of explicit, full-SHA changelog-note reconciliations for already-published commits; it can supply note text only and never changes classification, version policy, or release scope
payload pathspec set (payload, minus payload-exclude)the commit-window and rebuild-freshness scope, preserved one argument per line in $PAYLOAD_PATHS_FILE
$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)
$VERSION_POLICY (version-policy)version grammar and arithmetic; omission defaults to semver
$INITIAL_VERSION (initial-version)required fixed-shape floor for numeric-sequence; empty for semver
$RELEASE_BUILD (release-build)optional protected operator input: either the hosted-build command that produces the declared assets, or a fail-closed sentinel documenting that a reviewed exact-head CI cohort owns assembly
$RELEASE_ASSETS (release-assets)optional repeated flat filename templates; declared together with $RELEASE_BUILD
$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, $GENERATE, and a $RELEASE_BUILD selected by the reviewed hosted-build path are operator shell this lane EXECUTES — via run-pre-tag for pre-tag, and directly for the other executable commands — 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. A fail-closed $RELEASE_BUILD sentinel documenting that the retained exact-head cohort owns assembly is confirmed and reported but deliberately not executed; treating its expected refusal as an arbitrary ignored build failure is forbidden. 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 the other commands run — so a row SHOULD spell "$PY" in place of a hardcoded interpreter, the same way this file does.

This now extends to a Windows-hosted pre-tag row too (#602, closing the gap measured when the paragraph above was first written). run-pre-tag resolves a POSIX-compatible shell (Git for Windows' own bash.exe, found deterministically relative to git --exec-path — never WSL's same-named bash.exe stub under system32/WindowsApps, which runs inside a separate Linux filesystem) and dispatches $PRE_TAG through it directly, rather than falling through to subprocess.run(shell=True)'s default cmd.exe, which cannot expand $VAR. A row spelled "$PY" now expands the same way on every platform this skill runs on. When no POSIX shell can be resolved on a Windows host at all — no Git for Windows install, no bash reachable — run-pre-tag reports a distinct "could not run" diagnosis (exit 9, never 5 or 7) rather than misreading the absence as drift; the remedy is installing Git for Windows (which ships bash.exe) or putting an existing Git-for-Windows bash.exe on PATH.

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_ROOT/.codearbiter/release-targets.md does not exist on disk at all — the ONE gap this skill does not merely STOP on. This is mechanically distinct from an EXISTING file that merely carries no delimiter block, which raises the sibling FileExistsNoBlockError instead (HIGH-1, adversarial review 2026-07-31) — parse_release_targets sees text only and cannot itself tell "no file" from "a file with no block" apart, so load_targets, the one function that knows whether open() actually succeeded, makes the distinction and raises the two as siblings under ReleaseTargetsError rather than one subclassing the other. Every OTHER ReleaseTargetsError — FileExistsNoBlockError (exists, no block), or a malformed, empty, duplicate, or otherwise unparseable EXISTING file — still STOPs outright per "Targets" above; this lane triggers ONLY on AbsentBlockError and never runs against a file that already exists, in any state, however broken — a broken declaration is a different failure from a missing one, and detecting a shape to paper over it would silently discard the operator's own (bad) declaration. From the CLI this same distinction is an exit code, not free text to parse: tag-prefix and list-targets both exit 3 for the genuinely-absent case (the lane's ONE trigger) and 4 for every other declared-file error.

  1. Detect. From the project root, run "$PY" "${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. It declares payload: . together with payload-exclude: .codearbiter/, because this release-only adoption lane's hooks create governance scratch there and a root payload without the exclusion can never satisfy its own clean-tree gate. It declares the changelog-reconciliation ledger under the consumer project's protected state as well: a real project may already have published bumping commits from before the CHANGELOG: footer convention existed, and Back-fill must land the operator-authored exact-SHA bridge for that history before the first release reads it. It also 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 next step shows all declarations VERBATIM before anything is written, so the operator may narrow the payload or strike eligibility before confirming.
  2. Present, and require explicit confirmation before doing anything else. Show the printed block to the user VERBATIM. Do NOT write it, and do NOT proceed to Pre-flight or any phase below, until the user explicitly confirms the detected shape is correct — including the reconciliation-ledger path and the latest-eligible: true line above, which the operator may strike before confirming if this project's badge should live elsewhere. A refusal STOPs the lane — nothing is written, and nothing is proposed a second time without a fresh detection pass.

  3. Persist, only on confirmation — and re-check existence immediately before writing, regardless of how this lane was entered. Before minting any marker or writing anything, confirm no file exists yet at $PROJECT_ROOT/.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.

    Also confirm commit-gate can actually take this write before minting anything (#570 R4-08/R8-05/R8-06/R10-08, P6). The write below dirties the tree and, per its own closing paragraph, must be committed through commit-gate — and commit-gate's own Pre-flight requires $PROJECT_ROOT/.codearbiter/tech-stack.md to exist ("Stop if missing; do not guess.") before it will run at all. A release-only consumer that reached this lane without ever running full onboarding commonly has no tech-stack.md yet, so commit-gate would otherwise fail only after this lane has already detected, confirmed, and minted a marker for a write it cannot land. Check [ -f "$PROJECT_ROOT/.codearbiter/tech-stack.md" ] HERE, before minting the marker or writing anything below. Missing → STOP, mint no marker, write nothing: report that commit-gate requires a declared tech-stack.md (the test/lint/secrets-scan commands it reads) that this project does not have, and that context-creation is the way to declare one; re-enter Back-fill once it exists. This is not a new onboarding-free mode for Back-fill (P6) — this lane still cannot commit its own write without context-creation supplying tech-stack.md first; the check only moves that discovery ahead of the marker mint and the write, instead of behind them and inside commit-gate itself. It does not bypass or replace commit-gate — the commit two paragraphs below still routes through it, in full, exactly as before.

    Also confirm the current branch is not main, master, or the project's default branch before minting anything (#570 R4-06/R6-06, AC-11 — the direct rule conflict this closes: without this check here, an operator on a protected branch was told to write the confirmed block and commit it, and was refused only afterward, when this skill's own Pre-flight branch check ran on re-entry or when commit-gate itself finally declined the commit). Run git branch --show-current and compare it against main and master literally, plus — the same restriction Pre-flight's own branch check states below, resolved the same way: $PROJECT_ROOT/.codearbiter/CONTEXT.md's default-branch fact when it exists and names one (this lane, by design, commonly has no such file yet), otherwise 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 — the project's actual default branch. Check this HERE, before minting the marker or writing anything below. On main/master/the default branch → STOP, mint no marker, write nothing: report that this lane's declaration commit needs a feature branch, and instruct the user to create and switch to one before re-entering Back-fill. This does not replace Pre-flight's own branch check below, which still guards a consumer who reaches it directly with an already-declared file and never runs this lane at all; it only closes the ordering gap for the lane that runs ahead of it.

    Store the resolved default branch as $DEFAULT_BRANCH, then run release_require_commit_path || exit "$?" before the reconciliation pass, marker mint, or any tracked write.

    Reconcile the already-published pre-adoption window before writing either tracked file. Fetch origin/$DEFAULT_BRANCH, then classify the exact published default-branch history with the no-ledger form of the tested helper. For the canonical detected row, run git log "origin/$DEFAULT_BRANCH" --format=%H%x00%s%x00%b%x00 -- . ':(exclude).codearbiter/' | "$PY" "${PLUGIN_ROOT}/hooks/_releaselib.py" classify-window; if the operator confirmed a narrower payload, translate that confirmed payload and every payload-exclude to the same one-argument-per-pathspec shape instead of silently falling back to the canonical root scope. Exit 0 means no published bumping commit needs reconciliation; exit 1 prints every one that does as [NEEDS-TRIAGE]; exit 2 means the published window is non-bumping; any other non-zero exit STOPs. Never classify unpublished branch-only work into this ledger.

    Compose the ledger first in a scratch file outside the working tree, with exactly schema_version integer 1 and an entries list. Exit 0 or 2 above gets an empty list. For exit 1, require one entry for every reported published commit, with exactly the string fields target, commit_sha, changelog, reason, and authorization. Resolve commit_sha to the full lowercase Git object ID (40 hexadecimal characters for SHA-1 or 64 for SHA-256) and prove it is an ancestor of origin/$DEFAULT_BRANCH; a short or ambiguous identity STOPs. The operator must author the single-line changelog text, the reconciliation reason, and the authorization record explicitly — never invent any of them, never infer authorization from silence, and never use the ledger to change a commit's classification, bump, or release scope. Missing even one reported bumping commit leaves the later footer gate red by design.

    Before any tracked write, validate that scratch file through the same strict schema parser the release path uses: "$PY" "${PLUGIN_ROOT}/hooks/_releaselib.py" validate-reconciliations "$TARGET" "$BACKFILL_LEDGER_DRAFT". Exit 0 is only structural validation; it does not claim publication. Any non-zero exit STOPs without weakening or bypassing the ledger. Re-check that neither release-targets.md nor $PROJECT_ROOT/.codearbiter/release-changelog-reconciliations.json appeared since confirmation; never overwrite either existing file.

    Only once all three prerequisites and the reconciliation pass are 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_ROOT/.codearbiter/release-targets.md and copy the validated scratch ledger byte-for-byte to $PROJECT_ROOT/.codearbiter/release-changelog-reconciliations.json, 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, and both files must be committed before Pre-flight; this release invocation must not enter Pre-flight afterward, even though the canonical Back-fill row excludes .codearbiter/ from the release payload. Commit it through commit-gate on the current branch — already confirmed above to be neither main, master, nor the default — with both files staged, as chore: declare release targets and changelog reconciliation (or an equivalent non-bumping type). Land that commit through the project's normal PR path on the default branch, then fetch it so origin/$DEFAULT_BRANCH names the published declaration and ledger. The exclusion keeps hook-created gate-events.log and the declaration commit out of the product release window; it does not authorize leaving either file uncommitted. classify-window "$TARGET" "$DEFAULT_BRANCH" deliberately trusts the ledger only from the fetched published default-branch commit, so using the feature-branch copy early must fail closed rather than invite a bypass.

  4. Re-enter only from a fresh non-default release branch based on that published default branch. Confirm both the declaration and ledger are present in origin/$DEFAULT_BRANCH, create a fresh non-default release branch from that exact commit, and invoke this skill again. The published full SHAs may now supply only their operator-authored changelog text; malformed, missing, unpublished, non-ancestor, or incomplete entries still STOP. This is the first point at which Pre-flight may run for the new target.

  5. A second invocation reads; it does not re-detect — scoped to this checkout, not to the project at large. Once the file exists on disk, load_targets succeeds and this back-fill lane does not run again in this exact checkout, on this branch, for as long as the file remains present here — detection above fires ONLY when the file is genuinely absent, never once a row has been confirmed and persisted in this checkout. release-targets.md is a normal tracked file, not an irreversible project-wide fact: a branch created, or checked out, before the declaration commit merged or was fetched into it still finds no file on disk and correctly re-enters this lane, and so does any other clone or worktree that has not yet fetched or merged that commit.

Pre-flight

A project may declare more than one independently-versioned release target in $PROJECT_ROOT/.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_ROOT/.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 purpose is letting a release-only consumer skip full onboarding for the declared-target file specifically — not a guarantee that no other .codearbiter/ state is ever required; commit-gate's own tech-stack.md prerequisite (see the check below) is still enforced — 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.md present 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 from CONTEXT.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 — "no CONTEXT.md" and "CONTEXT.md exists 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 reads CONTEXT.md 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 is a Pre-flight ENTRY condition, not an invariant held throughout the phase: the $ARTIFACTS freshness step below deliberately runs a declared rebuild command that can dirty the tree, and its own remedy — commit the rebuild through commit-gate — restores a clean tree before Phase 2 tags anything. The two rules are sequenced, not in conflict: START clean, MAY dirty via rebuild, MUST be clean again before a tag is written.

    This governance layer's own scratch state is exempt, and only that (HIGH, blind exercise runs 15 and 17). Two paths under $PROJECT_ROOT/.codearbiter/ are written by the layer itself during the very run being checked, and neither is ever part of a release:

    • $PROJECT_ROOT/.codearbiter/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.
    • $PROJECT_ROOT/.codearbiter/.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.
    release_require_clean_tree || exit "$?"
    

    The helper resolves the selected row and applies each scratch exclusion only when that path is disjoint from every release surface declared by the row. A target whose payload is ., or whose manifests, changelog, generated manifests, provenance manifest, artifacts, or release assets overlap either scratch path, receives no hiding exclusion for that path. This keeps governance-owned noise out of unrelated targets without making an operator-declared release surface invisible to the gate.

    The helper's internal :/ and ,top pathspecs are load-bearing, not decoration (HIGH-1, blind exercise run 17). A bare . scopes the check to the CURRENT directory and a cwd-relative exclusion can hide real dirt outside that subtree. The helper anchors inclusion and any safe exclusion to the repository root, 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 declared release surface stays in scope.

  • Reuse the one $PROJECT_ROOT resolved before the first helper invocation. Pin every Git command to it and thread it explicitly into helpers that accept a root; never re-read a host-specific variable later in the workflow.

  • The earlier cd "$PROJECT_ROOT" binds helper defaults and every relative release surface to the same checkout. Keep explicit git -C "$PROJECT_ROOT" on Git reads and writes as a second, visible binding.

  • The current branch MUST NOT be main, master, or the default branch. Resolve it pinned to the same root: git -C "$PROJECT_ROOT" branch --show-current. Release lands through the normal branch/PR path; if HEAD is the default branch, STOP.

  • Verify commit-gate can actually take this lane's eventual release commit before any write below (#570 R4-08/R8-05/R8-06/R10-08, P6). Phase 1 step 7 routes that commit through commit-gate, whose own Pre-flight requires $PROJECT_ROOT/.codearbiter/tech-stack.md to exist ("Stop if missing; do not guess.") and a resolvable git repository ("A git repository must be present and git status available."). The branch bullet above already satisfies commit-gate Phase 2's branch restriction for this lane, ahead of any write. Check [ -f "$PROJECT_ROOT/.codearbiter/tech-stack.md" ] and that git -C "$PROJECT_ROOT" status succeeds HERE, before the $ARTIFACTS rebuild below or any manifest/changelog write in Phase 1. Missing → STOP before touching anything: report that commit-gate requires a declared tech-stack.md (the test/lint/secrets-scan commands it reads) that this project does not have, and that context-creation is the way to declare one; re-enter Pre-flight once it exists. This is not a new onboarding-free release path (P6) — it only moves the discovery of an unmet commit-gate prerequisite ahead of every write this lane would otherwise perform, instead of behind an already-rolled changelog and a bumped manifest. It does not bypass or replace commit-gate — Phase 1 step 7 still routes the release commit through it, in full, exactly as before.

  • Run release_require_commit_path || exit "$?" now, before any rebuild or release edit. The full commit-gate still owns committing the result.

  • Confirm every executable declaration before the first one can run on a real release. Before the $ARTIFACTS rebuild below, "$PY" "${PLUGIN_ROOT}/hooks/releasehash.py" check $TARGET must exit 0. Its digest binds the ordered pre-tag list plus release-build, rebuild, and generate; read every one of those values before using releasehash.py record to resolve exit 1 or 2. Exit 64 or 65 is a configuration failure and STOPs. Under --dry-run, list these commands but do not record a confirmation: none executes there, and a preview must not mint durable approval. Phase 1 step 6c repeats this check after the release edits as a last-moment drift guard; the repeat does not replace this pre-execution gate.

  • Fetch tags before resolving LAST_TAG on a real run (LOW, #585): git -C "$PROJECT_ROOT" 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 enters LAST_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. Under --dry-run, do not run either git fetch command in this bullet or the next one: fetch mutates repository metadata even when the tracked tree stays clean. Derive the preview from the current local refs and label that limitation explicitly; a dry-run result never supplies remote-freshness evidence for a real release.

  • Fetch the default branch before using published-history evidence on a real run: git -C "$PROJECT_ROOT" fetch origin "$DEFAULT_BRANCH". A failed fetch STOPs when the row declares $CHANGELOG_RECONCILIATIONS; a stale local remote-tracking ref is not sufficient proof that a malformed-footer commit is already published. A row with no reconciliation ledger may retain the ordinary stale-baseline acknowledgment above, but that acknowledgment can never authorize a reconciliation. Under --dry-run, use only the current local origin/$DEFAULT_BRANCH ref, report that its freshness was not established, and never treat the preview as reconciliation authority.

  • Resolve LAST_TAG from $TARGET's series and declared version policy only — never bare git describe --tags --abbrev=0, which can select a sibling series. Resolve it through the tested helper: LAST_TAG=$(git -C "$PROJECT_ROOT" tag -l | "$PY" "${PLUGIN_ROOT}/hooks/_releaselib.py" last-tag-for-policy "$TAG_PREFIX" "$VERSION_POLICY" "$INITIAL_VERSION"). The helper keeps the anchored prefix boundary, applies strict SemVer when the row omitted version-policy, and for numeric-sequence accepts only canonical same-shape dotted numeric tags at or above the declared initial version. Invalid declarations STOP rather than falling back. No matching tag prints <none> and makes the full history the window; a first release is normal.

    Verify LAST_TAG is actually reachable before using it for anything (HIGH, #570 finding BODY-03): last-tag-for-policy selects the series' highest tag by VALUE alone — it has no git access and cannot know whether that tag's commit is even in this branch's own history. A tag pushed once from a sibling branch that diverged before today's HEAD, an abandoned branch's stray push, or an unrelated orphan history that happens to share this series' prefix can still win the numeric-max scan even though HEAD never descends from it, silently anchoring $BASE_VERSION — and every version, changelog window, and tag derived from it — to history this release does not actually contain. Verify it immediately, before $BASE_VERSION or anything else reads $LAST_TAG: "$PY" "${PLUGIN_ROOT}/hooks/_releaselib.py" verify-tag-ancestor "$LAST_TAG" "$PROJECT_ROOT". The explicit second argument is $PROJECT_ROOT — the SAME value git -C "$PROJECT_ROOT" tag -l above was just pinned to, passed VERBATIM rather than re-derived by a second, independent environment read: this closes the split even when nothing sets an environment variable for this helper to fall back to. Exit 0 means $LAST_TAG is <none> (the zero-tag first-release path below — completely unaffected, a distinct code path from tag selection) or a confirmed ancestor of HEAD, including one reached only through a merge commit and both annotated and lightweight tag forms — proceed exactly as before. Exit 1 means $LAST_TAG resolves but is NOT an ancestor of HEAD: STOP, report the tag and that it is unreachable from HEAD. Never silently fall back to an older tag, never reuse the tag namespace, and never move or delete the tag — the operator removes the stray tag by hand (never retarget or delete a PUBLISHED one — see "Recovering from a bad release" below) or confirms the correct baseline before re-entering Pre-flight. This detects the ambiguity; it does NOT choose a lower baseline automatically and does NOT implement any maintenance-branch allocation policy for which of several diverged branches "should" own the next version — that decision stays with the operator. Exit 2 means $LAST_TAG or HEAD could not be resolved at all: STOP and report the cause; this is a repository/configuration problem, not a baseline decision to make silently.

    $BASE_VERSION — one base, computed the same way in every case under $VERSION_POLICY. It is the maximum of the bare LAST_TAG version (or 0.0.0 for a first SemVer release, and $INITIAL_VERSION for a first numeric-sequence release) and the highest version any declared manifest currently carries. Compare candidates only with "$PY" "${PLUGIN_ROOT}/hooks/_releaselib.py" version-greater <candidate> <floor> "$VERSION_POLICY" "$INITIAL_VERSION"; an invalid, regressing, or shape-changing floor STOPs. Read every manifest HERE, before anything is bumped, because a row may declare several and only their policy-valid maximum is safe.

    When a matching tag exists and the manifest is AHEAD of LAST_TAG, STOP (HIGH, blind exercise run 14). If the winning maximum came from a manifest rather than from an existing 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.

    A zero-tag target needs an explicit adoption classification, not an inferred publication history (HIGH, blind exercise run 30). A manifest above the policy's first-release identity proves only that a version was written; it does not prove that version was published. When LAST_TAG=<none> and a manifest is strictly above the declared first identity, present and record one choice in the release report and release PR before classification: never published means retain the manifest maximum as $BASE_VERSION, set DERIVATION_BASE=$BASE_VERSION and COMPARISON_BASE=$BASE_VERSION, confirm the adoption boundary in Phase 1 step 0, and derive the first tag strictly above that base; previously published without tags preserves this STOP and requires the maintainer to reconcile the published history. Never select either branch silently. A zero-tag manifest exactly equal to $INITIAL_VERSION is different: it is the candidate first identity, not evidence of an earlier publication. For that numeric-sequence case set DERIVATION_BASE="<none>" and COMPARISON_BASE="<none>", so the helper returns and validates $INITIAL_VERSION itself instead of incrementing it. For every other case set both bases to $BASE_VERSION. This makes the first numeric-sequence release 0.84.1, not 0.84.2, while a known untagged publication remains blocked.

    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. Fixed, not merely documented (previously a MEDIUM residual, adversarial review 2026-07-31; closed by #570 finding BODY-03): this replacement already fixes ancestry-based git describe's failure mode in one direction (a sibling series' tag can no longer leak in) and had 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, which would otherwise raise the baseline for every subsequent release even though no released history actually contains it. last_tag_select still has no way to detect that case by itself — it stays a pure, git-free function, per this module's design invariants — but it is no longer the only guard: the verify-tag-ancestor step introduced above now confirms reachability separately and STOPs on exactly this shape, so a project that hits it is refused with an explicit diagnostic rather than silently anchored to a baseline its own history never contains. Removing the stray tag by hand (never simply retarget or delete a PUBLISHED one — see "Recovering from a bad release" below) remains the operator's remedy once refused.

  • Scope every release-window read to the declared payload pathspec set: reload $PAYLOAD_PATHS_FILE into positional parameters as specified under Targets and pass "$@" after --, never a whole-repository log. A feat(some-other-target) commit must not bump $TARGET or land in its changelog, and vice versa. Phase 1 checks non-emptiness only after it establishes $EFFECTIVE_WINDOW; checking raw first-release history here would run before the adoption floor and could accept only pre-adoption work.

  • 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: not under --dry-run — this step EXECUTES the row's declared rebuild command, which routinely overwrites the very build artifact it exists to check as its normal, intended side effect (a bundled tool rebuilt from source lands back on its own committed output path); a dry run's entire premise is that nothing on disk changes. See "Dry run" below, which lists $REBUILD/$ARTIFACTS/$GENERATE by name instead of running them, for the identical reason it does not run $PRE_TAG. Otherwise, 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" ) || { echo "STOP — the rebuild itself failed; fix the build before trusting any freshness assertion" >&2; exit 1; } — and only THEN assert every path in $ARTIFACTS is 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 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

Configuration exits 64 and 65 are never confirmation states; the read-then-record remedy above does not apply to them.

Derive the bump mechanically from the commit log; do not guess it.

  1. Resolve the one effective window before reading or classifying any commit. Start with EFFECTIVE_WINDOW=$WINDOW. On a first release (LAST_TAG=<none>), floor the window at the adoption commit by resolving the boundary from BOTH candidate files before the first classify-window call: ADOPTED=$(git -C "$PROJECT_ROOT" log --diff-filter=A --format=%H -- .codearbiter/CONTEXT.md .codearbiter/release-targets.md | "$PY" "${PLUGIN_ROOT}/hooks/_releaselib.py" adoption-commit). Empty output leaves EFFECTIVE_WINDOW=HEAD. Otherwise present $ADOPTED as the proposed baseline and require the user's confirmation or explicit replacement, then set ADOPTED to that confirmed SHA; never narrow silently. Include the confirmed boundary commit itself: if git -C "$PROJECT_ROOT" rev-parse --verify "${ADOPTED}^" >/dev/null 2>&1, set EFFECTIVE_WINDOW="${ADOPTED}^..HEAD"; when $ADOPTED is the root commit and has no parent, use EFFECTIVE_WINDOW=HEAD.

    On Back-fill's own canonical first-release path, the declaration changes only the excluded .codearbiter/ state. An empty adoption-bounded payload window is an ORDINARY outcome after fast-forward, squash, OR a normal PR merge commit. The old adoption-boundary-equals-current-HEAD condition is not a requirement: a normal merge retains the declaration as an ancestor distinct from HEAD, and later non-payload commits also change HEAD without adding release content. When the confirmed adoption is a verified ancestor and its narrowed payload window is empty, the documented default is to propose EFFECTIVE_WINDOW=HEAD. It is presented for the operator's confirm-or-replace choice, never applied silently. State the scope change and the published reconciliation requirement before asking; a long history can still produce a 500-line footer-completeness report. A declined proposal STOPs without changing the window. This one confirmed value then governs emptiness, classification, bump derivation, and changelog harvesting.

  2. Reload $PAYLOAD_PATHS_FILE into positional parameters with the sanctioned while IFS= read -r pathspec loop. The underlying payload read is git -C "$PROJECT_ROOT" log "$EFFECTIVE_WINDOW" --format=%H -- "$@"; evaluate its output and exit status through the function below, never by empty stdout alone. Before treating empty output here as a genuine STOP, check step 0's documented default shape through this executable read-only decision. It checks the confirmed adoption's reachability rather than equality with HEAD, propagates failed Git reads, and never changes $EFFECTIVE_WINDOW:

    release_window_state() (
      if [ "$#" -eq 0 ]; then
        printf 'STOP — no declared payload pathspecs were supplied.\n' >&2
        exit 1
      fi
      if [ "$LAST_TAG" = "<none>" ] && [ -n "${ADOPTED:-}" ]; then
        git -C "$PROJECT_ROOT" merge-base --is-ancestor -- "$ADOPTED" HEAD || {
          printf 'STOP — the confirmed adoption boundary is not a verified ancestor of HEAD.\n' >&2
          exit 1
        }
      fi
      window_commits=$(git -C "$PROJECT_ROOT" log "$EFFECTIVE_WINDOW" --format=%H -- "$@") || {
        printf 'STOP — could not read the release window; failed inspection is not empty history.\n' >&2
        exit 1
      }
      if [ -n "$window_commits" ]; then
        printf 'ready\n'
      elif [ "$LAST_TAG" = "<none>" ] && [ -n "${ADOPTED:-}" ] && [ "$EFFECTIVE_WINDOW" != "HEAD" ]; then
        printf 'confirm-first-release-window\n'
      else
        printf 'empty\n'
      fi
    )
    WINDOW_STATE=$(release_window_state "$@") || exit "$?"
    

    ready proceeds with the existing window. confirm-first-release-window is a STOP for the explicit confirm-or-replace choice in step 0, not permission to write, classify, or widen. Only after that choice is actually supplied, set $EFFECTIVE_WINDOW to it and rerun the same decision with the same payload arguments. Require ready after that one recheck; do not loop through additional widening proposals. empty, a declined proposal, or a non-ready confirmed recheck STOPs as nothing to release for $TARGET. Any nonzero exit STOPs as failed inspection, never as empty history. Tagged-release windows never take the first-release widening route. Then read every commit in that same payload-scoped window: git -C "$PROJECT_ROOT" log "$EFFECTIVE_WINDOW" --format=%H%x00%s%x00%b%x00 -- "$@".

  3. Classify the effective window through the tested helper, not by hand: reload the pathspec positional parameters, then run git -C "$PROJECT_ROOT" log "$EFFECTIVE_WINDOW" --format=%H%x00%s%x00%b%x00 -- "$@" | "$PY" "${PLUGIN_ROOT}/hooks/_releaselib.py" classify-window "$TARGET" "$DEFAULT_BRANCH". NUL framing is load-bearing: Git commit messages cannot contain NUL, while any printable delimiter can appear in a body and hide a breaking footer. It prints the derived bump on the first line, then one [CLASSIFIED] JSON object per commit carrying the authoritative SHA, subject, type, scope, breaking flag, bump, exact CHANGELOG: text, and reconciliation flag. It additionally prints one [RECONCILED] <short-sha> <subject> :: <changelog> line per accepted published reconciliation and one [NEEDS-TRIAGE] <short-sha> <subject> line per remaining bumping commit missing a unique non-empty terminal CHANGELOG: footer. Preserve the [CLASSIFIED] rows as the only input to step 5's grouping and report; never reparse the commit bodies. Exit 0 clean; exit 1 at least one footer remains missing (step 3's BLOCK); exit 2 the whole window is non-bumping (the STOP below); exit 4 the input framing, declared reconciliation ledger, or Git ancestry proof failed closed. It classifies and reports; the decision stays here, in this skill.

    The optional ledger is used only when $TARGET explicitly declares $CHANGELOG_RECONCILIATIONS. The helper constructs and resolves the exact refs/remotes/origin/$DEFAULT_BRANCH identity itself, reads the declared ledger only as an exact regular-file blob from that fetched commit, validates the whole JSON document, selects only entries naming this target, and requires an exact lowercase full Git object ID (40 hexadecimal characters for SHA-1 or 64 for SHA-256) plus single-line note/reason/authorization fields. It accepts a matching row only when trusted Git proves that exact commit is an ancestor of the same fetched default-branch commit. A live-only, branch-only, ignored, untracked, malformed, missing, escaping, symlinked, duplicate, wrong-target, short-SHA, mismatched, or unpublished entry never clears the footer gate. The ledger supplies only the deliberately reviewed changelog text for the identified historical commit; the commit's type still determines the bump and changelog group.

    Hand-rolling this parse is how it goes wrong, and not hypothetically (HIGH-adjacent, adversarial review 2026-07-31, run 11): an exercising agent wrote subject.split('(')[0].split(':')[0].rstrip('!'), which strips the breaking marker before anything checks for it — so feat!: classified as a minor, feat(api)!: the same, and chore!: as no release at all. A breaking change ships as a minor, or does not ship. Two operators writing two parses produce two different gates on the check that decides whether a release may proceed, which is not a gate.

    The rules it implements, for reference — the helper is authoritative, this list is the explanation:

    • BREAKING CHANGE: footer or ! after the type/scope → major.
    • else any feat → minor.
    • else any fix, perf, refactor → patch.
    • test / docs / chore / ci only → no bump. If the whole window is non-bumping, STOP — there is nothing to release.

    For the footer-completeness rule below, an accepted [RECONCILED] row counts as footer-complete. That is not an auto-fill or a relaxation for ordinary missing footers: it is an explicit repository declaration bound to one exact commit already on the freshly fetched default branch, and every unlisted or unprovable commit remains [NEEDS-TRIAGE]. An unpublished commit must be amended or rebased instead and MUST NOT be added to the ledger. A reconciliation for a published commit lands through a normal reviewed commit, after which this lane restarts so its clean-tree and fresh-origin gates cover the policy change. When step 5 composes the changelog, it uses the exact helper-printed reconciliation text in the group selected by the commit's unchanged type; it does not reword or re-derive the note.

  4. Verify footer completeness for the already-established $EFFECTIVE_WINDOW before touching anything else — never after. On a first release, step 0 has already proposed and confirmed the adoption floor before classification; do not classify the raw full-history $WINDOW first and then try to narrow it here. The sole sanctioned exception is Back-fill's own canonical first-release shape (step 0's note, step 1's check): there, $EFFECTIVE_WINDOW may still be substituted to HEAD at step 1, but only through that step's OWN presented confirm-or-replace gate, before this step ever runs — a confirmed widen at the documented point of use, not an unconfirmed narrowing performed HERE. adoption-commit takes the oldest line of its newest-first input, so considering both candidate files identifies whichever adoption happened first. The confirmed boundary commit itself remains included, including when it is the root commit. Over that effective 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. This obligation is wider than those four types, regardless of type: any commit classify_commit marks breaking — an unfootered chore!:, docs!:, test!:, or ci!: included — is caught here too, because classify_window's missing_footer set keys on each row's own bump != "none", not a fixed type list, so a footer-incomplete breaking commit of any type is already BLOCKed at this step, before step 5 ever composes a changelog. 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. The remedy is the operator's to choose: first check whether the commit is already published with git -C "$PROJECT_ROOT" merge-base --is-ancestor <sha> "origin/$DEFAULT_BRANCH". An unpublished commit may be amended or rebased; a published one must not be rewritten or force-pushed. If a listed commit genuinely has nothing user-facing to say, reclassify its type instead of leaving it half-classified. Never invent footer text on the author's behalf. Checking before step 4 bumps any file means a blocked release leaves no partial manifest bump. A CHANGELOG: footer on a test/docs/chore/ci commit is harvested by step 5 rather than silently discarded, but does not by itself create a bump.

  5. Apply the step-2 classification to $BASE_VERSION through the policy-aware helper, never by eye. Capture step 2's first line verbatim as $BUMP. For LAST_TAG=<none> with the numeric-sequence manifest exactly at $INITIAL_VERSION, run DERIVATION_BASE="<none>"; COMPARISON_BASE="<none>"; otherwise run DERIVATION_BASE=$BASE_VERSION; COMPARISON_BASE=$BASE_VERSION. Then run VERSION=$("$PY" "${PLUGIN_ROOT}/hooks/_releaselib.py" derive-version "$DERIVATION_BASE" "$BUMP" "$VERSION_POLICY" "$INITIAL_VERSION") and set RELEASE_TAG=${TAG_PREFIX}${VERSION} once for every later tag, notes, asset, and publication command. This preserves SemVer's patch/minor/major arithmetic while numeric-sequence advances its final component exactly once for any bumping classification; none, an unknown policy, or an invalid base STOPs. Confirm the result mechanically with "$PY" "${PLUGIN_ROOT}/hooks/_releaselib.py" version-greater "$VERSION" "$COMPARISON_BASE" "$VERSION_POLICY" "$INITIAL_VERSION". The helper treats <none> as the empty numeric sequence only for this first-release comparison. Exit 1 means equal or lower; exit 2 means policy-invalid input. For compatibility, this is the policy-general replacement for the legacy semver-greater <derived> $BASE_VERSION check and keeps its remedy: re-derive from $BASE_VERSION rather than raising the bump. $BASE_VERSION already accounts for the tag and every manifest, including the first-release sentinel case. Bringing every manifest to the derived version still happens once in step 6; describing that write here too would be one action described two ways. Present the version and per-commit classification to the user for confirmation.

  6. Derive the release date once — RELEASE_DATE=$(date +%F) — and reuse that single value for the changelog header, the Phase-2 Released-at: footer, and the Phase-3 Release; never hand-type the date a second time (release_dates_consistent verifies the changelog-header date equals the Released-at: date). Roll the CHANGELOG: footers from each feat / fix / perf / refactor commit into a new ## [${VERSION}] — $RELEASE_DATE section in $CHANGELOG (the Keep-a-Changelog bracket heading this guard matches, not the bare v-prefixed form), grouped Added (feat) / Fixed (fix) / Performance (perf) / Changed (refactor, matching this repository's own changelog convention for non-user-facing-but-notable work). A commit whose classification marks breaking true — the ! bang after the type/scope or a BREAKING CHANGE:/BREAKING-CHANGE: footer, exactly the flag classify-window already computes and never re-derived here — rolls into its own ### Breaking group instead of its type group, placed first among the section's groups. A feat! no longer lands under ### Added indistinguishable from an ordinary feature, and a breaking chore!: (which has no type group of its own) still gets one; an ordinary nonbreaking commit is never placed in ### Breaking, and a commit already in ### Breaking is never also duplicated into its type group. This is the same composed section text Phase 2's <message-file> and Phase 3's <Phase-1 section file> both read back (below), so ### Breaking is visible in the changelog, the tag message, and the Release notes without a second composition pass. 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 includes/anti-slop-design/core.md §3.A (no prose-separator em-dashes in the entry prose) and §3.B (copy self-audit), and includes/anti-slop-design/medium-documents.md §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. This scratch file is not required to survive across a session boundary (HIGH, blind exercise run 19): resume_publish below explicitly permits Phase 3 to run in a LATER invocation than the one that composed this section — by design, since a fresh publish and a resumed one share the same Phase-3 authorization gate — and by then a mktemp file from a prior session is normally already gone. Phase 3 step 2 does not assume it survived; it reconstructs the same text mechanically from $CHANGELOG instead.

  7. 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. Re-check every declared executable release input. "$PY" "${PLUGIN_ROOT}/hooks/releasehash.py" check $TARGET must exit 0. The confirmation digest binds the ordered pre-tag list plus the optional release-build, rebuild, and generate; changing any one invalidates the same per-target confirmation. Pre-flight performed the first check before rebuild could execute, and this repeat catches declaration drift before generate, pre-tag, or a retained cohort is trusted. These values are operator-authored shell; a retained-cohort sentinel is protected operator-authored policy this lane must verify and report without executing. Exit 1 means one changed since the last confirmation and exit 2 means the row's executable inputs have never been confirmed; resolve either only after reading every pre-tag, release-build, rebuild, and generate value, then run "$PY" "${PLUGIN_ROOT}/hooks/releasehash.py" record $TARGET. Exit 64 (malformed CLI usage) and exit 65 (an unrecognised $TARGET) are configuration failures, not confirmation states — fix the invocation or declared target and re-run check; neither writes a marker. Recording without reading converts the gate into a rubber stamp. A row declaring none of the four executable inputs reports no-commands and exits 0, deliberately distinct from confirmed; a row declaring any one alone still requires confirmation.

    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. Each declared command runs with PY set 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, 8 the tree-state PROBE itself failed, and 9 no POSIX-compatible shell could be resolved to dispatch a declared row on Windows, so NO command ran at all (#602 — distinct from 7, which names a specific command's own interpreter as unresolvable). 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 and re-run; when the command is Python, spell the declaration with the already-resolved "$PY" on every supported host instead of hardcoding python or python3. 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. Pre-flight above already confirmed commit-gate's prerequisites and branch restriction are met before this phase's writes began; this step does not re-check them. Then assert the tree is clean before Phase 2 with release_require_clean_tree || exit "$?"; the underlying probe must print nothing AND exit 0. A nonzero probe exits this invocation before Phase 2. The target-aware helper exempts governance scratch only when the selected row does not declare it as a release surface. This step is reached only AFTER steps 5 and 6a have written the changelog and every declared manifest, and each of those writes can fire the hook that appends to the audit log. commit-gate stages by explicit path and leaves unrelated governance scratch out of the release commit, while a target that actually ships or asserts against that scratch still sees it and BLOCKs.

  1. Resolve declared publication assets without pretending local Phase 1 owns hosted package evidence. This runs only after the release edits are committed and every existing check above has passed. Set RELEASE_TAG=${TAG_PREFIX}${VERSION} and create RELEASE_ASSET_TEMPLATES_FILE=$(mktemp) and RELEASE_ASSET_NAMES_FILE=$(mktemp) outside the working tree. Materialize the templates with list-field "$TARGET" release-assets, then load them as exact positional arguments: set --; while IFS= read -r asset; do set -- "$@" "$asset"; done < "$RELEASE_ASSET_TEMPLATES_FILE". Run "$PY" "${PLUGIN_ROOT}/hooks/_releaselib.py" render-release-assets "$VERSION" "$RELEASE_TAG" "$@" > "$RELEASE_ASSET_NAMES_FILE", and STOP unless it succeeds. Report the exact names and the declared $RELEASE_BUILD, but do not execute that command in the interactive checkout: the mandatory PR boundary means local output cannot become publication input, and a repository may deliberately reserve package assembly for its protected exact-head CI cohort. The qualifying hosted publisher MUST declare and enforce one reviewed asset path before the release PR merges: either it executes $RELEASE_BUILD in its exact candidate checkout and verifies the resulting inventory, or it downloads and cryptographically/read-back verifies a retained exact-head CI package cohort bound to the same source commit and declared filenames. An undeclared choice, an arbitrary ignored build failure, or a retained artifact not bound to this candidate STOPs. If the row declares no asset contract, skip this step explicitly.

    Use this exact argument-preserving function for that final verification and reuse it for Phase 3's pre-push recheck:

    verify_declared_assets() {
      set --
      while IFS= read -r asset; do set -- "$@" "$asset"; done < "$RELEASE_ASSET_NAMES_FILE"
      "$PY" "${PLUGIN_ROOT}/hooks/_releaselib.py" verify-release-assets "$RELEASE_ASSET_DIR" "$@"
    }
    verify_declared_assets > "$RELEASE_ASSET_PATHS_FILE"
    

    An empty, unreadable, or malformed names file cannot pass: the helper rejects a zero-name request and all unsafe or duplicate names before inspecting the directory.

    Cross-boundary correction (HIGH, blind exercise runs 26 and 28): Phase 1 never supplies a Phase-3 asset. The mandatory PR stop and fresh hosted checkout make local mktemp paths non-transferable, while this repository's qualified packages carry CI provenance and cold-execution receipts that a local rebuild cannot recreate. Remove the names scratch file after the Phase-1 report. The authorized hosted publisher takes the already-reviewed path named above and verifies the same declared inventory from exact merged HEAD before any tag push.

Gate: version confirmed, strictly monotonic within $TARGET's declared policy and 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 target-aware probe; and every declared asset name plus its reviewed hosted qualification path is reported. BLOCK if the classification disagrees with the log, the window is non-bumping, a bumping commit's CHANGELOG: footer is missing, any command this phase actually executes fails or mutates the tracked tree, the asset qualification path is absent or ambiguous, 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.

The release commit must merge through a pull request before any tag is composed. After the Phase-1 commit and checks are complete, route the branch through the ordinary PR path and STOP this invocation. Never continue from the release branch merely because its local commit gate passed. The post-merge publication lane must bind the tag to the fetched default-branch commit, not to the PR head or a pre-merge release commit.

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.

Dry run (--dry-run)

The declared $RELEASE_BUILD and $RELEASE_ASSETS are listed in the report but the release build is never executed and no asset directory is created. Like rebuild, generate, and pre-tag, it is protected operator-authored executable input whose normal purpose is to write output, so executing it would violate the dry-run contract.

--dry-run runs the read-only, locally evaluable portion of Pre-flight. It checks the declared row, tree, branch, commit-path prerequisites, current local tag/default-branch refs, manifest floor, and window, but it does not execute commit-gate or any declared command; do not run either git fetch command. Therefore it does not claim a suite verdict, remote-ref freshness, artifact freshness, or post-bump pre-tag verdict. Those remain real-run gates and are listed as unexecuted limitations in the report. The $ARTIFACTS freshness bullet's own text now carves itself out under --dry-run, for the same reason $PRE_TAG is listed rather than run below — both are declared commands this lane EXECUTES, and a dry run's contract ("nothing is written") cannot coexist with executing a command whose job is to overwrite a file. Then runs Phase 1 steps 1 through 4 only: read the window, classify it through classify-window, verify CHANGELOG: footer completeness, and derive the identity through derive-version + version-greater under the declared policy. Stop there, before step 5. Steps 5 (compose and write the changelog section), 6 (bump every declared manifest, assert, confirm and run pre-tag), and 7 (commit the release edits) do not run — nor does anything in Phase 2 or Phase 3. Nothing is written: no scratch file survives the run, no path in $MANIFEST or $CHANGELOG changes, no commit, no tag.

Declared pre-tag commands are listed, never executed. Step 6d's checks assert a surface against the version AFTER $MANIFEST has been bumped — 6d's own text: "a badge or catalog check compares a surface against the NEW version and would pass vacuously against the old one." A dry run never bumps anything, so running them here would report the SAME false failure 6d's ordering already exists to prevent, not a preview of anything a real run would actually see. Print the exact ordered records from list-field "$TARGET" pre-tag as a name-only list instead; never use the comma-flattened show-row compatibility value for this report.

Declared rebuild/generate commands are printed by name, not run (HIGH, blind exercise run 19). Pre-flight's $ARTIFACTS freshness bullet above EXECUTES the row's declared rebuild command unconditionally, precisely so a stale committed bundle is caught before a real release — but that execution routinely overwrites the artifact it is checking, as its ordinary, intended behavior — verified during the exercise: a rebuild that regenerates its declared artifact in place turned a clean tree dirty with no --dry-run carve-out to stop it. A generate command (declared on a $GENERATED_MANIFEST path) is the identical shape: a write disguised as a read-only check. Under --dry-run neither runs. Print $REBUILD, $ARTIFACTS, and $GENERATE (from show-row $TARGET --field rebuild/--field artifacts/--field generate) as name-only lists instead, exactly as $PRE_TAG is printed above — a dry run reports what WOULD be checked, never checks it by running the command that would mutate the tree to do so.

Report: $TARGET, the resolved row (every declared field show-row prints, verbatim — this doubles as a way to validate a freshly authored release-targets.md without tagging anything), the derived version and step 2's bump rationale, the per-commit classification, step 3's footer-completeness result, and the declared pre-tag list from the paragraph above. State plainly that this was a dry run, nothing was written, the preview used current local refs without fetching, and suite/artifact/ post-bump checks were not executed.

Interaction with Back-fill: when $PROJECT_ROOT/.codearbiter/release-targets.md is genuinely absent, --dry-run still runs the Back-fill lane's Detect and Present steps (1–2) — they already print a proposed block and write nothing on their own. It STOPs there: never seek the explicit confirmation step 3 requires, and never persist. Report the proposed block instead, exactly as Detect printed it, labeled as a dry-run preview.

Gate: every read-only Pre-flight STOP condition that can be evaluated from current local state still applies; the two fetches, commit-gate, and Pre-flight's $ARTIFACTS freshness EXECUTION (the rebuild/generate commands, as opposed to merely listing them) does not run; steps 5 through 8 of Phase 1 and all of Phase 2 and Phase 3 are skipped, not merely unauthorized; the report above is delivered. MUST NOT write, commit, or tag anything under --dry-run — this explicitly includes not executing either git fetch, commit-gate, or a declared rebuild or generate command, not only $MANIFEST/$CHANGELOG/the tag.

Phase 2 — Tag & report · gate: BLOCK

Phase 2 is a contract for the qualifying hosted publisher, not a local fallback. Re-enter only after the release PR has merged, from a clean checkout whose HEAD is byte-for-byte the fetched origin/$DEFAULT_BRANCH revision. Publication requires a hosted workflow that verifies green exact-head evidence for that same commit before its write-token job can run. A project with no qualifying hosted publisher STOPs before tag composition; it must add and review that capability in a separate PR rather than weakening this release. The interactive agent may authorize, dispatch, or observe that publisher, but MUST NOT execute the tag-composition commands below locally.

All version-sensitive helper calls use the declared policy. In particular, invoke notes-match "$RELEASE_TAG" <stored-file> "$VERSION_POLICY" "$INITIAL_VERSION"; any shorter notes-match spelling below documents the retained SemVer-compatible CLI arity, not the command for this resolved row.

Phase 2's remaining work is twelve named substeps, in this fixed order (#623/T-17: the former 9,324-character single item is replaced, not duplicated alongside these). Each substep's number is a stable name for T-18's per-substep structural tests and the campaign invariant map — do not renumber them independently of that map.

2.0 Bind the hosted candidate to the release-surface commit

The changelog last-touch check below is preliminary, not sufficient by itself: a later commit can touch only the changelog preamble while retaining an older release section. Step 2.1 therefore proves that the selected version section itself is absent from, or byte-different in, $HOSTED_HEAD^1 before this cohort may continue.

Not merely to whatever default-branch HEAD exists when the job starts (HIGH, blind exercise runs 26 and 27). Set HOSTED_HEAD=$(git rev-parse HEAD). Require git log --first-parent -1 --format=%H -- "$CHANGELOG" to equal $HOSTED_HEAD. --first-parent is load-bearing: an ordinary no-fast-forward release PR merge owns its tree result even though Git's default path-history simplification reports the side-branch commit that last touched the file. Every declared manifest and companion-manifest path must exist in $HOSTED_HEAD; resolve each exact candidate blob with git rev-parse "$HOSTED_HEAD:$SURFACE" and require git hash-object "$SURFACE" to equal it before reading the version. Do not require an unchanged manifest's last-touch commit to equal $HOSTED_HEAD: a correction cohort can legitimately authorize publication of an already-bumped, still-unpublished version without rewriting identical JSON. The current changelog advance plus exact manifest version and blob identities form the candidate receipt. A later default-branch commit therefore cannot reuse the older changelog authorization, and altered or substituted manifest bytes fail closed; either case STOPs and restarts Phase 1. This binding is in addition to, not a substitute for, green exact-head CI and HEAD == origin/$DEFAULT_BRANCH.

After binding those exact blobs, reassert their semantic version as hosted evidence: "$PY" "${PLUGIN_ROOT}/hooks/_releaselib.py" check-manifests "$TARGET" "$VERSION" must exit 0 before any publish-state classification. Exit 1 means at least one declared manifest disagrees and exit 2 means at least one cannot be parsed; both STOP. Phase 1's result is not reusable here because merge resolution or a later candidate update can change a secondary manifest while preserving the first one, and classify deliberately short-circuits to publish_fresh when no tag exists.

2.1 Reconstruct Phase 1's section in the hosted checkout

Before composing the tag (HIGH, blind exercise run 25). The mandatory PR boundary means Phase 1's external mktemp file and shell variables are not inputs Phase 2 can possess. Create a fresh PHASE2_SECTION_FILE=$(mktemp) and run "$PY" "${PLUGIN_ROOT}/hooks/_releaselib.py" changelog-section "$PROJECT_ROOT" "$HOSTED_HEAD" "$CHANGELOG" "$VERSION" "$VERSION_POLICY" "$INITIAL_VERSION" > "$PHASE2_SECTION_FILE"; then require notes-match "$RELEASE_TAG" "$PHASE2_SECTION_FILE" "$VERSION_POLICY" "$INITIAL_VERSION". $HOSTED_HEAD is the already-bound full candidate commit object, never a mutable revision expression. Derive RELEASE_DATE mechanically from that validated section's first heading with sed -n '1s/.* \([0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]\)$/\1/p', STOP if it is empty, compose the tag message from this exact section plus Released-at: $RELEASE_DATE, and require dates-match "$PHASE2_SECTION_FILE" <message-file> "$VERSION_POLICY" "$INITIAL_VERSION". This is reconstruction from the exact merged commit, not re-derivation from memory; <Phase-1 section file> below means this PHASE2_SECTION_FILE, never the vanished Phase-1 scratch path.

After validating $PHASE2_SECTION_FILE, prove that this release section was introduced or changed by the hosted cohort. Resolve HOSTED_PARENT=$(git rev-parse "$HOSTED_HEAD^1" 2>/dev/null) || { printf 'STOP — hosted parent unavailable.\n' >&2; exit 1; }. Phase 2 follows a merged release PR, so a root commit or shallow checkout cannot qualify. Create PARENT_SECTION_FILE=$(mktemp) and run PARENT_SECTION_STATUS=0; "$PY" "${PLUGIN_ROOT}/hooks/_releaselib.py" changelog-section "$PROJECT_ROOT" "$HOSTED_PARENT" "$CHANGELOG" "$VERSION" "$VERSION_POLICY" "$INITIAL_VERSION" --allow-absent-path > "$PARENT_SECTION_FILE" || PARENT_SECTION_STATUS=$?. Status 1 means the parent has no changelog path or no section for this version. Status 0 requires cmp -s "$PARENT_SECTION_FILE" "$PHASE2_SECTION_FILE" to exit 1; an equal section or an unreadable comparison STOPs. Any other section status also STOPs. Remove $PARENT_SECTION_FILE after the comparison. The section delta, exact manifest blobs, and exact-head CI jointly bind this cohort.

2.2 Resolve <tag_exists> and <tag_sha> from the guarded ref snapshot

Zero-tag execution order: use only the guarded ref snapshot in the correction paragraph below. Any pipe-form show-ref/peel-tag assignment is rejected and MUST NOT be executed.

Resolve <tag_exists> and <tag_sha> from the LOCAL ref this way, and never from a raw tag ref lookup (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. Use the guarded ref-snapshot sequence in the next paragraph; it is the only executable form. 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>.

Zero-tag correction (HIGH, blind exercise run 24): a pipe-form show-ref/peel-tag assignment MUST NOT be executed. Under the hosted Bash contract (set -euo pipefail), a repository with no tags makes show-ref exit 1 and aborts the assignment before peel-tag can accept empty input. Snapshot local refs first with an explicit status guard: TAG_REFS_FILE=$(mktemp); SHOW_REF_STATUS=0; git show-ref --tags -d > "$TAG_REFS_FILE" || SHOW_REF_STATUS=$?; if [ "$SHOW_REF_STATUS" -ne 0 ] && [ "$SHOW_REF_STATUS" -ne 1 ]; then rm -f -- "$TAG_REFS_FILE"; exit "$SHOW_REF_STATUS"; fi; TAG_SHA=$("$PY" "${PLUGIN_ROOT}/hooks/_releaselib.py" peel-tag "$RELEASE_TAG" < "$TAG_REFS_FILE"); rm -f -- "$TAG_REFS_FILE". Exit 1 alone means the ordinary zero-tag state and becomes empty input; every other failure remains terminal. This spelling is safe under set -euo pipefail because the expected non-zero status is on the left of ||, not hidden in a pipeline. Never revive the deprecated local-object spelling git rev-parse ${TAG_PREFIX}${VERSION}; it returns the annotated tag object rather than the commit.

2.3 Compose the tag message and verify its date

  1. Compose the annotated tag message from the Phase 1 section plus a Released-at: $RELEASE_DATE footer (the same date derived once in Phase 1) into a message file, then assert the two dates agree: "$PY" "${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.)

2.4 Classification precedes tagging (ordering rule)

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 (2.9), and nowhere else.

2.5 Source the remaining classify arguments

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 read in its declared manifest format, per 2.6 below; <release_nondraft> is derived from the Release's draft state, per 2.7 below. 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.

2.6 Read <manifest_version> in its declared format

<manifest_version> is the version field of the row's first declared manifest, extracted with that FORMAT'S OWN parser rather than a line-grep (MEDIUM, run 5: every other argument here names a command and this one named none, so jq, grep, and a JSON parser could each return something different on a nested manifest). The grammar permits a row to declare manifests in different formats, so the command follows the FILE rather than a default: "$PY" -c "import json,sys;print(json.load(open(sys.argv[1]))['version'])" <manifest> for JSON, "$PY" -c "import tomllib,sys;print(tomllib.load(open(sys.argv[1],'rb'))['project']['version'])" <manifest> for TOML, the equivalent for anything else declared. Naming only the JSON form was itself the defect one run later — applied to a pyproject.toml it raises JSONDecodeError and exits 1 (MEDIUM, run 7). This reads the FIRST declared manifest, while Pre-flight's $BASE_VERSION reads the MAXIMUM across all of them. That difference is deliberate and is safe ONLY because step 6 bumps every declared path to the derived version — which step 6 now ASSERTS mechanically, because nothing else does. Correction (HIGH, adversarial review 2026-07-31, run 12): this paragraph previously claimed classify catches a partial bump. It does not, on the path that matters. classify_publish_state short-circuits on if not tag_exists: return "publish_fresh" before it ever compares versions, so that catch fires only when a tag already EXISTS — the resume path. On a fresh publish, which is every ordinary release and every first release, a lagging secondary manifest passes unnoticed, and the Traps section's own named consequence lands: a tag that installs a version string the tag does not name. Measured: classify false "" <head> 1.4.3 1.1.0 false → publish_fresh; the same disagreement with tag_exists=true → abort_mismatch. And re-read from the file NOW — never the value Pre-flight read (HIGH, adversarial review 2026-07-31, run 4): Pre-flight read that file before step 6 bumped it, so the carried value is the OLD version, and passing it makes classify return abort_mismatch — a terminal STOP — on a release where nothing whatsoever is wrong. <tag_version> is carried and <manifest_version> is re-read; the two arguments are sourced differently on purpose.

2.7 Derive <release_nondraft> from the Release, never guessed

<release_nondraft> is false whenever <tag_exists> is false (classify short-circuits past it before it is ever consulted), otherwise gh release view "$RELEASE_TAG" --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.

2.8 Invoke classify with all six sourced arguments

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>. All six arguments are already sourced by this point (2.2 for <tag_exists>/<tag_sha>, 2.5 for <head_sha>/<tag_version>, 2.6 for <manifest_version>, 2.7 for <release_nondraft>) — never invoke this before every one of them is in hand. Four outcomes result: publish_fresh (2.9–2.10 below), abort_mismatch, already_published, and resume_publish (all three in 2.11 below).

2.9 publish_fresh: create the tag — the sole tag-creation point

publish_fresh (the tag does not exist) → write the ref now, and only here: git tag -a "$RELEASE_TAG" -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.

2.10 Verify the stored tag round-tripped

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 "$RELEASE_TAG" > <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 "$RELEASE_TAG" <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.

2.11 Handle the remaining classification outcomes

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) → do not push or publish again. If the row declares a provenance manifest, verify the merged entry from the fetched default branch; if it is absent, use Receipt-only closeout below. That separate path also handles a historical tag after the default branch advances. A row without a provenance manifest explicitly skips the receipt. 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. publish_fresh is handled in 2.9–2.10 above, not here.

Report. $TARGET, version, bump rationale, the per-commit classification, the changelog section, and the tag SHA.

Authorization handoff. 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 hosted publisher has composed the annotated tag in its isolated checkout and delivered the report. 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 hosted publisher's isolated checkout. For a protected automatic post-CI publisher, only an explicit user instruction to merge or complete the already-reported release PR, given after the Phase 1 release-PR report and before that PR merged, with that publisher declared, is the one-cohort Phase-3 authorization. The workflow may consume that authorization after the exact merged commit passes its required CI; CI success alone never creates authorization, and the instruction does not authorize a later cohort or a rebuilt candidate. Do not ask the user to authorize the same cohort again after they act on that merge instruction.

A qualifying hosted release workflow is mandatory. Its read-only preflight holds no write token, resolves exactly one target, proves the candidate equals the fetched default-branch commit, and requires green merge readiness for that exact SHA before any publisher starts. Where an automatic post-CI cohort already exists, observe that run instead of dispatching a competing publication. Otherwise dispatch the reviewed hosted lane from the default branch with $TARGET's version in that target's confirmation input and every other input blank. The steps below state the required hosted publisher behavior and read-back; they are not authorization for a local tag push.

On authorization:

Before step 1, a row with declared assets completes its reviewed hosted qualification path from the exact candidate already bound in Phase 2; it never expects Phase-1 temp paths to cross the PR boundary. For the hosted-build path, create a new empty RELEASE_ASSET_DIR plus names/paths scratch files, render the declared names, require release_require_clean_tree || exit "$?" to succeed (the underlying probe must print nothing AND exit 0), export VERSION RELEASE_TAG RELEASE_ASSET_DIR, run ( eval "$RELEASE_BUILD" ), require release_require_clean_tree || exit "$?" again, and run verify_declared_assets > "$RELEASE_ASSET_PATHS_FILE". For the retained-cohort path, download only the artifact retained by the exact green CI run for HOSTED_HEAD, verify its signed/digested provenance and source-commit receipt under the reviewed workflow, and compare its flat filenames exactly with the helper-rendered declaration; never execute a local sentinel that explicitly delegates assembly to that cohort. Either path must yield one exact verified inventory before the tag push, and any failure STOPs. Re-present that inventory; the user's publication authorization covers this one qualified cohort but no later rebuild or run. The bounded same-tag recovery lane remains only for state lost after tag composition, not the ordinary path.

Deterministic publication-input gate — complete this before step 1's first irreversible write (HIGH, blind exercise run 25). Re-establish HOSTED_HEAD=$(git rev-parse HEAD) in this qualified publisher invocation and require PUBLISH_TAG_COMMIT=$(git rev-parse "$RELEASE_TAG^{commit}") to succeed and [ "$PUBLISH_TAG_COMMIT" = "$HOSTED_HEAD" ] to hold; a local tag retargeted after Phase 2 STOPs before push. Reconstruct a fresh PUBLISH_SECTION_FILE from the local annotated $RELEASE_TAG with the policy-aware changelog-section command. Reconstruct the stored tag object in this invocation too: PUBLISH_TAG_FILE=$(mktemp); git cat-file tag "$RELEASE_TAG" > "$PUBLISH_TAG_FILE" (STOP on either failure). Require policy-aware notes-match "$RELEASE_TAG" "$PUBLISH_TAG_FILE" "$VERSION_POLICY" "$INITIAL_VERSION" and dates-match "$PUBLISH_SECTION_FILE" "$PUBLISH_TAG_FILE" "$VERSION_POLICY" "$INITIAL_VERSION". Those checks cover the heading and date, not the section bytes: extract the tag body's section before its Released-at: footer with PUBLISH_TAG_SECTION_FILE=$(mktemp); sed '1,/^$/d; /^Released-at: /,$d' "$PUBLISH_TAG_FILE" > "$PUBLISH_TAG_SECTION_FILE", then require cmp -s "$PUBLISH_TAG_SECTION_FILE" "$PUBLISH_SECTION_FILE" to succeed or STOP. Do not depend on Phase 2's scratch <message-file> surviving a restart. Derive the Release title and --latest decision now, while failure is still recoverable, and require gh auth status plus read-only repository access to succeed. Only after the section is unique, committed, heading-correct, byte-identical, date-consistent, and every deterministic gh release create input is ready may step 1 push the tag. Phase 3 step 2 repeats the section reconstruction after the push as a read-back guard, but it is not the first validation.

  1. Pin the local annotated tag object before the push: LOCAL_TAG_OBJECT_SHA=$(git rev-parse "refs/tags/$RELEASE_TAG^{tag}") (STOP if it fails), and print its SHA to the hosted job log before the push with the tag name and $HOSTED_HEAD: printf 'pre-push tag=%s tag_object=%s hosted_head=%s\n' "$RELEASE_TAG" "$LOCAL_TAG_OBJECT_SHA" "$HOSTED_HEAD". Retain the authenticated run link in the report so interrupted receipt closeout can recover the original tuple. The authorized hosted publisher then pushes only the fully qualified tag ref: git push origin "refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG". Never use the short source spelling: a malicious or mistaken prefix such as refs/heads/release- is rejected by the declaration parser, and the full refspec independently prevents Git from resolving a same-named branch as publication input.

  2. Resolve <Phase-1 section file> fresh in every Phase-3 invocation — never assume Phase 1's scratch file survived (HIGH, blind exercise run 19). That file was created with mktemp outside the working tree and discarded once Phase 3 no longer needed it; on the resume_publish path (tag composed in a prior invocation, published now) it is normally already gone, and there was previously no stated way to get it back that did not read as the "re-derive or hand-write" the hard rules forbid. There is one sanctioned, mechanical way: "$PY" "${PLUGIN_ROOT}/hooks/_releaselib.py" changelog-section "$PROJECT_ROOT" "$RELEASE_TAG" "$CHANGELOG" "$VERSION" prints the ## [${VERSION}] … section back out of the exact regular-file blob committed under the already-composed tag — guaranteed present, because Phase 1 step 7 committed it before the tag was created. The project root and every value are separate quoted arguments; the helper rejects a nested or unrelated root, an absolute or escaping changelog path, a non-regular Git tree entry, a malformed heading, Unreleased in a released position, and duplicate target sections. It resolves the tag to a commit hash before reading the blob, so a dirty/deleted working file, a changed current HEAD, or an unrelated current directory cannot substitute Release notes after the tag is composed. Redirect stdout to a fresh local file and use that as <Phase-1 section file> for every step below; this is reading the exact text back from its one permanent home, not composing new notes. Exit 1 (no heading for ${VERSION}) means the committed changelog and tag version disagree; exit 3 means the root/revision/path/blob binding could not be proven; exit 4 means the changelog is malformed or ambiguous. Every one STOPs for investigation; never compose a substitute section by hand. On a same-session fresh publish the Phase 1 scratch file is still there and this reconstruction is redundant but harmless — run it anyway, so Phase 3 does not need to know which case it is in. Guard the notes-file first: assert its first heading matches the tag — "$PY" "${PLUGIN_ROOT}/hooks/_releaselib.py" notes-match "$RELEASE_TAG" <Phase-1 section file> (exit 0). A stale notes-file (notes_heading_matches False) would publish the wrong changelog section under the right tag — STOP on mismatch. Then create the GitHub Release from the same changelog section composed in Phase 1 — reuse it as the notes, never re-derive or hand-write them. --latest follows the declared row: assert it only when $TARGET's row declares latest-eligible: true, and only when this tag is also the newest release across every declared series (compare against gh release list; 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 "$RELEASE_TAG" --title "<title>" --notes-file <Phase-1 section file> --latest --verify-tag when the row qualifies per the rule above, otherwise gh release create "$RELEASE_TAG" --title "<title>" --notes-file <Phase-1 section file> --latest=false --verify-tag — two distinct, individually runnable commands, never the bracket notation --latest[=false], which is prose shorthand and not shell gh accepts. The title convention is <$DISPLAY_NAME> ${VERSION}: <summary> — $DISPLAY_NAME is the row's declared display-name, or $TARGET itself when the row declares none — with no em-dash separator. <summary> is derived, not invented (MEDIUM, adversarial review 2026-07-31, run 3: it appeared exactly once in this file and was never defined, so it was whatever the agent made up): take the single highest-precedence entry from the Phase 1 section — the first bullet under ### Breaking if the window bumped major, the first bullet under ### Added if the window bumped minor, otherwise the first bullet under ### Fixed, else the first bullet of the first non-empty group — and compress it to a noun phrase under ten words, in the entry's own words. "First" means first as the section actually lists it: classify-window preserves the window's own order and step 5 does not reorder within a group, so the section's listed order is the window's own order as git log emits it (reverse-chronological, newest-first), never a re-sorted pass. 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.

    Use the policy-aware forms for the resolved row: "$PY" "${PLUGIN_ROOT}/hooks/_releaselib.py" changelog-section "$PROJECT_ROOT" "$RELEASE_TAG" "$CHANGELOG" "$VERSION" "$VERSION_POLICY" "$INITIAL_VERSION" and "$PY" "${PLUGIN_ROOT}/hooks/_releaselib.py" notes-match "$RELEASE_TAG" <Phase-1 section file> "$VERSION_POLICY" "$INITIAL_VERSION". When assets are declared, replace the assetless gh release create spelling above with a local publish_release function. Inside it, start with set --, then while IFS= read -r asset; do set -- "$@" "$asset"; done < "$RELEASE_ASSET_PATHS_FILE", and invoke the same authorized gh release create command with "$@" appended. This preserves each verified path as one argument and uploads only the helper-rendered, helper-verified inventory; never glob the output directory or reconstruct paths by hand.

  3. Handle edge cases explicitly, never silently: if a Release for the tag already exists, report it and skip creation (the tag push may already have landed); if gh is missing, unauthenticated, or the call fails, STOP and print the exact gh release create command so publication can be finished by hand rather than left half-done.

  4. Verify publication — never assume it. Read the Release back: gh release view "$RELEASE_TAG" --json url,isDraft,tagName,assets. STOP unless it returns a non-draft Release on the correct tag. For a declared asset contract, also write gh release view "$RELEASE_TAG" --json assets --jq '.assets[].name' to a fresh scratch file, sort that file and RELEASE_ASSET_NAMES_FILE under the same locale, and require cmp -s to succeed. Duplicate, missing, or extra published names fail this exact comparison: success means the GitHub Release contains exactly the declared asset names. A partially successful or mismatched publish is not a passing gate; report the URL only after both metadata and inventory read-back pass.

  5. Capture the tag's provenance. Check the remote ref for every row, including one without a provenance manifest; only the receipt write is conditional. 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. Capture one remote snapshot after the push: REMOTE_TAG_REFS=$(git ls-remote --tags origin "refs/tags/$RELEASE_TAG" "refs/tags/$RELEASE_TAG^{}"), and STOP unless it contains exactly the direct ref plus its peeled ref. Derive TAG_OBJECT_SHA from the exact refs/tags/$RELEASE_TAG line and derive TAG_COMMIT_SHA=$(printf '%s\n' "$REMOTE_TAG_REFS" | "$PY" "${PLUGIN_ROOT}/hooks/_releaselib.py" peel-tag "$RELEASE_TAG"). Require TAG_OBJECT_SHA to equal LOCAL_TAG_OBJECT_SHA pinned immediately before the push, and TAG_COMMIT_SHA to equal the exact hosted HEAD already qualified above; STOP on either mismatch. Report both identities and the tag before the hosted job ends. Both identities come from the same remote response, and the object comparison binds that response to the tag actually pushed. If the row declares no $PROVENANCE_MANIFEST, skip only the receipt and say so explicitly in the report. Otherwise, do not write to the hosted default-branch checkout: after publication, the operator creates a new non-default branch based on the fetched default branch, adds {"object_sha": <TAG_OBJECT_SHA>, "object_type": "tag", "commit_sha": <TAG_COMMIT_SHA>} under tags in $PROVENANCE_MANIFEST, commits through commit-gate, and must merge the receipt through a pull request. A branch-only commit is not a recorded default-branch receipt: provenance closeout remains pending until that PR merges. If this project runs an automated tag-drift check against that file, the next release may be blocked until the receipt merges; never move or recreate the published tag to clear it.

  6. Clean up declared-asset scratch state only after publication and the provenance snapshot succeed. When assets were declared and steps 1 through 5 have passed through remote-ref capture, remove only the positively identified paths minted by this invocation: rm -rf -- "$RELEASE_ASSET_DIR" and rm -f -- "$RELEASE_ASSET_NAMES_FILE" "$RELEASE_ASSET_PATHS_FILE". Do not name an unminted scratch variable, glob a temporary root, or infer any path. Until both metadata and inventory read-back pass, or whenever the required remote-ref capture fails, preserve the directory and scratch files as failure evidence and report their exact paths; cleanup never runs on a partial or unverified publication.

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 remote provenance snapshot is captured and handed off for a separate receipt PR (or the report explicitly says the row declares none), AND any declared-asset scratch state is removed only after those gates pass. Report publication as live but provenance closeout as pending until the receipt PR merges; do not claim a branch-local entry completed it. A failed or unverified publish is NOT a passing gate. Without authorization, nothing is published.

Asset recovery for resume_publish

This is a hosted-publisher incident-recovery contract, not an interactive local fallback. It is only for a tag previously composed by the qualifying hosted publisher that classifies as resume_publish and whose declared-asset scratch state no longer exists. It reconstructs the already-tagged release; it never enters Phase 1's commit-window derivation, never applies version arithmetic, never writes a new tag, and never advances the version.

Resolve the row mechanically as in Targets, then derive the candidate only from tags that point at the current commit: RECOVERY_TAG=$(git tag --points-at HEAD | "$PY" "${PLUGIN_ROOT}/hooks/_releaselib.py" last-tag-for-policy "$TAG_PREFIX" "$VERSION_POLICY" "$INITIAL_VERSION"). STOP if it prints <none>. Require the exact tag boundary with case "$RECOVERY_TAG" in "$TAG_PREFIX"*) VERSION=${RECOVERY_TAG#"$TAG_PREFIX"} ;; *) STOP ;; esac, then set RELEASE_TAG=$RECOVERY_TAG. Bind the immutable inputs before executing anything: RECOVERY_HEAD=$(git rev-parse HEAD) and RECOVERY_TAG_COMMIT=$(git rev-parse "$RELEASE_TAG^{commit}") must be identical; "$PY" "${PLUGIN_ROOT}/hooks/_releaselib.py" check-manifests "$TARGET" "$VERSION" must exit 0; and "$PY" "${PLUGIN_ROOT}/hooks/_releaselib.py" changelog-section "$PROJECT_ROOT" "$RELEASE_TAG" "$CHANGELOG" "$VERSION" "$VERSION_POLICY" "$INITIAL_VERSION" must reconstruct the committed section from that tag. Re-run the ordinary publish-state inputs and require classify to return exactly resume_publish; a moved HEAD, mismatched manifest, existing non-draft Release, missing Release evidence, or any other state STOPs this recovery lane.

Before confirming or executing any declared input, require release_require_clean_tree || exit "$?" to succeed (the underlying probe must print nothing AND exit 0), proving the working tree is the tagged commit just bound above under the selected row's release-surface boundary. Then "$PY" "${PLUGIN_ROOT}/hooks/releasehash.py" check $TARGET must confirm the exact current pre-tag, release-build, rebuild, and generate inputs — see step 6c for the full exit-code meaning (0 confirmed/no-commands, 1 changed, 2 never confirmed, and 64/65 configuration failures — a malformed invocation or an unrecognised $TARGET — that STOP this recovery lane the same way a 1 or 2 would, never a confirmation) — and "$PY" "${PLUGIN_ROOT}/hooks/_releaselib.py" run-pre-tag $TARGET must pass against that clean tagged commit. Immediately after run-pre-tag, require release_require_clean_tree || exit "$?" again so a check cannot mutate the tagged tree unnoticed. Re-run the same reviewed hosted qualification path used for the original cohort: either build in a new empty RELEASE_ASSET_DIR with ( eval "$RELEASE_BUILD" ) plus clean-tree and exact-inventory verification, or recover and reverify the retained exact-source CI package and its receipts. A local sentinel that delegates to the retained cohort is never treated as the build command. Present the reconstructed changelog section and verified asset names, preserve its fresh evidence paths, then STOP for fresh publication authorization. Only a later authorized Phase 3 invocation may push or publish; if that invocation loses this new evidence state, repeat this recovery lane and obtain fresh authorization again.

Receipt-only closeout after an interrupted publication

Use this only for an already published tag whose declared provenance receipt is absent from the fetched default branch. Resolve the target row and the exact tag name; require that tag to match its declared prefix and version policy. This is the historical released commit's closeout: it does not run Phase 1 or Phase 2, does not require the tag commit to equal today's default HEAD, and never pushes the tag or creates another Release. If a receipt PR already exists, continue it instead of opening a duplicate.

Read the authenticated log and run metadata for the exact hosted publisher that made the original push. Require its pre-push (tag, tag-object SHA, HOSTED_HEAD) tuple and exact-source run identity; without them STOP rather than inventing a receipt from a current mutable ref. In one fresh git ls-remote --tags response require exactly the direct annotated tag ref and peeled ref, with the direct object equal to that recorded pre-push object and the peeled commit equal to that run's hosted commit. Read the GitHub Release back and require a non-draft Release on the same tag. Compare any already merged manifest entry on the fetched default branch with this same object, object_type: tag, and commit; a mismatch STOPs. If the entry is absent, create the receipt on a new non-default branch from fetched default, commit it through commit-gate, and merge it through a PR. Report provenance closeout as pending until the PR merges. Missing or conflicting original evidence is a reviewed incident, never permission to move the tag or rewrite the ledger.

Recovering from a bad release

A published tag is immutable. Correction means publishing a NEW version — never moving, re-pointing, or deleting the old one. Where this project records that as a maintainer ruling (an ADR, an issue, a decision log entry — commonly paired with a hosting-service tag-protection setting and a deliberate no break-glass role), that ruling stands; the doctrine below does not depend on one existing to be true.

This is not a style preference. Consumers are instructed to pin an exact tag, so the tag is the identity of a payload that review, CI, and a published changelog have all vouched for. Retargeting a published tag does not fix it for anyone who already installed it; it silently changes what everyone who installs it next gets, under a version whose verification history now describes different code. Deleting it is worse — every pinned install breaks at once, with no version left to roll back to.

When a release is wrong:

  1. Leave the bad tag and its Release exactly where they are. Do not git push --force the tag, do not git push --delete, do not gh release delete. The bad version staying visible is what lets a consumer tell which payload they got.
  2. Fix the defect on a branch and land it through the normal PR path.
  3. Run /release $TARGET again. The bump is derived from that target's commit log as usual, so the fix ships as the next patch (or higher) version in the same series.
  4. Mark the bad release so nobody installs it on purpose: gh release edit <bad-tag> --prerelease demotes it out of the Latest position, and a note at the top of its body should name the superseding version. Editing release notes is fine — it changes no code and moves no ref.
  5. If the bad release is actively harmful (a leaked secret, a destructive bug), say so in the new release's notes and in the old release's body. An advisory is the sanctioned way to un-recommend a version; a moved tag is not.

The one case that is not a correction: a tag pushed by mistake with no GitHub Release and no possibility of a consumer having fetched it. Even then, prefer superseding it. If it must be removed, that is a maintainer action taken deliberately and announced, and, when $TARGET's row declares $PROVENANCE_MANIFEST, that file must be updated in the same PR so a drift audit does not report a deletion it was told to expect.

If this project runs an automated tag-immutability drift check in CI, the manifest is the witness, not the suspect, when it reports drift. Such a check compares live tag refs against a committed provenance manifest; going red means a published ref moved. The fix is to restore the ref to its recorded sha and find out who moved it. Editing the provenance manifest to match the moved sha would "fix" the check by deleting the evidence — never do that to silence a red run. The only legitimate provenance-manifest edits are recording a newly published tag (Phase 3 step 5, when the row declares one) and a deliberate, announced removal. A project with no such CI check, or no declared $PROVENANCE_MANIFEST, still keeps the doctrine above in full — the check is a detection layer for the rule, not the rule itself.

Hard rules

  • MUST resolve $TARGET to exactly one declared row before anything else, and MUST STOP on an unrecognised target rather than guessing which project was meant.
  • MUST NOT tag on a red suite. Local commit-gate evidence is impact-bounded and is not publication evidence; the mandatory hosted publisher must verify its configured merge-readiness aggregate is green for the exact fetched default-branch SHA before its write-token job starts.
  • 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 compose or publish a tag from an unmerged release branch. Phase 1 ends at the PR boundary; only the qualifying hosted publisher may tag the merged, exact-head default-branch commit.
  • MUST NOT push the tag or create the GitHub Release without explicit user authorization; they publish together in Phase 3, even after the hosted publisher composes the tag in its isolated checkout.
  • MUST scope tag resolution, the commit window, and the bump derivation to $TARGET's series and its argument-preserving payload pathspec set; another target's tag or commit MUST NOT influence this release's version, window, or changelog. MUST NOT resolve LAST_TAG with bare git describe --tags, and MUST take $TAG_PREFIX from the declared file rather than typing it.
  • MUST assert the derived version equals every path declared in $MANIFEST. MUST NOT hand-edit a path also listed in $GENERATED_MANIFEST — regenerate it via the row's declared generate command instead.
  • MUST rebuild $ARTIFACTS (via the row's declared rebuild, when one exists) and assert every committed bundle is clean before tagging; a target ships the built file, not its source.
  • MUST run every declared pre-tag check, in declared order, and BLOCK on a non-zero exit (DECISION-0034) — this is how a target's own extra release surfaces (a badge, a catalog table) get asserted consistent; this skill assumes nothing about what any target's surfaces are beyond what the row declares.
  • MUST verify the published Release by read-back (gh release view → non-draft, correct tag); a failed or unverified publish is not a passing gate.
  • MUST NOT assert --latest for any target whose row does not declare latest-eligible: true, and not even then unless the tag is the newest release across every declared series.
  • MUST use the Phase-1 changelog section verbatim as the GitHub Release notes — never re-derive or hand-write them.
  • MUST NOT guess the version — derive it from the commit log through derive-version under the declared policy, and confirm it with version-greater, never by eye. The retained apply-bump and semver-greater commands are compatibility APIs for existing callers, not the policy-general release route.
  • MUST NOT auto-fill a missing CHANGELOG: footer, and MUST NOT tag past one — an ordinary missing footer on a bumping commit is a Phase-1 BLOCK, surfaced as [NEEDS-TRIAGE] and stopped. The sole reconciliation path is a target-declared strict ledger entry for an exact full SHA already proven on the fetched default branch; short, mismatched, wrong-target, malformed, undeclared, or unpublished entries fail closed, and the entry may supply changelog text only.
  • MUST NOT tag a non-bumping window — test/docs/chore/ci-only sets do not release.
  • MUST NOT move, retarget, delete, or re-point a published tag, in any declared target's tag namespace, for any reason — no git push --force on a tag, no git push --delete, no gh release delete. A bad release is corrected by publishing a NEW version; see "Recovering from a bad release". There is no break-glass path.
  • MUST record every newly published tag in the row's declared $PROVENANCE_MANIFEST, when one is declared, and MUST NOT edit an existing entry to silence a red tag-immutability drift check — a red run means a ref moved, and the manifest is the evidence of where it belonged.
  • MUST NOT write, commit, build assets, or tag anything under --dry-run — it runs Pre-flight's STOP assertions and Phase 1 steps 1-4 only, then reports and stops; steps 5-8 and every phase after Phase 1 do not run (#565). This includes not EXECUTING a declared rebuild, generate, pre-tag, or release-build command — list each by name instead.

レビュー

まだレビューはありません。使ってみた感想をお寄せください。

同じリポジトリのスキル

概要と使いどころ

The Socratic spec-refinement front of /feature, and the planning front of /sprint. Routed to BEFORE any code — it turns a one-line idea into a concrete spec with testable criteria, ready for initial combined sprint review or explicit sequential approval. Five gated phases — frame, shape, refine, write, review-and-approve. No implementation before an approved spec; the caller retains required planning, execution and delivery ordering. Each acceptance criterion becomes one tdd Phase 1 obligation.

日本語の概要は準備中です。原文の説明を表示しています。

arbiterForge/codeArbiter1472026年10月11日 更新

Vet a new or changed third-party dependency for license, provenance, and supply-chain risk before any install runs.

日本語の概要は準備中です。原文の説明を表示しています。

arbiterForge/codeArbiter1472026年10月11日 更新

ca-adr

無料

Record user-decided ADRs or inspect their health read-only. Preserve attribution and acceptance evidence.

日本語の概要は準備中です。原文の説明を表示しています。

arbiterForge/codeArbiter1472026年10月11日 更新

Inspect ADR health read-only; optionally select one ADR with --adr N.

日本語の概要は準備中です。原文の説明を表示しています。

arbiterForge/codeArbiter1472026年10月11日 更新

ca-audit

無料

Assemble the governance record for a range — commits, overrides, ADRs, sprint auto-decisions, open questions, checkpoint findings — into one dated audit packet. Read-only.

日本語の概要は準備中です。原文の説明を表示しています。

arbiterForge/codeArbiter1472026年10月11日 更新

ca-btw

無料

Lightweight Q&A about the project — answer from context and return, no routing, no state change.

日本語の概要は準備中です。原文の説明を表示しています。

arbiterForge/codeArbiter1472026年10月11日 更新

arbiterForge のスキルをすべて見る

このスキルの問題を報告する