Claude Skill

release

The single permitted path to a version tag, for any target declared in the project's .codearbiter/release-targets.md. Routed to when the user invokes /release on a non-default branch with a green suite. Takes the declared target as its one argument, derives the SemVer bump from C

LLM Mart · 0 points · 15 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download arbiterForge-codeArbiter-plugins_ca-pi_routines_release-8e88bce.zip · 47 KB
Part of arbiterforge/codearbiter — 232 skills

Install

skills CLI npx skills add https://github.com/arbiterForge/codeArbiter/tree/main/plugins/ca-pi/routines/release
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install arbiterforge-codearbiter@llmmart
Git git clone https://github.com/arbiterForge/codeArbiter.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole arbiterforge/codearbiter collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

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: <plugin-root>/hooks/_releaselib.py's module docstring). /release takes the target's name as its only argument. When $TARGET is omitted and the declared file names exactly one target, that target is used — a single-target project's bare /release behaves exactly as it always has. When more than one target is declared, $TARGET is required; STOP and ask rather than guessing which one a bare invocation meant. Resolve the omitted-single-target case mechanically, never by assumption (MEDIUM, adversarial review 2026-07-31: tag-prefix itself takes $TARGET as a REQUIRED positional argument and has no way to express "the implicit one", so naming it here was not itself enough — the mechanical step that turns an omitted target into a concrete name before tag-prefix is ever called has to be spelled out too): run "$PY" "<plugin-root>/hooks/_releaselib.py" list-targets first — the sanctioned enumeration, through the same tested grammar tag-prefix already reads, rather than a by-eye scan of the delimiter block. Exactly one printed line confirms which name $TARGET is; more than one is the multi-target STOP above, restated by the tool rather than assumed. There is deliberately no second command per target: N commands would be N public surfaces to govern, catalog, and carry, for one operation whose only difference is which declared row it reads.

Every phase below is written once, against that row. Nothing in this skill is per-target prose.

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" "<plugin-root>/hooks/<script>" <args> literally, one spelling throughout — including the inline "$PY" -c "…" snippets. Two spellings for one thing invites reading the difference as meaningful (blind exercise run 14 flagged exactly that when only three steps used "$PY" and fifteen still said python3).

Both quotes are load-bearing, and the second one is the easier to lose (HIGH-1, blind exercise run 16). Quoting only the interpreter — "$PY" <plugin-root>/hooks/<script> — leaves the script path exposed to word splitting, and a plugin root containing a space is an ordinary Windows install (C:\Users\First Last\.claude\plugins\…, since an account name with a space is unremarkable). On such a host the path splits at the space, Python is handed a truncated filename, and EVERY step of this lane fails at once: target resolution, last-tag, classify-window, check-manifests, classify, notes-match. The operator's only diagnostic is can't open file '…\First', which names nothing recognisable. The same applies to any $PROJECT_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 <plugin-root>/hooks/_releaselib.py show-row $TARGET" followed by $($ROW --field prefix) reads as the obvious tidy-up and reintroduces, in the one block that reads EVERY field, the exact defect the quoting above removes (HIGH-1, blind exercise run 16). An unquoted $ROW is subject to word splitting, which is what makes it run as a command at all — so the interpreter path inside it cannot be protected, and a plugin root containing a space (C:\Users\First Last\.claude\plugins\… is an ordinary Windows install) splits mid-path and fails every field read at once. Quoting "$ROW" does not rescue it either; that spelling looks for a single executable whose filename is the entire string. The verbosity is the price of the property.

MUST NOT read the row with eval. A bare eval "$(… show-row …)" executes the declared values: rebuild: cd x && npm run build parses as the assignment REBUILD=cd followed by the command x, with && npm run build waiting behind it — and eval still exits 0, because plain assignments follow. Blind exercise run 15 hit exactly that. These values are operator-authored shell that this lane runs only AFTER step 6c confirms a human has read them; executing a fragment of them while merely READING the row runs them before the gate that exists for them. show-row's bare form is shell-quoted so the mistake is now inert, but --field needs no eval at all and is the sanctioned spelling.

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:

field meaning
$TAG_PREFIX (prefix) the tag namespace this target publishes under
$DISPLAY_NAME (display-name) optional; the human-readable name used in the Phase-3 Release title. Defaults to $TARGET itself when a row declares none
$MANIFEST (manifest) one or more version-carrying files; every one is asserted equal to the derived version
$GENERATED_MANIFEST (generated-manifest) optional subset of $MANIFEST; never hand-edited — regenerated by $GENERATE instead
$GENERATE (generate) optional command that regenerates every path in $GENERATED_MANIFEST; run before the Phase-1 manifest-equality assertion
$CHANGELOG (changelog) the file the Phase-1 section is rolled into
$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 r

