@arbiterforge/ca-pi 0.14.12 → 0.14.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -12
- package/package.json +2 -1
- package/plugins/ca-pi/CHANGELOG.md +39 -0
- package/plugins/ca-pi/LICENSE +661 -0
- package/plugins/ca-pi/THIRD_PARTY_NOTICES.md +40 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-amd64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-arm64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-amd64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-arm64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-amd64.exe +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-arm64.exe +0 -0
- package/plugins/ca-pi/helpers/artifacts/release.json +6 -6
- package/plugins/ca-pi/hooks/_artifactauthoritylib.py +486 -37
- package/plugins/ca-pi/hooks/_artifactlib.py +150 -3
- package/plugins/ca-pi/hooks/_host.py +1 -1
- package/plugins/ca-pi/hooks/_releaselib.py +345 -111
- package/plugins/ca-pi/hooks/artifact-authority-hook.py +57 -2
- package/plugins/ca-pi/hooks/artifact-authority.py +23 -1
- package/plugins/ca-pi/hooks/hostapi.py +1 -1
- package/plugins/ca-pi/hooks/releasehash.py +36 -23
- package/plugins/ca-pi/includes/artifacts.md +26 -6
- package/plugins/ca-pi/package.json +1 -1
- package/plugins/ca-pi/routines/release/SKILL.md +220 -70
- package/plugins/ca-pi/skills/ca-release/SKILL.md +6 -4
|
@@ -7,7 +7,7 @@ description: The single permitted path to a version tag, for any target declared
|
|
|
7
7
|
|
|
8
8
|
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.
|
|
9
9
|
|
|
10
|
-
**One command, any number of declared targets.** A project declares one or more release targets in
|
|
10
|
+
**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.
|
|
11
11
|
|
|
12
12
|
Every phase below is written once, against that row. Nothing in this skill is per-target prose.
|
|
13
13
|
|
|
@@ -26,45 +26,114 @@ STOP; do not translate the workflow ad hoc into a second command language.
|
|
|
26
26
|
|
|
27
27
|
```sh
|
|
28
28
|
PY=python3; { command -v python3 >/dev/null 2>&1 && python3 --version >/dev/null 2>&1; } || PY=python
|
|
29
|
+
PYTHONDONTWRITEBYTECODE=1; export PYTHONDONTWRITEBYTECODE
|
|
30
|
+
PYTHONUTF8=1; PYTHONIOENCODING=utf-8; export PYTHONUTF8 PYTHONIOENCODING
|
|
31
|
+
PROJECT_ROOT=$(pwd)
|
|
32
|
+
cd "$PROJECT_ROOT" || { printf 'STOP — cannot enter the resolved project root.\n' >&2; exit 1; }
|
|
29
33
|
```
|
|
30
34
|
|
|
31
35
|
**`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.
|
|
32
36
|
|
|
33
|
-
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`).
|
|
37
|
+
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`).
|
|
34
38
|
|
|
35
|
-
**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
|
|
39
|
+
**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.
|
|
36
40
|
|
|
37
41
|
**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.
|
|
38
42
|
|
|
43
|
+
**Executable local guards.** Define these functions in each shell that enters this
|
|
44
|
+
workflow, including a fresh hosted or recovery shell. They read state; they do
|
|
45
|
+
not authorize a write, replace `commit-gate`, or grant publication permission.
|
|
46
|
+
Every call below must succeed before its following operation can run.
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
release_require_clean_tree() (
|
|
50
|
+
tree_status=0
|
|
51
|
+
tree_output=$("$PY" "<plugin-root>/hooks/_releaselib.py" clean-tree-status "$TARGET") || tree_status=$?
|
|
52
|
+
if [ "$tree_status" -ne 0 ]; then
|
|
53
|
+
printf 'STOP — release-tree inspection failed (exit %s); an empty stdout is not a clean tree.\n' "$tree_status" >&2
|
|
54
|
+
exit "$tree_status"
|
|
55
|
+
fi
|
|
56
|
+
if [ -n "$tree_output" ]; then
|
|
57
|
+
printf '%s\n' "$tree_output" >&2
|
|
58
|
+
printf 'STOP — the release tree is dirty.\n' >&2
|
|
59
|
+
exit 1
|
|
60
|
+
fi
|
|
61
|
+
exit 0
|
|
62
|
+
)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**The clean-tree success predicate is exit 0 AND empty porcelain stdout.** A
|
|
66
|
+
failed or timed-out probe never establishes cleanliness, even when it prints
|
|
67
|
+
nothing. Use `release_require_clean_tree || exit "$?"` at every clean-tree gate;
|
|
68
|
+
never test only `-z "$(… clean-tree-status …)"`. Preserve the underlying helper's
|
|
69
|
+
contract: clean and dirty successful probes both exit 0, while probe failure is
|
|
70
|
+
nonzero. The wrapper distinguishes these outcomes without changing that API.
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
release_require_commit_path() (
|
|
74
|
+
if [ ! -f "$PROJECT_ROOT/.codearbiter/tech-stack.md" ]; then
|
|
75
|
+
printf 'STOP — commit-gate requires .codearbiter/tech-stack.md; declare it through context-creation before writing.\n' >&2
|
|
76
|
+
exit 1
|
|
77
|
+
fi
|
|
78
|
+
if [ -z "${DEFAULT_BRANCH:-}" ]; then
|
|
79
|
+
printf 'STOP — resolve and confirm the default branch before writing.\n' >&2
|
|
80
|
+
exit 1
|
|
81
|
+
fi
|
|
82
|
+
branch=$(git -C "$PROJECT_ROOT" symbolic-ref --quiet --short HEAD) || {
|
|
83
|
+
printf 'STOP — release preparation requires an attached non-default branch.\n' >&2
|
|
84
|
+
exit 1
|
|
85
|
+
}
|
|
86
|
+
case "$branch" in
|
|
87
|
+
main|master|"$DEFAULT_BRANCH")
|
|
88
|
+
printf 'STOP — commit-gate refuses branch %s; use a non-default feature branch.\n' "$branch" >&2
|
|
89
|
+
exit 1 ;;
|
|
90
|
+
esac
|
|
91
|
+
git -C "$PROJECT_ROOT" status --porcelain >/dev/null || {
|
|
92
|
+
printf 'STOP — Git cannot inspect this repository; do not write release files.\n' >&2
|
|
93
|
+
exit 1
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
This pre-write check is only a prerequisite refusal. It does not certify the
|
|
99
|
+
contents of `tech-stack.md`, execute its tests, or waive any later commit-gate
|
|
100
|
+
check. Resolve `$DEFAULT_BRANCH` using the documented fact/remote/user fallback
|
|
101
|
+
before calling it; do not infer a default from the current branch name.
|
|
102
|
+
|
|
103
|
+
If a previously published tag lacks its declared provenance receipt, resolve the
|
|
104
|
+
target row, then use **Receipt-only closeout** below. That path does not run
|
|
105
|
+
Phase 1 or Phase 2 and does not require the historical released commit to
|
|
106
|
+
remain the current default-branch HEAD. It never publishes again.
|
|
107
|
+
|
|
39
108
|
## Targets
|
|
40
109
|
|
|
41
|
-
Resolve `$TARGET`'s row from the declared file FIRST and use it throughout — never a hardcoded table. An unparseable declared file (any parser-contract violation on a file that DOES exist — including one that exists but carries no delimiter block at all, `FileExistsNoBlockError`) → STOP and surface the parse error; never guess a row's shape, and never treat an existing-but-broken file as an opportunity to back-fill it (see "Back-fill" below for why that distinction is mechanical, not a judgment call). A genuinely ABSENT declared file — nothing on disk at all, the one state `AbsentBlockError` alone names — enters the "Back-fill" lane below instead of stopping outright; that lane never runs against a file that already exists, in any state. Resolve `$TAG_PREFIX` through the shared mechanism, never typed from memory: `TAG_PREFIX=$("$PY" "<plugin-root>/hooks/_releaselib.py" tag-prefix $TARGET)`. Where a hosted publish lane's own namespace resolution is ALSO wired to read this declared file — rather than carrying a separate, hardcoded copy of the same facts — the command and the lane cannot disagree; where it is not (yet) wired that way, the two can drift, and reconciling them is a workflow-authoring task this skill cannot enforce from the command side alone.
|
|
110
|
+
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.
|
|
42
111
|
|
|
43
112
|
**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.
|
|
44
113
|
|
|
45
|
-
Read one field at a time with `--field`, into a normal command substitution, spelled in full each time:
|
|
114
|
+
Read one scalar field at a time with `--field`, into a normal command substitution, spelled in full each time:
|
|
46
115
|
|
|
47
116
|
```sh
|
|
48
117
|
TAG_PREFIX=$("$PY" "<plugin-root>/hooks/_releaselib.py" show-row $TARGET --field prefix)
|
|
49
118
|
CHANGELOG=$("$PY" "<plugin-root>/hooks/_releaselib.py" show-row $TARGET --field changelog)
|
|
50
119
|
CHANGELOG_RECONCILIATIONS=$("$PY" "<plugin-root>/hooks/_releaselib.py" show-row $TARGET --field changelog-reconciliations)
|
|
51
|
-
MANIFEST=$("$PY" "<plugin-root>/hooks/_releaselib.py" show-row $TARGET --field manifest)
|
|
52
120
|
VERSION_POLICY=$("$PY" "<plugin-root>/hooks/_releaselib.py" show-row $TARGET --field version-policy)
|
|
53
121
|
INITIAL_VERSION=$("$PY" "<plugin-root>/hooks/_releaselib.py" show-row $TARGET --field initial-version)
|
|
54
122
|
RELEASE_BUILD=$("$PY" "<plugin-root>/hooks/_releaselib.py" show-row $TARGET --field release-build)
|
|
55
|
-
RELEASE_ASSETS=$("$PY" "<plugin-root>/hooks/_releaselib.py" show-row $TARGET --field release-assets)
|
|
56
123
|
VERSION_POLICY=${VERSION_POLICY:-semver}
|
|
57
124
|
```
|
|
58
125
|
|
|
59
|
-
…and so on for
|
|
126
|
+
…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.
|
|
60
127
|
|
|
61
|
-
**
|
|
128
|
+
**Traverse a multi-valued field from its line-record file**. Materialize only outside the working tree, then load each record without word splitting:
|
|
62
129
|
|
|
63
130
|
```sh
|
|
64
|
-
(
|
|
131
|
+
ARTIFACTS_FILE=$(mktemp)
|
|
132
|
+
"$PY" "<plugin-root>/hooks/_releaselib.py" list-field "$TARGET" artifacts > "$ARTIFACTS_FILE"
|
|
133
|
+
while IFS= read -r a; do git diff --quiet -- "$a" || …; done < "$ARTIFACTS_FILE"
|
|
65
134
|
```
|
|
66
135
|
|
|
67
|
-
|
|
136
|
+
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.
|
|
68
137
|
|
|
69
138
|
**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.
|
|
70
139
|
|
|
@@ -119,23 +188,25 @@ Traps worth stating rather than discovering, general to any row rather than spec
|
|
|
119
188
|
|
|
120
189
|
## Back-fill (no declared file yet)
|
|
121
190
|
|
|
122
|
-
`load_targets` raises `AbsentBlockError` when
|
|
191
|
+
`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.
|
|
123
192
|
|
|
124
193
|
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`).
|
|
125
194
|
- **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.
|
|
126
195
|
- **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.
|
|
127
196
|
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.
|
|
128
|
-
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
|
|
197
|
+
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.
|
|
198
|
+
|
|
199
|
+
**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.
|
|
129
200
|
|
|
130
|
-
**Also confirm `
|
|
201
|
+
**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.
|
|
131
202
|
|
|
132
|
-
|
|
203
|
+
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.
|
|
133
204
|
|
|
134
|
-
**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" --
|
|
205
|
+
**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.
|
|
135
206
|
|
|
136
|
-
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
|
|
207
|
+
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.
|
|
137
208
|
|
|
138
|
-
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
|
|
209
|
+
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.
|
|
139
210
|
|
|
140
211
|
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):
|
|
141
212
|
|
|
@@ -144,7 +215,7 @@ Traps worth stating rather than discovering, general to any row rather than spec
|
|
|
144
215
|
touch "$(git rev-parse --show-toplevel)/.codearbiter/.markers/release-targets-authoring"
|
|
145
216
|
```
|
|
146
217
|
|
|
147
|
-
Write the confirmed block verbatim to
|
|
218
|
+
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:
|
|
148
219
|
|
|
149
220
|
```bash
|
|
150
221
|
rm -f "$(git rev-parse --show-toplevel)/.codearbiter/.markers/release-targets-authoring"
|
|
@@ -156,21 +227,21 @@ Traps worth stating rather than discovering, general to any row rather than spec
|
|
|
156
227
|
|
|
157
228
|
## Pre-flight
|
|
158
229
|
|
|
159
|
-
**A project may declare more than one independently-versioned release target** in
|
|
230
|
+
**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.
|
|
160
231
|
|
|
161
232
|
Read these, or STOP and surface the gap — never guess:
|
|
162
233
|
|
|
163
|
-
-
|
|
234
|
+
- `$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.
|
|
164
235
|
- `$TARGET` must resolve to a declared row (see "Targets" above). An unrecognised target STOPs; do not guess which project was meant.
|
|
165
236
|
- `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.
|
|
166
237
|
|
|
167
|
-
**This governance layer's own scratch state is exempt, and only that** (HIGH, blind exercise runs 15 and 17). Two paths under
|
|
238
|
+
**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:
|
|
168
239
|
|
|
169
|
-
-
|
|
170
|
-
-
|
|
240
|
+
- `$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.
|
|
241
|
+
- `$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.
|
|
171
242
|
|
|
172
243
|
```sh
|
|
173
|
-
|
|
244
|
+
release_require_clean_tree || exit "$?"
|
|
174
245
|
```
|
|
175
246
|
|
|
176
247
|
The helper resolves the selected row and applies each scratch exclusion
|
|
@@ -184,11 +255,14 @@ Read these, or STOP and surface the gap — never guess:
|
|
|
184
255
|
**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.
|
|
185
256
|
|
|
186
257
|
A release must still not carry uncommitted work in anything it ships or asserts against, so every declared release surface stays in scope.
|
|
187
|
-
- **
|
|
258
|
+
- **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.
|
|
259
|
+
- 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.
|
|
188
260
|
- 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.
|
|
189
|
-
- **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
|
|
190
|
-
-
|
|
191
|
-
- **
|
|
261
|
+
- **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.
|
|
262
|
+
- Run `release_require_commit_path || exit "$?"` now, before any rebuild or release edit. The full `commit-gate` still owns committing the result.
|
|
263
|
+
- **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.
|
|
264
|
+
- **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.
|
|
265
|
+
- **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.
|
|
192
266
|
- **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.
|
|
193
267
|
|
|
194
268
|
**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.
|
|
@@ -197,7 +271,7 @@ Read these, or STOP and surface the gap — never guess:
|
|
|
197
271
|
|
|
198
272
|
**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.
|
|
199
273
|
|
|
200
|
-
**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
|
|
274
|
+
**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.
|
|
201
275
|
|
|
202
276
|
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.
|
|
203
277
|
|
|
@@ -215,13 +289,46 @@ Read these, or STOP and surface the gap — never guess:
|
|
|
215
289
|
|
|
216
290
|
## Phase 1 — Version & changelog · gate: BLOCK
|
|
217
291
|
|
|
292
|
+
Configuration exits 64 and 65 are never confirmation states; the read-then-record remedy above does not apply to them.
|
|
293
|
+
|
|
218
294
|
Derive the bump mechanically from the commit log; do not guess it.
|
|
219
295
|
|
|
220
|
-
0. **Resolve the one effective window before reading or classifying any commit.** Start with `EFFECTIVE_WINDOW=$WINDOW`. On a first release (`LAST_TAG=<none>`), floor the window at the adoption commit by resolving the boundary from BOTH candidate files before the first `classify-window` call: `ADOPTED=$(git log --diff-filter=A --format=%H --
|
|
221
|
-
|
|
222
|
-
|
|
296
|
+
0. **Resolve the one effective window before reading or classifying any commit.** Start with `EFFECTIVE_WINDOW=$WINDOW`. On a first release (`LAST_TAG=<none>`), floor the window at the adoption commit by resolving the boundary from BOTH candidate files before the first `classify-window` call: `ADOPTED=$(git -C "$PROJECT_ROOT" log --diff-filter=A --format=%H -- .codearbiter/CONTEXT.md .codearbiter/release-targets.md | "$PY" "<plugin-root>/hooks/_releaselib.py" adoption-commit)`. Empty output leaves `EFFECTIVE_WINDOW=HEAD`. Otherwise present `$ADOPTED` as the proposed baseline and require the user's confirmation or explicit replacement, then set `ADOPTED` to that confirmed SHA; never narrow silently. Include the confirmed boundary commit itself: if `git -C "$PROJECT_ROOT" rev-parse --verify "${ADOPTED}^" >/dev/null 2>&1`, set `EFFECTIVE_WINDOW="${ADOPTED}^..HEAD"`; when `$ADOPTED` is the root commit and has no parent, use `EFFECTIVE_WINDOW=HEAD`.
|
|
297
|
+
|
|
298
|
+
On Back-fill's own canonical first-release path, the declaration changes only the excluded `.codearbiter/` state. An empty adoption-bounded payload window is an ORDINARY outcome after fast-forward, squash, OR a normal PR merge commit. The old adoption-boundary-equals-current-HEAD condition is not a requirement: a normal merge retains the declaration as an ancestor distinct from `HEAD`, and later non-payload commits also change `HEAD` without adding release content. When the confirmed adoption is a verified ancestor and its narrowed payload window is empty, the documented default is to propose `EFFECTIVE_WINDOW=HEAD`. It is presented for the operator's confirm-or-replace choice, never applied silently. State the scope change and the published reconciliation requirement before asking; a long history can still produce a 500-line footer-completeness report. A declined proposal STOPs without changing the window. This one confirmed value then governs emptiness, classification, bump derivation, and changelog harvesting.
|
|
299
|
+
1. Reload `$PAYLOAD_PATHS_FILE` into positional parameters with the sanctioned `while IFS= read -r pathspec` loop. The underlying payload read is `git -C "$PROJECT_ROOT" log "$EFFECTIVE_WINDOW" --format=%H -- "$@"`; evaluate its output and exit status through the function below, never by empty stdout alone. Before treating empty output here as a genuine STOP, check step 0's documented default shape through this executable read-only decision. It checks the confirmed adoption's reachability rather than equality with `HEAD`, propagates failed Git reads, and never changes `$EFFECTIVE_WINDOW`:
|
|
300
|
+
|
|
301
|
+
```sh
|
|
302
|
+
release_window_state() (
|
|
303
|
+
if [ "$#" -eq 0 ]; then
|
|
304
|
+
printf 'STOP — no declared payload pathspecs were supplied.\n' >&2
|
|
305
|
+
exit 1
|
|
306
|
+
fi
|
|
307
|
+
if [ "$LAST_TAG" = "<none>" ] && [ -n "${ADOPTED:-}" ]; then
|
|
308
|
+
git -C "$PROJECT_ROOT" merge-base --is-ancestor -- "$ADOPTED" HEAD || {
|
|
309
|
+
printf 'STOP — the confirmed adoption boundary is not a verified ancestor of HEAD.\n' >&2
|
|
310
|
+
exit 1
|
|
311
|
+
}
|
|
312
|
+
fi
|
|
313
|
+
window_commits=$(git -C "$PROJECT_ROOT" log "$EFFECTIVE_WINDOW" --format=%H -- "$@") || {
|
|
314
|
+
printf 'STOP — could not read the release window; failed inspection is not empty history.\n' >&2
|
|
315
|
+
exit 1
|
|
316
|
+
}
|
|
317
|
+
if [ -n "$window_commits" ]; then
|
|
318
|
+
printf 'ready\n'
|
|
319
|
+
elif [ "$LAST_TAG" = "<none>" ] && [ -n "${ADOPTED:-}" ] && [ "$EFFECTIVE_WINDOW" != "HEAD" ]; then
|
|
320
|
+
printf 'confirm-first-release-window\n'
|
|
321
|
+
else
|
|
322
|
+
printf 'empty\n'
|
|
323
|
+
fi
|
|
324
|
+
)
|
|
325
|
+
WINDOW_STATE=$(release_window_state "$@") || exit "$?"
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
`ready` proceeds with the existing window. `confirm-first-release-window` is a STOP for the explicit confirm-or-replace choice in step 0, not permission to write, classify, or widen. Only after that choice is actually supplied, set `$EFFECTIVE_WINDOW` to it and rerun the same decision with the same payload arguments. Require `ready` after that one recheck; do not loop through additional widening proposals. `empty`, a declined proposal, or a non-ready confirmed recheck STOPs as nothing to release for `$TARGET`. Any nonzero exit STOPs as failed inspection, never as empty history. Tagged-release windows never take the first-release widening route. Then read every commit in that same payload-scoped window: `git -C "$PROJECT_ROOT" log "$EFFECTIVE_WINDOW" --format=%H%x00%s%x00%b%x00 -- "$@"`.
|
|
329
|
+
2. **Classify the effective window through the tested helper, not by hand:** reload the pathspec positional parameters, then run `git -C "$PROJECT_ROOT" log "$EFFECTIVE_WINDOW" --format=%H%x00%s%x00%b%x00 -- "$@" | "$PY" "<plugin-root>/hooks/_releaselib.py" classify-window "$TARGET" "$DEFAULT_BRANCH"`. NUL framing is load-bearing: Git commit messages cannot contain NUL, while any printable delimiter can appear in a body and hide a breaking footer. It prints the derived bump on the first line, then one `[CLASSIFIED]` JSON object per commit carrying the authoritative SHA, subject, type, scope, breaking flag, bump, exact `CHANGELOG:` text, and reconciliation flag. It additionally prints one `[RECONCILED] <short-sha> <subject> :: <changelog>` line per accepted published reconciliation and one `[NEEDS-TRIAGE] <short-sha> <subject>` line per remaining bumping commit missing a unique non-empty terminal `CHANGELOG:` footer. Preserve the `[CLASSIFIED]` rows as the only input to step 5's grouping and report; never reparse the commit bodies. **Exit 0** clean; **exit 1** at least one footer remains missing (step 3's BLOCK); **exit 2** the whole window is non-bumping (the STOP below); **exit 4** the input framing, declared reconciliation ledger, or Git ancestry proof failed closed. It classifies and reports; the decision stays here, in this skill.
|
|
223
330
|
|
|
224
|
-
The optional ledger is used only when `$TARGET` explicitly declares `$CHANGELOG_RECONCILIATIONS`. The helper constructs and resolves the exact `refs/remotes/origin/$DEFAULT_BRANCH` identity itself, reads the declared ledger only as an exact regular-file blob from that fetched commit, validates the whole JSON document, selects only entries naming this target, and requires an exact lowercase 40-
|
|
331
|
+
The optional ledger is used only when `$TARGET` explicitly declares `$CHANGELOG_RECONCILIATIONS`. The helper constructs and resolves the exact `refs/remotes/origin/$DEFAULT_BRANCH` identity itself, reads the declared ledger only as an exact regular-file blob from that fetched commit, validates the whole JSON document, selects only entries naming this target, and requires an exact lowercase full Git object ID (40 hexadecimal characters for SHA-1 or 64 for SHA-256) plus single-line note/reason/authorization fields. It accepts a matching row only when trusted Git proves that exact commit is an ancestor of the same fetched default-branch commit. A live-only, branch-only, ignored, untracked, malformed, missing, escaping, symlinked, duplicate, wrong-target, short-SHA, mismatched, or unpublished entry never clears the footer gate. The ledger supplies only the deliberately reviewed changelog text for the identified historical commit; the commit's type still determines the bump and changelog group.
|
|
225
332
|
|
|
226
333
|
Hand-rolling this parse is how it goes wrong, and not hypothetically (HIGH-adjacent, adversarial review 2026-07-31, run 11): an exercising agent wrote `subject.split('(')[0].split(':')[0].rstrip('!')`, which strips the breaking marker *before* anything checks for it — so `feat!:` classified as a minor, `feat(api)!:` the same, and `chore!:` as no release at all. A breaking change ships as a minor, or does not ship. Two operators writing two parses produce two different gates on the check that decides whether a release may proceed, which is not a gate.
|
|
227
334
|
|
|
@@ -232,8 +339,8 @@ Derive the bump mechanically from the commit log; do not guess it.
|
|
|
232
339
|
- `test` / `docs` / `chore` / `ci` only → no bump. If the whole window is non-bumping, STOP — there is nothing to release.
|
|
233
340
|
|
|
234
341
|
**For the footer-completeness rule below, an accepted `[RECONCILED]` row counts as footer-complete.** That is not an auto-fill or a relaxation for ordinary missing footers: it is an explicit repository declaration bound to one exact commit already on the freshly fetched default branch, and every unlisted or unprovable commit remains `[NEEDS-TRIAGE]`. An unpublished commit must be amended or rebased instead and MUST NOT be added to the ledger. A reconciliation for a published commit lands through a normal reviewed commit, after which this lane restarts so its clean-tree and fresh-origin gates cover the policy change. When step 5 composes the changelog, it uses the exact helper-printed reconciliation text in the group selected by the commit's unchanged type; it does not reword or re-derive the note.
|
|
235
|
-
3. **Verify footer completeness for the already-established `$EFFECTIVE_WINDOW` before touching anything else — never after.** On a first release, step 0 has already proposed and confirmed the adoption floor before classification; do not classify the raw full-history `$WINDOW` first and then try to narrow it here. The sole sanctioned exception is Back-fill's own canonical first-release shape (step 0's note, step 1's check): there, `$EFFECTIVE_WINDOW` may still be substituted to `HEAD` at step 1, but only through that step's OWN presented confirm-or-replace gate, before this step ever runs — a confirmed widen at the documented point of use, not an unconfirmed narrowing performed HERE. `adoption-commit` takes the oldest line of its newest-first input, so considering both candidate files identifies whichever adoption happened first. The confirmed boundary commit itself remains included, including when it is the root commit. Over that effective window, every `feat`/`fix`/`perf`/`refactor` commit (the full harvested set step 5 rolls into the changelog, not just `feat`/`fix`) MUST carry a `CHANGELOG:` footer. **This obligation is wider than those four types, regardless of type: any commit `classify_commit` marks `breaking` — an unfootered `chore!:`, `docs!:`, `test!:`, or `ci!:` included — is caught here too**, because `classify_window`'s `missing_footer` set keys on each row's own `bump != "none"`, not a fixed type list, so a footer-incomplete breaking commit of any type is already BLOCKed at this step, before step 5 ever composes a changelog. **A missing footer on any one of them is a BLOCK**, not a soft finding: surface EVERY offending commit as its own `[NEEDS-TRIAGE]` line in the release report presented to the user (one line per commit missing the footer, `<short-sha> <subject>` — never a single collapsed `[NEEDS-TRIAGE]` when more than one commit is at fault, and never written to any file: the report is its only destination) and STOP here — never auto-fill it, and never tag a changelog that silently drops a user-visible change. **The remedy is the operator's to choose:** first check whether the commit is already published with `git merge-base --is-ancestor <sha> origin/$DEFAULT_BRANCH`. An unpublished commit may be amended or rebased; a published one must not be rewritten or force-pushed. If a listed commit genuinely has nothing user-facing to say, reclassify its type instead of leaving it half-classified. Never invent footer text on the author's behalf. Checking before step 4 bumps any file means a blocked release leaves no partial manifest bump. A `CHANGELOG:` footer on a `test`/`docs`/`chore`/`ci` commit is harvested by step 5 rather than silently discarded, but does not by itself create a bump.
|
|
236
|
-
4. **Apply the step-2 classification to `$BASE_VERSION` through the policy-aware helper, never by eye.** Capture step 2's first line verbatim as `$BUMP`,
|
|
342
|
+
3. **Verify footer completeness for the already-established `$EFFECTIVE_WINDOW` before touching anything else — never after.** On a first release, step 0 has already proposed and confirmed the adoption floor before classification; do not classify the raw full-history `$WINDOW` first and then try to narrow it here. The sole sanctioned exception is Back-fill's own canonical first-release shape (step 0's note, step 1's check): there, `$EFFECTIVE_WINDOW` may still be substituted to `HEAD` at step 1, but only through that step's OWN presented confirm-or-replace gate, before this step ever runs — a confirmed widen at the documented point of use, not an unconfirmed narrowing performed HERE. `adoption-commit` takes the oldest line of its newest-first input, so considering both candidate files identifies whichever adoption happened first. The confirmed boundary commit itself remains included, including when it is the root commit. Over that effective window, every `feat`/`fix`/`perf`/`refactor` commit (the full harvested set step 5 rolls into the changelog, not just `feat`/`fix`) MUST carry a `CHANGELOG:` footer. **This obligation is wider than those four types, regardless of type: any commit `classify_commit` marks `breaking` — an unfootered `chore!:`, `docs!:`, `test!:`, or `ci!:` included — is caught here too**, because `classify_window`'s `missing_footer` set keys on each row's own `bump != "none"`, not a fixed type list, so a footer-incomplete breaking commit of any type is already BLOCKed at this step, before step 5 ever composes a changelog. **A missing footer on any one of them is a BLOCK**, not a soft finding: surface EVERY offending commit as its own `[NEEDS-TRIAGE]` line in the release report presented to the user (one line per commit missing the footer, `<short-sha> <subject>` — never a single collapsed `[NEEDS-TRIAGE]` when more than one commit is at fault, and never written to any file: the report is its only destination) and STOP here — never auto-fill it, and never tag a changelog that silently drops a user-visible change. **The remedy is the operator's to choose:** first check whether the commit is already published with `git -C "$PROJECT_ROOT" merge-base --is-ancestor <sha> "origin/$DEFAULT_BRANCH"`. An unpublished commit may be amended or rebased; a published one must not be rewritten or force-pushed. If a listed commit genuinely has nothing user-facing to say, reclassify its type instead of leaving it half-classified. Never invent footer text on the author's behalf. Checking before step 4 bumps any file means a blocked release leaves no partial manifest bump. A `CHANGELOG:` footer on a `test`/`docs`/`chore`/`ci` commit is harvested by step 5 rather than silently discarded, but does not by itself create a bump.
|
|
343
|
+
4. **Apply the step-2 classification to `$BASE_VERSION` through the policy-aware helper, never by eye.** Capture step 2's first line verbatim as `$BUMP`. For `LAST_TAG=<none>` with the numeric-sequence manifest exactly at `$INITIAL_VERSION`, run `DERIVATION_BASE="<none>"; COMPARISON_BASE="<none>"`; otherwise run `DERIVATION_BASE=$BASE_VERSION; COMPARISON_BASE=$BASE_VERSION`. Then run `VERSION=$("$PY" "<plugin-root>/hooks/_releaselib.py" derive-version "$DERIVATION_BASE" "$BUMP" "$VERSION_POLICY" "$INITIAL_VERSION")` and set `RELEASE_TAG=${TAG_PREFIX}${VERSION}` once for every later tag, notes, asset, and publication command. This preserves SemVer's patch/minor/major arithmetic while `numeric-sequence` advances its final component exactly once for any bumping classification; `none`, an unknown policy, or an invalid base STOPs. Confirm the result mechanically with `"$PY" "<plugin-root>/hooks/_releaselib.py" version-greater "$VERSION" "$COMPARISON_BASE" "$VERSION_POLICY" "$INITIAL_VERSION"`. The helper treats `<none>` as the empty numeric sequence only for this first-release comparison. Exit 1 means equal or lower; exit 2 means policy-invalid input. For compatibility, this is the policy-general replacement for the legacy `semver-greater <derived> $BASE_VERSION` check and keeps its remedy: re-derive from `$BASE_VERSION` rather than raising the bump. `$BASE_VERSION` already accounts for the tag and every manifest, including the first-release sentinel case. Bringing every manifest to the derived version still happens once in step 6; describing that write here too would be one action described two ways. Present the version and per-commit classification to the user for confirmation.
|
|
237
344
|
5. Derive the release date **once** — `RELEASE_DATE=$(date +%F)` — and reuse that single value for the changelog header, the Phase-2 `Released-at:` footer, and the Phase-3 Release; never hand-type the date a second time (`release_dates_consistent` verifies the changelog-header date equals the `Released-at:` date). Roll the `CHANGELOG:` footers from each `feat` / `fix` / `perf` / `refactor` commit into a new `## [${VERSION}] — $RELEASE_DATE` section in `$CHANGELOG` (the Keep-a-Changelog bracket heading this guard matches, not the bare `v`-prefixed form), grouped Added (`feat`) / Fixed (`fix`) / Performance (`perf`) / Changed (`refactor`, matching this repository's own changelog convention for non-user-facing-but-notable work). **A commit whose classification marks `breaking` true — the `!` bang after the type/scope or a `BREAKING CHANGE:`/`BREAKING-CHANGE:` footer, exactly the flag `classify-window` already computes and never re-derived here — rolls into its own `### Breaking` group instead of its type group, placed first among the section's groups.** A `feat!` no longer lands under `### Added` indistinguishable from an ordinary feature, and a breaking `chore!:` (which has no type group of its own) still gets one; an ordinary nonbreaking commit is never placed in `### Breaking`, and a commit already in `### Breaking` is never also duplicated into its type group. This is the same composed section text Phase 2's `<message-file>` and Phase 3's `<Phase-1 section file>` both read back (below), so `### Breaking` is visible in the changelog, the tag message, and the Release notes without a second composition pass. **Also harvest a `CHANGELOG:` footer from any `test`/`docs`/`chore`/`ci` commit that carries one**, into the same section's Changed group: its type says "not user-visible" but its footer is the more specific, deliberately-authored signal, and dropping it because of the type would be exactly the silent discard this guard exists to prevent (measured against this repository's own history, where such footers are a recurring, intentional pattern, not a mistake to reject). This harvesting rule does not by itself force a release — if the whole window is non-bumping, step 2's STOP still applies even when a non-bumping commit in it carries a footer; widening what a bare footer alone can trigger is out of scope here. Prior sections stay intact. Create the file with a `# Changelog` heading if absent. The changelog is a user-facing deliverable: apply `<plugin-root>/includes/anti-slop-design/core.md` §3.A (no prose-separator em-dashes in the entry prose) and §3.B (copy self-audit), and `<plugin-root>/includes/anti-slop-design/medium-documents.md` §7.A.1 changelog guidance, to each rolled entry. **Home for the composed section (MEDIUM, adversarial review 2026-07-31, previously unspecified):** besides landing in `$CHANGELOG` itself, this same section text is what Phase 2's `<message-file>` and Phase 3's `<Phase-1 section file>` both read back — write it to a scratch file created OUTSIDE the working tree (e.g. `mktemp`), never anywhere under the repo. A copy left inside the tree would either dirty the clean-tree state Pre-flight already required (this skill never re-runs Pre-flight mid-phase to notice) or, under a `payload: .` row, be swept straight into the very release window it is composing. Discard the scratch file once Phase 3 no longer needs it; it is working state, not a deliverable. **This scratch file is not required to survive across a session boundary** (HIGH, blind exercise run 19): `resume_publish` below explicitly permits Phase 3 to run in a LATER invocation than the one that composed this section — by design, since a fresh publish and a resumed one share the same Phase-3 authorization gate — and by then a `mktemp` file from a prior session is normally already gone. Phase 3 step 2 does not assume it survived; it reconstructs the same text mechanically from `$CHANGELOG` instead.
|
|
238
345
|
6. **Sync `$TARGET`'s release surfaces to the repo — mechanically derived, never typed.** In this order; each sub-step depends on the one before it.
|
|
239
346
|
|
|
@@ -243,7 +350,7 @@ Derive the bump mechanically from the commit log; do not guess it.
|
|
|
243
350
|
|
|
244
351
|
**This assertion runs AFTER 6a, never before it.** The two used to sit in the opposite order in this step's prose, so an agent following it top-to-bottom ran the equality guard against manifests it had not updated yet and got a failure the lane had itself caused.
|
|
245
352
|
|
|
246
|
-
**6c.
|
|
353
|
+
**6c. Re-check every declared executable release input.** `"$PY" "<plugin-root>/hooks/releasehash.py" check $TARGET` must exit 0. The confirmation digest binds the ordered `pre-tag` list plus the optional `release-build`, `rebuild`, and `generate`; changing any one invalidates the same per-target confirmation. Pre-flight performed the first check before `rebuild` could execute, and this repeat catches declaration drift before `generate`, `pre-tag`, or a retained cohort is trusted. These values are operator-authored shell; a retained-cohort sentinel is protected operator-authored policy this lane must verify and report without executing. Exit 1 means one changed since the last confirmation and exit 2 means the row's executable inputs have never been confirmed; resolve either only after reading every `pre-tag`, `release-build`, `rebuild`, and `generate` value, then run `"$PY" "<plugin-root>/hooks/releasehash.py" record $TARGET`. **Exit 64 (malformed CLI usage) and exit 65 (an unrecognised `$TARGET`) are configuration failures, not confirmation states** — fix the invocation or declared target and re-run `check`; neither writes a marker. Recording without reading converts the gate into a rubber stamp. A row declaring none of the four executable inputs reports `no-commands` and exits 0, deliberately distinct from `confirmed`; a row declaring any one alone still requires confirmation.
|
|
247
354
|
|
|
248
355
|
**6d. Run them.** `$PRE_TAG` is the row's declared `pre-tag` commands (DECISION-0034: check-only, never a fixer). **Run them through the shipped runner, not by hand:** `"$PY" "<plugin-root>/hooks/_releaselib.py" run-pre-tag $TARGET` must exit 0. It executes them in declared order, stops at the first non-zero exit, and asserts a clean tree after each one — the three rules this step used to state as prose for an agent to remember and apply in the right order. Each declared command runs with `PY` set in its own environment to this runner's own resolved interpreter, so a row may portably spell `"$PY"` instead of a hardcoded interpreter (see "Targets" above). Its exit codes are distinguishable: **5** a command RAN and reported drift, **6** a command exited 0 but MUTATED the tree — the check-only rule broken by the declaration itself, which is the case the tree assertion exists for — **7** a command's interpreter or program itself could not be located or executed at all, **8** the tree-state PROBE itself failed, and **9** no POSIX-compatible shell could be resolved to dispatch a declared row on Windows, so NO command ran at all (#602 — distinct from 7, which names a specific command's own interpreter as unresolvable). The assertion is "this command changed nothing NEW", snapshotted before the first command and compared after each one, so this step deliberately runs AFTER step 5's changelog roll and the manifest bump: a badge or catalog check compares a surface against the NEW version and would pass vacuously against the old one. It reports every path a command added, edited, **or reverted**, and it runs every command in the project root rather than whatever directory the caller happened to be in.
|
|
249
356
|
|
|
@@ -252,9 +359,9 @@ Derive the bump mechanically from the commit log; do not guess it.
|
|
|
252
359
|
**On exit 7, this is NOT drift — do not apply exit 5's remedy** (#585 MEDIUM-2 / #584 MEDIUM-1: "could not run" is never "ran and disagreed"). The command's interpreter or program itself could not be located, so it never actually ran and nothing was checked at all — a missing interpreter is not a policy violation to reconcile. Fix the interpreter this row names for THIS host and re-run; when the command is Python, spell the declaration with the already-resolved `"$PY"` on every supported host instead of hardcoding `python` or `python3`. No release-edit discard is needed here, unlike exit 5: nothing was checked, so there is nothing to undo.
|
|
253
360
|
|
|
254
361
|
**On exit 8, the probe failed, not a command** — surface it and investigate the tree-state probe itself (not a git repository, an unreadable `.git`, or similar) before re-running; no verdict about any declared command exists yet, so neither exit 5's nor exit 6's remedy applies.
|
|
255
|
-
7. **Commit the release edits before leaving this phase — this is not conditional** (HIGH, adversarial review 2026-07-31, run 10). Steps 5 and 6 rolled `$CHANGELOG` and bumped every path in `$MANIFEST`; those edits are uncommitted, and `git tag` in Phase 2 names a COMMIT, not the working tree. Tagging with them outstanding produces a tag whose payload still carries the OLD version and no new changelog section, while its own message quotes the section that never shipped — and every guard in the lane passes, because `dates-match` compares two scratch files and `notes-match` reads only a heading, so none of them looks at the tagged tree at all. This step previously read "*if* the changelog edit … *needs* to land as a commit", which a literal traversal answers "no". Route the commit through `commit-gate`; do not reimplement the commit path here. Pre-flight above already confirmed `commit-gate`'s prerequisites and branch restriction are met before this phase's writes began; this step does not re-check them. Then assert the tree is clean before Phase 2 with `
|
|
362
|
+
7. **Commit the release edits before leaving this phase — this is not conditional** (HIGH, adversarial review 2026-07-31, run 10). Steps 5 and 6 rolled `$CHANGELOG` and bumped every path in `$MANIFEST`; those edits are uncommitted, and `git tag` in Phase 2 names a COMMIT, not the working tree. Tagging with them outstanding produces a tag whose payload still carries the OLD version and no new changelog section, while its own message quotes the section that never shipped — and every guard in the lane passes, because `dates-match` compares two scratch files and `notes-match` reads only a heading, so none of them looks at the tagged tree at all. This step previously read "*if* the changelog edit … *needs* to land as a commit", which a literal traversal answers "no". Route the commit through `commit-gate`; do not reimplement the commit path here. Pre-flight above already confirmed `commit-gate`'s prerequisites and branch restriction are met before this phase's writes began; this step does not re-check them. Then assert the tree is clean before Phase 2 with `release_require_clean_tree || exit "$?"`; the underlying probe must print nothing AND exit 0. A nonzero probe exits this invocation before Phase 2. The target-aware helper exempts governance scratch only when the selected row does not declare it as a release surface. This step is reached only AFTER steps 5 and 6a have written the changelog and every declared manifest, and each of those writes can fire the hook that appends to the audit log. `commit-gate` stages by explicit path and leaves unrelated governance scratch out of the release commit, while a target that actually ships or asserts against that scratch still sees it and BLOCKs.
|
|
256
363
|
|
|
257
|
-
8. **Resolve declared publication assets without pretending local Phase 1 owns hosted package evidence.** This runs only after the release edits are committed and every existing check above has passed. Set `RELEASE_TAG=${TAG_PREFIX}${VERSION}` and create `RELEASE_ASSET_NAMES_FILE=$(mktemp)` outside the working tree.
|
|
364
|
+
8. **Resolve declared publication assets without pretending local Phase 1 owns hosted package evidence.** This runs only after the release edits are committed and every existing check above has passed. Set `RELEASE_TAG=${TAG_PREFIX}${VERSION}` and create `RELEASE_ASSET_TEMPLATES_FILE=$(mktemp)` and `RELEASE_ASSET_NAMES_FILE=$(mktemp)` outside the working tree. Materialize the templates with `list-field "$TARGET" release-assets`, then load them as exact positional arguments: `set --; while IFS= read -r asset; do set -- "$@" "$asset"; done < "$RELEASE_ASSET_TEMPLATES_FILE"`. Run `"$PY" "<plugin-root>/hooks/_releaselib.py" render-release-assets "$VERSION" "$RELEASE_TAG" "$@" > "$RELEASE_ASSET_NAMES_FILE"`, and STOP unless it succeeds. Report the exact names and the declared `$RELEASE_BUILD`, but do not execute that command in the interactive checkout: the mandatory PR boundary means local output cannot become publication input, and a repository may deliberately reserve package assembly for its protected exact-head CI cohort. The qualifying hosted publisher MUST declare and enforce one reviewed asset path before the release PR merges: either it executes `$RELEASE_BUILD` in its exact candidate checkout and verifies the resulting inventory, or it downloads and cryptographically/read-back verifies a retained exact-head CI package cohort bound to the same source commit and declared filenames. An undeclared choice, an arbitrary ignored build failure, or a retained artifact not bound to this candidate STOPs. If the row declares no asset contract, skip this step explicitly.
|
|
258
365
|
|
|
259
366
|
Use this exact argument-preserving function for that final verification and reuse it for Phase 3's pre-push recheck:
|
|
260
367
|
|
|
@@ -285,9 +392,11 @@ head or a pre-merge release commit.
|
|
|
285
392
|
|
|
286
393
|
The declared `$RELEASE_BUILD` and `$RELEASE_ASSETS` are listed in the report but the release build is never executed and no asset directory is created. Like `rebuild`, `generate`, and `pre-tag`, it is protected operator-authored executable input whose normal purpose is to write output, so executing it would violate the dry-run contract.
|
|
287
394
|
|
|
288
|
-
`--dry-run` runs
|
|
289
|
-
|
|
290
|
-
|
|
395
|
+
`--dry-run` runs the read-only, locally evaluable portion of Pre-flight. It checks the declared row,
|
|
396
|
+
tree, branch, commit-path prerequisites, current local tag/default-branch refs, manifest floor, and
|
|
397
|
+
window, but it does not execute `commit-gate` or any declared command; do not run either `git fetch` command. Therefore
|
|
398
|
+
it does not claim a suite verdict, remote-ref freshness, artifact freshness, or post-bump `pre-tag`
|
|
399
|
+
verdict. Those remain real-run gates and are listed as unexecuted limitations in the report. The
|
|
291
400
|
`$ARTIFACTS` freshness bullet's own text now carves itself out under `--dry-run`, for the same reason
|
|
292
401
|
`$PRE_TAG` is listed rather than run below — both are declared commands this lane EXECUTES, and a dry
|
|
293
402
|
run's contract ("nothing is written") cannot coexist with executing a command whose job is to overwrite
|
|
@@ -302,8 +411,9 @@ run, no path in `$MANIFEST` or `$CHANGELOG` changes, no commit, no tag.
|
|
|
302
411
|
the version AFTER `$MANIFEST` has been bumped — 6d's own text: "a badge or catalog check compares a
|
|
303
412
|
surface against the NEW version and would pass vacuously against the old one." A dry run never bumps
|
|
304
413
|
anything, so running them here would report the SAME false failure 6d's ordering already exists to
|
|
305
|
-
prevent, not a preview of anything a real run would actually see. Print
|
|
306
|
-
$TARGET
|
|
414
|
+
prevent, not a preview of anything a real run would actually see. Print the exact ordered records from
|
|
415
|
+
`list-field "$TARGET" pre-tag` as a name-only list instead; never use the comma-flattened `show-row`
|
|
416
|
+
compatibility value for this report.
|
|
307
417
|
|
|
308
418
|
**Declared `rebuild`/`generate` commands are printed by name, not run** (HIGH, blind exercise run
|
|
309
419
|
19). Pre-flight's `$ARTIFACTS` freshness bullet above EXECUTES the row's declared `rebuild` command
|
|
@@ -316,23 +426,26 @@ $TARGET --field rebuild`/`--field artifacts`/`--field generate`) as name-only li
|
|
|
316
426
|
`$PRE_TAG` is printed above — a dry run reports what WOULD be checked, never checks it by running the
|
|
317
427
|
command that would mutate the tree to do so.
|
|
318
428
|
|
|
319
|
-
**Report:** `$TARGET`, the resolved row (every field `show-row` prints, verbatim — this doubles as a
|
|
429
|
+
**Report:** `$TARGET`, the resolved row (every declared field `show-row` prints, verbatim — this doubles as a
|
|
320
430
|
way to validate a freshly authored `release-targets.md` without tagging anything), the derived
|
|
321
431
|
version and step 2's bump rationale, the per-commit classification, step 3's footer-completeness
|
|
322
432
|
result, and the declared `pre-tag` list from the paragraph above. State plainly that this was a dry
|
|
323
|
-
run
|
|
433
|
+
run, nothing was written, the preview used current local refs without fetching, and suite/artifact/
|
|
434
|
+
post-bump checks were not executed.
|
|
324
435
|
|
|
325
|
-
**Interaction with Back-fill:** when
|
|
436
|
+
**Interaction with Back-fill:** when `$PROJECT_ROOT/.codearbiter/release-targets.md` is genuinely
|
|
326
437
|
absent, `--dry-run` still runs the Back-fill lane's Detect and Present steps (1–2) — they already
|
|
327
438
|
print a proposed block and write nothing on their own. It STOPs there: never seek the explicit
|
|
328
439
|
confirmation step 3 requires, and never persist. Report the proposed block instead, exactly as
|
|
329
440
|
Detect printed it, labeled as a dry-run preview.
|
|
330
441
|
|
|
331
|
-
Gate: every Pre-flight STOP condition
|
|
442
|
+
Gate: every read-only Pre-flight STOP condition that can be evaluated from current local state still applies;
|
|
443
|
+
the two fetches, `commit-gate`, and Pre-flight's `$ARTIFACTS` freshness EXECUTION (the
|
|
332
444
|
`rebuild`/`generate` commands, as opposed to merely listing them) does not run; steps 5 through 8 of
|
|
333
445
|
Phase 1 and all of Phase 2 and Phase 3 are skipped, not merely unauthorized; the report above is
|
|
334
|
-
delivered. MUST NOT write, commit, or tag anything under `--dry-run` — this
|
|
335
|
-
executing a declared `rebuild` or `generate` command, not only
|
|
446
|
+
delivered. MUST NOT write, commit, or tag anything under `--dry-run` — this explicitly includes not
|
|
447
|
+
executing either `git fetch`, `commit-gate`, or a declared `rebuild` or `generate` command, not only
|
|
448
|
+
`$MANIFEST`/`$CHANGELOG`/the tag.
|
|
336
449
|
|
|
337
450
|
## Phase 2 — Tag & report · gate: BLOCK
|
|
338
451
|
|
|
@@ -344,25 +457,37 @@ qualifying hosted publisher STOPs before tag composition; it must add and review
|
|
|
344
457
|
a separate PR rather than weakening this release. The interactive agent may authorize, dispatch, or
|
|
345
458
|
observe that publisher, but MUST NOT execute the tag-composition commands below locally.
|
|
346
459
|
|
|
347
|
-
All version-sensitive helper calls use the declared policy. In particular, invoke `notes-match $
|
|
460
|
+
All version-sensitive helper calls use the declared policy. In particular, invoke `notes-match "$RELEASE_TAG" <stored-file> "$VERSION_POLICY" "$INITIAL_VERSION"`; any shorter `notes-match` spelling below documents the retained SemVer-compatible CLI arity, not the command for this resolved row.
|
|
348
461
|
|
|
349
462
|
Phase 2's remaining work is twelve named substeps, in this fixed order (#623/T-17: the former 9,324-character single item is replaced, not duplicated alongside these). Each substep's number is a stable name for T-18's per-substep structural tests and the campaign invariant map — do not renumber them independently of that map.
|
|
350
463
|
|
|
351
464
|
### 2.0 Bind the hosted candidate to the release-surface commit
|
|
352
465
|
|
|
466
|
+
The changelog last-touch check below is preliminary, not sufficient by itself: a later commit can touch only the changelog preamble while retaining an older release section. Step 2.1 therefore proves that the selected version section itself is absent from, or byte-different in, `$HOSTED_HEAD^1` before this cohort may continue.
|
|
467
|
+
|
|
353
468
|
Not merely to whatever default-branch `HEAD` exists when the job starts (HIGH, blind exercise runs 26 and 27). Set `HOSTED_HEAD=$(git rev-parse HEAD)`. Require `git log --first-parent -1 --format=%H -- "$CHANGELOG"` to equal `$HOSTED_HEAD`. `--first-parent` is load-bearing: an ordinary no-fast-forward release PR merge owns its tree result even though Git's default path-history simplification reports the side-branch commit that last touched the file. Every declared manifest and companion-manifest path must exist in `$HOSTED_HEAD`; resolve each exact candidate blob with `git rev-parse "$HOSTED_HEAD:$SURFACE"` and require `git hash-object "$SURFACE"` to equal it before reading the version. Do not require an unchanged manifest's last-touch commit to equal `$HOSTED_HEAD`: a correction cohort can legitimately authorize publication of an already-bumped, still-unpublished version without rewriting identical JSON. The current changelog advance plus exact manifest version and blob identities form the candidate receipt. A later default-branch commit therefore cannot reuse the older changelog authorization, and altered or substituted manifest bytes fail closed; either case STOPs and restarts Phase 1. This binding is in addition to, not a substitute for, green exact-head CI and `HEAD == origin/$DEFAULT_BRANCH`.
|
|
354
469
|
|
|
470
|
+
After binding those exact blobs, reassert their semantic version as hosted evidence:
|
|
471
|
+
`"$PY" "<plugin-root>/hooks/_releaselib.py" check-manifests "$TARGET" "$VERSION"`
|
|
472
|
+
must exit 0 before any publish-state classification. Exit 1 means at least one
|
|
473
|
+
declared manifest disagrees and exit 2 means at least one cannot be parsed; both
|
|
474
|
+
STOP. Phase 1's result is not reusable here because merge resolution or a later
|
|
475
|
+
candidate update can change a secondary manifest while preserving the first one,
|
|
476
|
+
and `classify` deliberately short-circuits to `publish_fresh` when no tag exists.
|
|
477
|
+
|
|
355
478
|
### 2.1 Reconstruct Phase 1's section in the hosted checkout
|
|
356
479
|
|
|
357
|
-
Before composing the tag (HIGH, blind exercise run 25). The mandatory PR boundary means Phase 1's external `mktemp` file and shell variables are not inputs Phase 2 can possess. Create a fresh `PHASE2_SECTION_FILE=$(mktemp)` and run `"$PY" "<plugin-root>/hooks/_releaselib.py" changelog-section "
|
|
480
|
+
Before composing the tag (HIGH, blind exercise run 25). The mandatory PR boundary means Phase 1's external `mktemp` file and shell variables are not inputs Phase 2 can possess. Create a fresh `PHASE2_SECTION_FILE=$(mktemp)` and run `"$PY" "<plugin-root>/hooks/_releaselib.py" changelog-section "$PROJECT_ROOT" "$HOSTED_HEAD" "$CHANGELOG" "$VERSION" "$VERSION_POLICY" "$INITIAL_VERSION" > "$PHASE2_SECTION_FILE"`; then require `notes-match "$RELEASE_TAG" "$PHASE2_SECTION_FILE" "$VERSION_POLICY" "$INITIAL_VERSION"`. `$HOSTED_HEAD` is the already-bound full candidate commit object, never a mutable revision expression. Derive `RELEASE_DATE` mechanically from that validated section's first heading with `sed -n '1s/.* \([0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]\)$/\1/p'`, STOP if it is empty, compose the tag message from this exact section plus `Released-at: $RELEASE_DATE`, and require `dates-match "$PHASE2_SECTION_FILE" <message-file> "$VERSION_POLICY" "$INITIAL_VERSION"`. This is reconstruction from the exact merged commit, not re-derivation from memory; `<Phase-1 section file>` below means this `PHASE2_SECTION_FILE`, never the vanished Phase-1 scratch path.
|
|
481
|
+
|
|
482
|
+
After validating `$PHASE2_SECTION_FILE`, prove that this release section was introduced or changed by the hosted cohort. Resolve `HOSTED_PARENT=$(git rev-parse "$HOSTED_HEAD^1" 2>/dev/null) || { printf 'STOP — hosted parent unavailable.\n' >&2; exit 1; }`. Phase 2 follows a merged release PR, so a root commit or shallow checkout cannot qualify. Create `PARENT_SECTION_FILE=$(mktemp)` and run `PARENT_SECTION_STATUS=0; "$PY" "<plugin-root>/hooks/_releaselib.py" changelog-section "$PROJECT_ROOT" "$HOSTED_PARENT" "$CHANGELOG" "$VERSION" "$VERSION_POLICY" "$INITIAL_VERSION" --allow-absent-path > "$PARENT_SECTION_FILE" || PARENT_SECTION_STATUS=$?`. Status 1 means the parent has no changelog path or no section for this version. Status 0 requires `cmp -s "$PARENT_SECTION_FILE" "$PHASE2_SECTION_FILE"` to exit 1; an equal section or an unreadable comparison STOPs. Any other section status also STOPs. Remove `$PARENT_SECTION_FILE` after the comparison. The section delta, exact manifest blobs, and exact-head CI jointly bind this cohort.
|
|
358
483
|
|
|
359
484
|
### 2.2 Resolve `<tag_exists>` and `<tag_sha>` from the guarded ref snapshot
|
|
360
485
|
|
|
361
486
|
**Zero-tag execution order:** use only the guarded ref snapshot in the correction paragraph below. Any pipe-form `show-ref`/`peel-tag` assignment is rejected and MUST NOT be executed.
|
|
362
487
|
|
|
363
|
-
**Resolve `<tag_exists>` and `<tag_sha>` from the LOCAL ref this way, and never from a
|
|
488
|
+
**Resolve `<tag_exists>` and `<tag_sha>` from the LOCAL ref this way, and never from a raw tag ref lookup** (HIGH-1, adversarial review 2026-07-31): an annotated tag's own object id — what `git rev-parse` on a bare tag name returns — is not the commit it names, so feeding that value straight into `classify` below would classify a perfectly healthy tag as `abort_mismatch` and hard-stop a release that needed no stopping at all. Use the guarded ref-snapshot sequence in the next paragraph; it is the only executable form. Empty output means the tag does not exist yet (`<tag_exists>=false`, `<tag_sha>=` any value, `classify` short-circuits past it); any other output is the commit sha to pass as `<tag_sha>`.
|
|
364
489
|
|
|
365
|
-
**Zero-tag correction (HIGH, blind exercise run 24): a pipe-form `show-ref`/`peel-tag` assignment MUST NOT be executed.** Under the hosted Bash contract (`set -euo pipefail`), a repository with no tags makes `show-ref` exit 1 and aborts the assignment before `peel-tag` can accept empty input. Snapshot local refs first with an explicit status guard: `TAG_REFS_FILE=$(mktemp); SHOW_REF_STATUS=0; git show-ref --tags -d > "$TAG_REFS_FILE" || SHOW_REF_STATUS=$?; if [ "$SHOW_REF_STATUS" -ne 0 ] && [ "$SHOW_REF_STATUS" -ne 1 ]; then rm -f -- "$TAG_REFS_FILE"; exit "$SHOW_REF_STATUS"; fi; TAG_SHA=$("$PY" "<plugin-root>/hooks/_releaselib.py" peel-tag "$
|
|
490
|
+
**Zero-tag correction (HIGH, blind exercise run 24): a pipe-form `show-ref`/`peel-tag` assignment MUST NOT be executed.** Under the hosted Bash contract (`set -euo pipefail`), a repository with no tags makes `show-ref` exit 1 and aborts the assignment before `peel-tag` can accept empty input. Snapshot local refs first with an explicit status guard: `TAG_REFS_FILE=$(mktemp); SHOW_REF_STATUS=0; git show-ref --tags -d > "$TAG_REFS_FILE" || SHOW_REF_STATUS=$?; if [ "$SHOW_REF_STATUS" -ne 0 ] && [ "$SHOW_REF_STATUS" -ne 1 ]; then rm -f -- "$TAG_REFS_FILE"; exit "$SHOW_REF_STATUS"; fi; TAG_SHA=$("$PY" "<plugin-root>/hooks/_releaselib.py" peel-tag "$RELEASE_TAG" < "$TAG_REFS_FILE"); rm -f -- "$TAG_REFS_FILE"`. Exit 1 alone means the ordinary zero-tag state and becomes empty input; every other failure remains terminal. This spelling is safe under `set -euo pipefail` because the expected non-zero status is on the left of `||`, not hidden in a pipeline. Never revive the deprecated local-object spelling `git rev-parse ${TAG_PREFIX}${VERSION}`; it returns the annotated tag object rather than the commit.
|
|
366
491
|
|
|
367
492
|
### 2.3 Compose the tag message and verify its date
|
|
368
493
|
|
|
@@ -382,7 +507,7 @@ Before composing the tag (HIGH, blind exercise run 25). The mandatory PR boundar
|
|
|
382
507
|
|
|
383
508
|
### 2.7 Derive `<release_nondraft>` from the Release, never guessed
|
|
384
509
|
|
|
385
|
-
`<release_nondraft>` is `false` whenever `<tag_exists>` is `false` (classify short-circuits past it before it is ever consulted), otherwise `gh release view $
|
|
510
|
+
`<release_nondraft>` is `false` whenever `<tag_exists>` is `false` (classify short-circuits past it before it is ever consulted), otherwise `gh release view "$RELEASE_TAG" --json isDraft --jq '.isDraft'` **with its answer inverted**: that command prints `true` for a DRAFT release, so `<release_nondraft>` is `false` when it prints `true`, and `true` when it prints `false` (HIGH, run 4). Pass the bare literal `true` or `false` and nothing else — feeding the raw `--json` object `{"isDraft":false}` straight through makes every value coerce falsey, which silently renders `already_published` unreachable rather than failing loudly. **If that `gh` call fails for any reason other than a genuinely absent Release** — no remote configured, no network, `gh` unauthenticated — **STOP and surface it rather than passing a guessed value**: `gh` reports "no git remotes found" and "release not found" as the same non-zero exit, and this argument decides the one branch where the answer determines whether an already-published release is republished.
|
|
386
511
|
|
|
387
512
|
### 2.8 Invoke `classify` with all six sourced arguments
|
|
388
513
|
|
|
@@ -390,15 +515,15 @@ Before composing the tag (HIGH, blind exercise run 25). The mandatory PR boundar
|
|
|
390
515
|
|
|
391
516
|
### 2.9 `publish_fresh`: create the tag — the sole tag-creation point
|
|
392
517
|
|
|
393
|
-
`publish_fresh` (the tag does not exist) → **write the ref now, and only here**: `git tag -a $
|
|
518
|
+
`publish_fresh` (the tag does not exist) → **write the ref now, and only here**: `git tag -a "$RELEASE_TAG" -F <message-file> --cleanup=verbatim` — never `-m` for multi-line content, never an interactive editor; this is the sole point in the whole lane at which a tag is created. **`--cleanup=verbatim` is load-bearing, not stylistic** (HIGH, adversarial review 2026-07-31, run 4; issue #569): `git tag`'s DEFAULT cleanup mode is `strip`, which deletes every line beginning with `#` as a comment. A Keep-a-Changelog section is composed entirely of `#`-prefixed headings, so the default silently destroys the `## [${VERSION}]` heading and every `### Added`/`### Fixed`/`### Performance`/`### Changed` grouping, leaving an undifferentiated bullet list that no longer says which version it describes. This has already happened to a real published tag in the repository that ships this skill. Because a published tag is immutable, a mangled message can never be repaired — only superseded by a new version.
|
|
394
519
|
|
|
395
520
|
### 2.10 Verify the stored tag round-tripped
|
|
396
521
|
|
|
397
|
-
Then **verify it round-tripped, rather than trusting the flag** — dump what git actually STORED to a scratch file beside the message file and check its heading: `git cat-file tag $
|
|
522
|
+
Then **verify it round-tripped, rather than trusting the flag** — dump what git actually STORED to a scratch file beside the message file and check its heading: `git cat-file tag "$RELEASE_TAG" > <stored-file>` — the raw tag object, not `git tag -l --format='%(contents)'`, which returns a RECONSTRUCTION that appends a trailing newline and so cannot detect a byte-level difference even in principle (LOW, run 5) — followed by `"$PY" "<plugin-root>/hooks/_releaselib.py" notes-match "$RELEASE_TAG" <stored-file>`, which must exit 0. (A file, not a pipe: `notes-match` takes a path, and a shell-specific stdin spelling would not survive the move to a Windows consumer.) `release_dates_consistent` cannot catch this on its own — it reads the changelog section from a separate argument and only needs the `Released-at:` line, both of which survive stripping — so a heading check against the ACTUAL stored message is the only guard that sees it.
|
|
398
523
|
|
|
399
524
|
### 2.11 Handle the remaining classification outcomes
|
|
400
525
|
|
|
401
|
-
`abort_mismatch` (tag points at a non-HEAD commit, or its version disagrees with the manifest) → STOP; `already_published` (a non-draft Release already exists on the tag) →
|
|
526
|
+
`abort_mismatch` (tag points at a non-HEAD commit, or its version disagrees with the manifest) → STOP; `already_published` (a non-draft Release already exists on the tag) → do not push or publish again. If the row declares a provenance manifest, verify the merged entry from the fetched default branch; if it is absent, use **Receipt-only closeout** below. That separate path also handles a historical tag after the default branch advances. A row without a provenance manifest explicitly skips the receipt. `resume_publish` (tag already at HEAD with the matching version but no Release) → skip re-tagging and resume at Phase 3 to create the missing Release — **this still requires explicit user authorization, exactly as a fresh publish does** (HIGH-4, adversarial review 2026-07-31): the local tag having been composed in a PRIOR invocation of Phase 2 is not itself authorization for Phase 3, which gates on it independently below; nothing about landing in `resume_publish` shortens, implies, or waives the Phase-3 STOP. `publish_fresh` is handled in 2.9–2.10 above, not here.
|
|
402
527
|
|
|
403
528
|
**Report.** `$TARGET`, version, bump rationale, the per-commit classification, the changelog section, and the tag SHA.
|
|
404
529
|
|
|
@@ -408,7 +533,7 @@ Gate: the hosted publisher has composed the annotated tag in its isolated checko
|
|
|
408
533
|
|
|
409
534
|
## Phase 3 — Publish · gate: STOP
|
|
410
535
|
|
|
411
|
-
The tag and the GitHub Release publish together, and only after the user explicitly authorizes publication. This phase does not run until then; absent authorization, nothing leaves the hosted publisher's isolated checkout. **For a protected automatic post-CI publisher, an explicit user instruction to merge or complete the already-reported release PR, given after the report and with that publisher declared, is the one-cohort Phase-3 authorization.** The workflow may consume that authorization after the exact merged commit passes its required CI; CI success alone never creates authorization, and the instruction does not authorize a later cohort or a rebuilt candidate. Do not ask the user to authorize the same cohort again after they act on that merge instruction.
|
|
536
|
+
The tag and the GitHub Release publish together, and only after the user explicitly authorizes publication. This phase does not run until then; absent authorization, nothing leaves the hosted publisher's isolated checkout. **For a protected automatic post-CI publisher, only an explicit user instruction to merge or complete the already-reported release PR, given after the Phase 1 release-PR report and before that PR merged, with that publisher declared, is the one-cohort Phase-3 authorization.** The workflow may consume that authorization after the exact merged commit passes its required CI; CI success alone never creates authorization, and the instruction does not authorize a later cohort or a rebuilt candidate. Do not ask the user to authorize the same cohort again after they act on that merge instruction.
|
|
412
537
|
|
|
413
538
|
**A qualifying hosted release workflow is mandatory.** Its read-only preflight holds no write token,
|
|
414
539
|
resolves exactly one target, proves the candidate equals the fetched default-branch commit, and
|
|
@@ -420,29 +545,54 @@ publisher behavior and read-back; they are not authorization for a local tag pus
|
|
|
420
545
|
|
|
421
546
|
On authorization:
|
|
422
547
|
|
|
423
|
-
Before step 1, a row with declared assets completes its reviewed hosted qualification path from the exact candidate already bound in Phase 2; it never expects Phase-1 temp paths to cross the PR boundary. For the hosted-build path, create a new empty `RELEASE_ASSET_DIR` plus names/paths scratch files, render the declared names, require `
|
|
548
|
+
Before step 1, a row with declared assets completes its reviewed hosted qualification path from the exact candidate already bound in Phase 2; it never expects Phase-1 temp paths to cross the PR boundary. For the hosted-build path, create a new empty `RELEASE_ASSET_DIR` plus names/paths scratch files, render the declared names, require `release_require_clean_tree || exit "$?"` to succeed (the underlying probe must print nothing AND exit 0), export `VERSION RELEASE_TAG RELEASE_ASSET_DIR`, run `( eval "$RELEASE_BUILD" )`, require `release_require_clean_tree || exit "$?"` again, and run `verify_declared_assets > "$RELEASE_ASSET_PATHS_FILE"`. For the retained-cohort path, download only the artifact retained by the exact green CI run for `HOSTED_HEAD`, verify its signed/digested provenance and source-commit receipt under the reviewed workflow, and compare its flat filenames exactly with the helper-rendered declaration; never execute a local sentinel that explicitly delegates assembly to that cohort. Either path must yield one exact verified inventory before the tag push, and any failure STOPs. Re-present that inventory; the user's publication authorization covers this one qualified cohort but no later rebuild or run. The bounded same-tag recovery lane remains only for state lost after tag composition, not the ordinary path.
|
|
424
549
|
|
|
425
|
-
**Deterministic publication-input gate — complete this before step 1's first irreversible write** (HIGH, blind exercise run 25). Reconstruct a fresh `PUBLISH_SECTION_FILE` from the local annotated `$RELEASE_TAG` with the policy-aware `changelog-section` command
|
|
550
|
+
**Deterministic publication-input gate — complete this before step 1's first irreversible write** (HIGH, blind exercise run 25). Re-establish `HOSTED_HEAD=$(git rev-parse HEAD)` in this qualified publisher invocation and require `PUBLISH_TAG_COMMIT=$(git rev-parse "$RELEASE_TAG^{commit}")` to succeed and `[ "$PUBLISH_TAG_COMMIT" = "$HOSTED_HEAD" ]` to hold; a local tag retargeted after Phase 2 STOPs before push. Reconstruct a fresh `PUBLISH_SECTION_FILE` from the local annotated `$RELEASE_TAG` with the policy-aware `changelog-section` command. Reconstruct the stored tag object in this invocation too: `PUBLISH_TAG_FILE=$(mktemp); git cat-file tag "$RELEASE_TAG" > "$PUBLISH_TAG_FILE"` (STOP on either failure). Require policy-aware `notes-match "$RELEASE_TAG" "$PUBLISH_TAG_FILE" "$VERSION_POLICY" "$INITIAL_VERSION"` and `dates-match "$PUBLISH_SECTION_FILE" "$PUBLISH_TAG_FILE" "$VERSION_POLICY" "$INITIAL_VERSION"`. Those checks cover the heading and date, not the section bytes: extract the tag body's section before its `Released-at:` footer with `PUBLISH_TAG_SECTION_FILE=$(mktemp); sed '1,/^$/d; /^Released-at: /,$d' "$PUBLISH_TAG_FILE" > "$PUBLISH_TAG_SECTION_FILE"`, then require `cmp -s "$PUBLISH_TAG_SECTION_FILE" "$PUBLISH_SECTION_FILE"` to succeed or STOP. Do not depend on Phase 2's scratch `<message-file>` surviving a restart. Derive the Release title and `--latest` decision now, while failure is still recoverable, and require `gh auth status` plus read-only repository access to succeed. Only after the section is unique, committed, heading-correct, byte-identical, date-consistent, and every deterministic `gh release create` input is ready may step 1 push the tag. Phase 3 step 2 repeats the section reconstruction after the push as a read-back guard, but it is not the first validation.
|
|
426
551
|
|
|
427
|
-
1. The authorized hosted publisher pushes the tag: `git push origin
|
|
428
|
-
2. **Resolve `<Phase-1 section file>` fresh in every Phase-3 invocation — never assume Phase 1's scratch file survived** (HIGH, blind exercise run 19). That file was created with `mktemp` outside the working tree and discarded once Phase 3 no longer needed it; on the `resume_publish` path (tag composed in a prior invocation, published now) it is normally already gone, and there was previously no stated way to get it back that did not read as the "re-derive or hand-write" the hard rules forbid. There is one sanctioned, mechanical way: `"$PY" "<plugin-root>/hooks/_releaselib.py" changelog-section "
|
|
552
|
+
1. Pin the local annotated tag object before the push: `LOCAL_TAG_OBJECT_SHA=$(git rev-parse "refs/tags/$RELEASE_TAG^{tag}")` (STOP if it fails), and print its SHA to the hosted job log before the push with the tag name and `$HOSTED_HEAD`: `printf 'pre-push tag=%s tag_object=%s hosted_head=%s\n' "$RELEASE_TAG" "$LOCAL_TAG_OBJECT_SHA" "$HOSTED_HEAD"`. Retain the authenticated run link in the report so interrupted receipt closeout can recover the original tuple. The authorized hosted publisher then pushes only the fully qualified tag ref: `git push origin "refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG"`. Never use the short source spelling: a malicious or mistaken prefix such as `refs/heads/release-` is rejected by the declaration parser, and the full refspec independently prevents Git from resolving a same-named branch as publication input.
|
|
553
|
+
2. **Resolve `<Phase-1 section file>` fresh in every Phase-3 invocation — never assume Phase 1's scratch file survived** (HIGH, blind exercise run 19). That file was created with `mktemp` outside the working tree and discarded once Phase 3 no longer needed it; on the `resume_publish` path (tag composed in a prior invocation, published now) it is normally already gone, and there was previously no stated way to get it back that did not read as the "re-derive or hand-write" the hard rules forbid. There is one sanctioned, mechanical way: `"$PY" "<plugin-root>/hooks/_releaselib.py" changelog-section "$PROJECT_ROOT" "$RELEASE_TAG" "$CHANGELOG" "$VERSION"` prints the `## [${VERSION}] …` section back out of the exact regular-file blob committed under the already-composed tag — guaranteed present, because Phase 1 step 7 committed it before the tag was created. The project root and every value are separate quoted arguments; the helper rejects a nested or unrelated root, an absolute or escaping changelog path, a non-regular Git tree entry, a malformed heading, `Unreleased` in a released position, and duplicate target sections. It resolves the tag to a commit hash before reading the blob, so a dirty/deleted working file, a changed current `HEAD`, or an unrelated current directory cannot substitute Release notes after the tag is composed. Redirect stdout to a fresh local file and use that as `<Phase-1 section file>` for every step below; this is reading the exact text back from its one permanent home, not composing new notes. Exit 1 (no heading for `${VERSION}`) means the committed changelog and tag version disagree; exit 3 means the root/revision/path/blob binding could not be proven; exit 4 means the changelog is malformed or ambiguous. Every one STOPs for investigation; never compose a substitute section by hand. On a same-session fresh publish the Phase 1 scratch file is still there and this reconstruction is redundant but harmless — run it anyway, so Phase 3 does not need to know which case it is in. **Guard the notes-file first:** assert its first heading matches the tag — `"$PY" "<plugin-root>/hooks/_releaselib.py" notes-match "$RELEASE_TAG" <Phase-1 section file>` (exit 0). A stale notes-file (`notes_heading_matches` False) would publish the wrong changelog section under the right tag — STOP on mismatch. Then create the GitHub Release from the **same changelog section composed in Phase 1** — reuse it as the notes, never re-derive or hand-write them. **`--latest` follows the declared row:** assert it only when `$TARGET`'s row declares `latest-eligible: true`, and only when this tag is also the newest release across every declared series (compare against `gh release list`; vacuously satisfied when only one series is declared); every other target passes `--latest=false`. GitHub has one repo-wide "Latest"; a declared file may name several series, so a target claiming it wrongly hides another's current release from every visitor. `gh release create "$RELEASE_TAG" --title "<title>" --notes-file <Phase-1 section file> --latest --verify-tag` when the row qualifies per the rule above, otherwise `gh release create "$RELEASE_TAG" --title "<title>" --notes-file <Phase-1 section file> --latest=false --verify-tag` — two distinct, individually runnable commands, never the bracket notation `--latest[=false]`, which is prose shorthand and not shell `gh` accepts. The title convention is `<$DISPLAY_NAME> ${VERSION}: <summary>` — `$DISPLAY_NAME` is the row's declared `display-name`, or `$TARGET` itself when the row declares none — with no em-dash separator. **`<summary>` is derived, not invented** (MEDIUM, adversarial review 2026-07-31, run 3: it appeared exactly once in this file and was never defined, so it was whatever the agent made up): take the single highest-precedence entry from the Phase 1 section — the first bullet under `### Breaking` if the window bumped major, the first bullet under `### Added` if the window bumped minor, otherwise the first bullet under `### Fixed`, else the first bullet of the first non-empty group — and compress it to a noun phrase under ten words, in the entry's own words. "First" means first as the section actually lists it: `classify-window` preserves the window's own order and step 5 does not reorder within a group, so the section's listed order is the window's own order as `git log` emits it (reverse-chronological, newest-first), never a re-sorted pass. If that yields nothing usable because the section has one group with one terse bullet, use that bullet verbatim. Never write a summary that names a change absent from the section.
|
|
429
554
|
|
|
430
|
-
Use the policy-aware forms for the resolved row: `"$PY" "<plugin-root>/hooks/_releaselib.py" changelog-section "
|
|
555
|
+
Use the policy-aware forms for the resolved row: `"$PY" "<plugin-root>/hooks/_releaselib.py" changelog-section "$PROJECT_ROOT" "$RELEASE_TAG" "$CHANGELOG" "$VERSION" "$VERSION_POLICY" "$INITIAL_VERSION"` and `"$PY" "<plugin-root>/hooks/_releaselib.py" notes-match "$RELEASE_TAG" <Phase-1 section file> "$VERSION_POLICY" "$INITIAL_VERSION"`. When assets are declared, replace the assetless `gh release create` spelling above with a local `publish_release` function. Inside it, start with `set --`, then `while IFS= read -r asset; do set -- "$@" "$asset"; done < "$RELEASE_ASSET_PATHS_FILE"`, and invoke the same authorized `gh release create` command with `"$@"` appended. This preserves each verified path as one argument and uploads only the helper-rendered, helper-verified inventory; never glob the output directory or reconstruct paths by hand.
|
|
431
556
|
3. Handle edge cases explicitly, never silently: if a Release for the tag already exists, report it and skip creation (the tag push may already have landed); if `gh` is missing, unauthenticated, or the call fails, STOP and print the exact `gh release create` command so publication can be finished by hand rather than left half-done.
|
|
432
557
|
4. **Verify publication — never assume it.** Read the Release back: `gh release view "$RELEASE_TAG" --json url,isDraft,tagName,assets`. STOP unless it returns a **non-draft** Release on the correct tag. For a declared asset contract, also write `gh release view "$RELEASE_TAG" --json assets --jq '.assets[].name'` to a fresh scratch file, sort that file and `RELEASE_ASSET_NAMES_FILE` under the same locale, and require `cmp -s` to succeed. Duplicate, missing, or extra published names fail this exact comparison: success means the GitHub Release contains exactly the declared asset names. A partially successful or mismatched publish is not a passing gate; report the URL only after both metadata and inventory read-back pass.
|
|
433
|
-
5. **
|
|
558
|
+
5. **Capture the tag's provenance.** Check the remote ref for every row, including one without a provenance manifest; only the receipt write is conditional. A git tag is a mutable ref, and the commit a tag was *originally* published at is not recoverable from the API once it moves. Capture one remote snapshot after the push: `REMOTE_TAG_REFS=$(git ls-remote --tags origin "refs/tags/$RELEASE_TAG" "refs/tags/$RELEASE_TAG^{}")`, and STOP unless it contains exactly the direct ref plus its peeled ref. Derive `TAG_OBJECT_SHA` from the exact `refs/tags/$RELEASE_TAG` line and derive `TAG_COMMIT_SHA=$(printf '%s\n' "$REMOTE_TAG_REFS" | "$PY" "<plugin-root>/hooks/_releaselib.py" peel-tag "$RELEASE_TAG")`. Require `TAG_OBJECT_SHA` to equal `LOCAL_TAG_OBJECT_SHA` pinned immediately before the push, and `TAG_COMMIT_SHA` to equal the exact hosted `HEAD` already qualified above; STOP on either mismatch. Report both identities and the tag before the hosted job ends. Both identities come from the same remote response, and the object comparison binds that response to the tag actually pushed. If the row declares no `$PROVENANCE_MANIFEST`, skip only the receipt and say so explicitly in the report. Otherwise, do not write to the hosted default-branch checkout: after publication, the operator creates a new non-default branch based on the fetched default branch, adds `{"object_sha": <TAG_OBJECT_SHA>, "object_type": "tag", "commit_sha": <TAG_COMMIT_SHA>}` under `tags` in `$PROVENANCE_MANIFEST`, commits through `commit-gate`, and must merge the receipt through a pull request. A branch-only commit is not a recorded default-branch receipt: provenance closeout remains pending until that PR merges. If this project runs an automated tag-drift check against that file, the next release may be blocked until the receipt merges; never move or recreate the published tag to clear it.
|
|
434
559
|
|
|
435
|
-
6. **Clean up declared-asset scratch state only after publication and provenance
|
|
560
|
+
6. **Clean up declared-asset scratch state only after publication and the provenance snapshot succeed.** When assets were declared and steps 1 through 5 have passed through remote-ref capture, remove only the positively identified paths minted by this invocation: `rm -rf -- "$RELEASE_ASSET_DIR"` and `rm -f -- "$RELEASE_ASSET_NAMES_FILE" "$RELEASE_ASSET_PATHS_FILE"`. Do not name an unminted scratch variable, glob a temporary root, or infer any path. Until both metadata and inventory read-back pass, or whenever the required remote-ref capture fails, preserve the directory and scratch files as failure evidence and report their exact paths; cleanup never runs on a partial or unverified publication.
|
|
436
561
|
|
|
437
|
-
Gate: with authorization, the tag is pushed AND a non-draft GitHub Release on that tag is confirmed by read-back (or, on failure, the exact manual command was surfaced and the half-finished state named), AND, when the row declares `$PROVENANCE_MANIFEST`, the
|
|
562
|
+
Gate: with authorization, the tag is pushed AND a non-draft GitHub Release on that tag is confirmed by read-back (or, on failure, the exact manual command was surfaced and the half-finished state named), AND, when the row declares `$PROVENANCE_MANIFEST`, the remote provenance snapshot is captured and handed off for a separate receipt PR (or the report explicitly says the row declares none), AND any declared-asset scratch state is removed only after those gates pass. Report publication as live but provenance closeout as pending until the receipt PR merges; do not claim a branch-local entry completed it. A failed or unverified publish is NOT a passing gate. Without authorization, nothing is published.
|
|
438
563
|
|
|
439
564
|
### Asset recovery for `resume_publish`
|
|
440
565
|
|
|
441
566
|
This is a hosted-publisher incident-recovery contract, not an interactive local fallback. It is only for a tag previously composed by the qualifying hosted publisher that classifies as `resume_publish` and whose declared-asset scratch state no longer exists. It reconstructs the already-tagged release; it never enters Phase 1's commit-window derivation, never applies version arithmetic, never writes a new tag, and never advances the version.
|
|
442
567
|
|
|
443
|
-
Resolve the row mechanically as in Targets, then derive the candidate only from tags that point at the current commit: `RECOVERY_TAG=$(git tag --points-at HEAD | "$PY" "<plugin-root>/hooks/_releaselib.py" last-tag-for-policy "$TAG_PREFIX" "$VERSION_POLICY" "$INITIAL_VERSION")`. STOP if it prints `<none>`. Require the exact tag boundary with `case "$RECOVERY_TAG" in "$TAG_PREFIX"*) VERSION=${RECOVERY_TAG#"$TAG_PREFIX"} ;; *) STOP ;; esac`, then set `RELEASE_TAG=$RECOVERY_TAG`. Bind the immutable inputs before executing anything: `RECOVERY_HEAD=$(git rev-parse HEAD)` and `RECOVERY_TAG_COMMIT=$(git rev-parse "$RELEASE_TAG^{commit}")` must be identical; `"$PY" "<plugin-root>/hooks/_releaselib.py" check-manifests "$TARGET" "$VERSION"` must exit 0; and `"$PY" "<plugin-root>/hooks/_releaselib.py" changelog-section "
|
|
444
|
-
|
|
445
|
-
Before confirming or executing any declared input, require `
|
|
568
|
+
Resolve the row mechanically as in Targets, then derive the candidate only from tags that point at the current commit: `RECOVERY_TAG=$(git tag --points-at HEAD | "$PY" "<plugin-root>/hooks/_releaselib.py" last-tag-for-policy "$TAG_PREFIX" "$VERSION_POLICY" "$INITIAL_VERSION")`. STOP if it prints `<none>`. Require the exact tag boundary with `case "$RECOVERY_TAG" in "$TAG_PREFIX"*) VERSION=${RECOVERY_TAG#"$TAG_PREFIX"} ;; *) STOP ;; esac`, then set `RELEASE_TAG=$RECOVERY_TAG`. Bind the immutable inputs before executing anything: `RECOVERY_HEAD=$(git rev-parse HEAD)` and `RECOVERY_TAG_COMMIT=$(git rev-parse "$RELEASE_TAG^{commit}")` must be identical; `"$PY" "<plugin-root>/hooks/_releaselib.py" check-manifests "$TARGET" "$VERSION"` must exit 0; and `"$PY" "<plugin-root>/hooks/_releaselib.py" changelog-section "$PROJECT_ROOT" "$RELEASE_TAG" "$CHANGELOG" "$VERSION" "$VERSION_POLICY" "$INITIAL_VERSION"` must reconstruct the committed section from that tag. Re-run the ordinary publish-state inputs and require `classify` to return exactly `resume_publish`; a moved HEAD, mismatched manifest, existing non-draft Release, missing Release evidence, or any other state STOPs this recovery lane.
|
|
569
|
+
|
|
570
|
+
Before confirming or executing any declared input, require `release_require_clean_tree || exit "$?"` to succeed (the underlying probe must print nothing AND exit 0), proving the working tree is the tagged commit just bound above under the selected row's release-surface boundary. Then `"$PY" "<plugin-root>/hooks/releasehash.py" check $TARGET` must confirm the exact current `pre-tag`, `release-build`, `rebuild`, and `generate` inputs — see step 6c for the full exit-code meaning (0 confirmed/no-commands, 1 changed, 2 never confirmed, and 64/65 configuration failures — a malformed invocation or an unrecognised `$TARGET` — that STOP this recovery lane the same way a 1 or 2 would, never a confirmation) — and `"$PY" "<plugin-root>/hooks/_releaselib.py" run-pre-tag $TARGET` must pass against that clean tagged commit. Immediately after `run-pre-tag`, require `release_require_clean_tree || exit "$?"` again so a check cannot mutate the tagged tree unnoticed. Re-run the same reviewed hosted qualification path used for the original cohort: either build in a new empty `RELEASE_ASSET_DIR` with `( eval "$RELEASE_BUILD" )` plus clean-tree and exact-inventory verification, or recover and reverify the retained exact-source CI package and its receipts. A local sentinel that delegates to the retained cohort is never treated as the build command. Present the reconstructed changelog section and verified asset names, preserve its fresh evidence paths, then **STOP for fresh publication authorization**. Only a later authorized Phase 3 invocation may push or publish; if that invocation loses this new evidence state, repeat this recovery lane and obtain fresh authorization again.
|
|
571
|
+
|
|
572
|
+
### Receipt-only closeout after an interrupted publication
|
|
573
|
+
|
|
574
|
+
Use this only for an already published tag whose declared provenance receipt is
|
|
575
|
+
absent from the fetched default branch. Resolve the target row and the exact tag
|
|
576
|
+
name; require that tag to match its declared prefix and version policy. This is
|
|
577
|
+
the historical released commit's closeout: it does not run Phase 1 or Phase 2,
|
|
578
|
+
does not require the tag commit to equal today's default `HEAD`, and never
|
|
579
|
+
pushes the tag or creates another Release. If a receipt PR already exists,
|
|
580
|
+
continue it instead of opening a duplicate.
|
|
581
|
+
|
|
582
|
+
Read the authenticated log and run metadata for the exact hosted publisher
|
|
583
|
+
that made the original push. Require its pre-push `(tag, tag-object SHA,
|
|
584
|
+
HOSTED_HEAD)` tuple and exact-source run identity; without them STOP rather
|
|
585
|
+
than inventing a receipt from a current mutable ref. In one fresh
|
|
586
|
+
`git ls-remote --tags` response require exactly the direct annotated tag ref and
|
|
587
|
+
peeled ref, with the direct object equal to that recorded pre-push object and
|
|
588
|
+
the peeled commit equal to that run's hosted commit. Read the GitHub Release
|
|
589
|
+
back and require a non-draft Release on the same tag. Compare any already
|
|
590
|
+
merged manifest entry on the fetched default branch with this same object,
|
|
591
|
+
`object_type: tag`, and commit; a mismatch STOPs. If the entry is absent,
|
|
592
|
+
create the receipt on a new non-default branch from fetched default, commit it
|
|
593
|
+
through `commit-gate`, and merge it through a PR. Report provenance closeout
|
|
594
|
+
as pending until the PR merges. Missing or conflicting original evidence is a
|
|
595
|
+
reviewed incident, never permission to move the tag or rewrite the ledger.
|
|
446
596
|
|
|
447
597
|
## Recovering from a bad release
|
|
448
598
|
|