Files (codearbiter)
  • SKILL.md 138.9 KB
    ---
    name: release
    description: Prepare a declared release target, or preview it with --dry-run. Derive its version and require authorization before publication.
    argument-hint: "[target] | --dry-run"
    ---
    
    # 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: `<plugin-root>/hooks/_releaselib.py`'s module docstring). `/release` takes the target's name as its only argument. When `$TARGET` is omitted and the declared file names exactly one target, that target is used — a single-target project's bare `/release` behaves exactly as it always has. When more than one target is declared, `$TARGET` is required; STOP and ask rather than guessing which one a bare invocation meant. **Resolve the omitted-single-target case mechanically, never by assumption** (MEDIUM, adversarial review 2026-07-31: `tag-prefix` itself takes `$TARGET` as a REQUIRED positional argument and has no way to express "the implicit one", so naming it here was not itself enough — the mechanical step that turns an omitted target into a concrete name before `tag-prefix` is ever called has to be spelled out too): run `"$PY" "<plugin-root>/hooks/_releaselib.py" list-targets` first — the sanctioned enumeration, through the same tested grammar `tag-prefix` already reads, rather than a by-eye scan of the delimiter block. Exactly one printed line confirms which name `$TARGET` is; more than one is the multi-target STOP above, restated by the tool rather than assumed. There is deliberately no second command per target: N commands would be N public surfaces to govern, catalog, and carry, for one operation whose only difference is which declared row it reads.
    
    Every phase below is written once, against that row. Nothing in this skill is per-target prose.
    
    **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:**
    
    ```sh
    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" "<plugin-root>/hooks/<script>" <args>` literally, one spelling throughout — including the inline `"$PY" -c "…"` snippets. Two spellings for one thing invites reading the difference as meaningful (blind exercise run 14 flagged exactly that when only three steps used `"$PY"` and fifteen still said `python3`).
    
    **Both quotes are load-bearing, and the second one is the easier to lose** (HIGH-1, blind exercise run 16). Quoting only the interpreter — `"$PY" <plugin-root>/hooks/<script>` — leaves the script path exposed to word splitting, and a plugin root containing a space is an ordinary Windows install (`C:\Users\First Last\.claude\plugins\…`, since an account name with a space is unremarkable). On such a host the path splits at the space, Python is handed a truncated filename, and EVERY step of this lane fails at once: target resolution, `last-tag`, `classify-window`, `check-manifests`, `classify`, `notes-match`. The operator's only diagnostic is `can't open file '…\First'`, which names nothing recognisable. The same applies to any `$PROJECT_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.
    
    ```sh
    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.
    
    ```sh
    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:
    
    ```sh
    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:
    
    ```sh
    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 <plugin-root>/hooks/_releaselib.py show-row $TARGET"` followed by `$($ROW --field prefix)` reads as the obvious tidy-up and reintroduces, in the one block that reads EVERY field, the exact defect the quoting above removes (HIGH-1, blind exercise run 16). An unquoted `$ROW` is subject to word splitting, which is what makes it run as a command at all — so the interpreter path inside it cannot be protected, and a plugin root containing a space (`C:\Users\First Last\.claude\plugins\…` is an ordinary Windows install) splits mid-path and fails every field read at once. Quoting `"$ROW"` does not rescue it either; that spelling looks for a single executable whose filename is the entire string. The verbosity is the price of the property.
    
    **MUST NOT read the row with `eval`.** A bare `eval "$(… show-row …)"` executes the declared values: `rebuild: cd x && npm run build` parses as the assignment `REBUILD=cd` followed by the command `x`, with `&& npm run build` waiting behind it — and `eval` still exits 0, because plain assignments follow. Blind exercise run 15 hit exactly that. These values are operator-authored shell that this lane runs only AFTER step 6c confirms a human has read them; executing a fragment of them while merely READING the row runs them before the gate that exists for them. `show-row`'s bare form is shell-quoted so the mistake is now inert, but `--field` needs no `eval` at all and is the sanctioned spelling.
    
    **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:
    
    ```sh
    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:
    
    | field | meaning |
    |---|---|
    | `$TAG_PREFIX` (`prefix`) | the tag namespace this target publishes under |
    | `$DISPLAY_NAME` (`display-name`) | optional; the human-readable name used in the Phase-3 Release title. Defaults to `$TARGET` itself when a row declares none |
    | `$MANIFEST` (`manifest`) | one or more version-carrying files; every one is asserted equal to the derived version |
    | `$GENERATED_MANIFEST` (`generated-manifest`) | optional subset of `$MANIFEST`; never hand-edited — regenerated by `$GENERATE` instead |
    | `$GENERATE` (`generate`) | optional command that regenerates every path in `$GENERATED_MANIFEST`; run before the Phase-1 manifest-equality assertion |
    | `$CHANGELOG` (`changelog`) | the file the Phase-1 section is rolled into |
    | `$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):
    
       ```bash
       mkdir -p "$(git rev-parse --show-toplevel)/.codearbiter/.markers"
       touch "$(git rev-parse --show-toplevel)/.codearbiter/.markers/release-targets-authoring"
       ```
    
       Write the confirmed block verbatim to `$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:
    
       ```bash
       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.
    
      ```sh
      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 r

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related