@arbiterforge/ca-pi 0.10.13 → 0.14.7
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 +1 -1
- package/package.json +2 -1
- package/plugins/ca-pi/CHANGELOG.md +167 -0
- package/plugins/ca-pi/SKILLS.md +1 -1
- package/plugins/ca-pi/SPRINT.md +40 -10
- package/plugins/ca-pi/agents/backend-author.md +1 -1
- package/plugins/ca-pi/agents/frontend-author.md +1 -1
- package/plugins/ca-pi/generated/command-catalog.json +1 -1
- 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 +38 -0
- package/plugins/ca-pi/hooks/_approvallib.py +355 -0
- package/plugins/ca-pi/hooks/_artifactlib.py +855 -0
- package/plugins/ca-pi/hooks/_cleanuplib.py +1553 -0
- package/plugins/ca-pi/hooks/_hooklib.py +37 -13
- package/plugins/ca-pi/hooks/_host.py +1 -1
- package/plugins/ca-pi/hooks/_intentlib.py +31 -0
- package/plugins/ca-pi/hooks/_planfilelib.py +34 -1
- package/plugins/ca-pi/hooks/_prerequisitelib.py +971 -0
- package/plugins/ca-pi/hooks/_protectedstatelib.py +11 -1
- package/plugins/ca-pi/hooks/_readinjectlib.py +120 -0
- package/plugins/ca-pi/hooks/_releaselib.py +933 -85
- package/plugins/ca-pi/hooks/_standuplib.py +38 -10
- package/plugins/ca-pi/hooks/doctor.py +103 -0
- package/plugins/ca-pi/hooks/hostapi.py +1 -1
- package/plugins/ca-pi/hooks/pi-bridge.py +5 -1
- package/plugins/ca-pi/hooks/prompt-submit.py +47 -10
- package/plugins/ca-pi/hooks/releasehash.py +46 -32
- package/plugins/ca-pi/includes/artifacts.md +210 -0
- package/plugins/ca-pi/includes/author-tdd-workflow.md +2 -1
- package/plugins/ca-pi/includes/farm.md +27 -4
- package/plugins/ca-pi/includes/routing-table.md +2 -2
- package/plugins/ca-pi/includes/verification-boundary.md +27 -0
- package/plugins/ca-pi/package.json +18 -0
- package/plugins/ca-pi/routines/INDEX.md +4 -4
- package/plugins/ca-pi/routines/brainstorming/SKILL.md +23 -4
- package/plugins/ca-pi/routines/commit-gate/SKILL.md +30 -5
- package/plugins/ca-pi/routines/executing-plans/SKILL.md +52 -7
- package/plugins/ca-pi/routines/finishing-a-development-branch/SKILL.md +25 -3
- package/plugins/ca-pi/routines/refactor/SKILL.md +3 -2
- package/plugins/ca-pi/routines/release/SKILL.md +150 -44
- package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +53 -9
- package/plugins/ca-pi/routines/subagent-driven-development/references/farm-dispatch.md +20 -3
- package/plugins/ca-pi/routines/tdd/SKILL.md +27 -5
- package/plugins/ca-pi/routines/using-git-worktrees/SKILL.md +11 -2
- package/plugins/ca-pi/routines/writing-plans/SKILL.md +39 -10
- package/plugins/ca-pi/routines/writing-plans/references/farm-plan.md +34 -13
- package/plugins/ca-pi/skills/ca-chore/SKILL.md +7 -3
- package/plugins/ca-pi/skills/ca-commit/SKILL.md +3 -2
- package/plugins/ca-pi/skills/ca-doctor/SKILL.md +14 -0
- package/plugins/ca-pi/skills/ca-feature/SKILL.md +42 -8
- package/plugins/ca-pi/skills/ca-refactor/SKILL.md +4 -2
- package/plugins/ca-pi/skills/ca-release/SKILL.md +7 -6
- package/plugins/ca-pi/skills/ca-standup/SKILL.md +36 -8
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: release
|
|
3
|
-
description: The single permitted path to a version tag, for any target declared in the project's .codearbiter/release-targets.md. Routed to when the user invokes /release on a non-default branch with a green suite. Takes the declared target as its one argument, derives the
|
|
3
|
+
description: The single permitted path to a version tag, for any target declared in the project's .codearbiter/release-targets.md. Routed to when the user invokes /release on a non-default branch with a green suite. Takes the declared target as its one argument, derives the next identity under its declared version policy from Conventional-Commits history, rolls the commits into that target's CHANGELOG, writes an annotated tag in that target's namespace, and on authorization publishes it as a GitHub Release with the changelog section and exact declared assets. A release commit, if needed, routes through commit-gate; the tag and Release are never published without explicit authorization.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# release
|
|
@@ -11,6 +11,15 @@ The single permitted path to a version tag. Routed to when the user invokes `/re
|
|
|
11
11
|
|
|
12
12
|
Every phase below is written once, against that row. Nothing in this skill is per-target prose.
|
|
13
13
|
|
|
14
|
+
**Execution-shell contract.** Every fenced `sh` command and every inline shell fragment in this
|
|
15
|
+
workflow runs inside one POSIX-compatible shell session. On Windows, resolve and enter Git for
|
|
16
|
+
Windows' own `bash.exe` before the interpreter step below; MUST NOT paste the `sh` snippets into
|
|
17
|
+
PowerShell and treat PowerShell's exit status as their verdict. A native PowerShell session may be
|
|
18
|
+
used only to locate and launch Git Bash, for example by resolving `git.exe`, taking its installation
|
|
19
|
+
root, and invoking `bash.exe --noprofile --norc` from that installation's `bin` directory. Reject WSL aliases and WindowsApps
|
|
20
|
+
stubs because they execute in a different filesystem. If no POSIX-compatible shell is available,
|
|
21
|
+
STOP; do not translate the workflow ad hoc into a second command language.
|
|
22
|
+
|
|
14
23
|
**Interpreter convention, stated once and applying to every helper invocation in this file** (A-3.6). `python3` is not universally present — a Windows consumer commonly has `python` on PATH and no `python3` at all, and a literal `python3` spelling fails on every invocation at once there.
|
|
15
24
|
|
|
16
25
|
**Resolve the interpreter ONCE, by presence, before the first invocation:**
|
|
@@ -23,7 +32,7 @@ PY=python3; { command -v python3 >/dev/null 2>&1 && python3 --version >/dev/null
|
|
|
23
32
|
|
|
24
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`).
|
|
25
34
|
|
|
26
|
-
**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.
|
|
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 `<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.
|
|
27
36
|
|
|
28
37
|
**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.
|
|
29
38
|
|
|
@@ -38,10 +47,16 @@ Read one field at a time with `--field`, into a normal command substitution, spe
|
|
|
38
47
|
```sh
|
|
39
48
|
TAG_PREFIX=$("$PY" "<plugin-root>/hooks/_releaselib.py" show-row $TARGET --field prefix)
|
|
40
49
|
CHANGELOG=$("$PY" "<plugin-root>/hooks/_releaselib.py" show-row $TARGET --field changelog)
|
|
50
|
+
CHANGELOG_RECONCILIATIONS=$("$PY" "<plugin-root>/hooks/_releaselib.py" show-row $TARGET --field changelog-reconciliations)
|
|
41
51
|
MANIFEST=$("$PY" "<plugin-root>/hooks/_releaselib.py" show-row $TARGET --field manifest)
|
|
52
|
+
VERSION_POLICY=$("$PY" "<plugin-root>/hooks/_releaselib.py" show-row $TARGET --field version-policy)
|
|
53
|
+
INITIAL_VERSION=$("$PY" "<plugin-root>/hooks/_releaselib.py" show-row $TARGET --field initial-version)
|
|
54
|
+
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
|
+
VERSION_POLICY=${VERSION_POLICY:-semver}
|
|
42
57
|
```
|
|
43
58
|
|
|
44
|
-
…and so on for `generated-manifest`, `generate`, `artifacts`, `rebuild`, `pre-tag`, `provenance-manifest`, `latest-eligible`, `display-name`. Multi-valued fields print comma-separated; an undeclared one prints empty.
|
|
59
|
+
…and so on for `generated-manifest`, `generate`, `artifacts`, `rebuild`, `pre-tag`, `provenance-manifest`, `latest-eligible`, `display-name`. Multi-valued fields, including `release-assets`, print comma-separated; an undeclared one prints empty. 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.
|
|
45
60
|
|
|
46
61
|
**Split a multi-valued field this one sanctioned way** (LOW, #585) — a subshell so a temporary `IFS` change never leaks into the rest of the lane:
|
|
47
62
|
|
|
@@ -55,13 +70,17 @@ Every consumer of `$ARTIFACTS`/`$PRE_TAG`/`$MANIFEST` splits this way, not a han
|
|
|
55
70
|
|
|
56
71
|
**MUST NOT read the row with `eval`.** A bare `eval "$(… show-row …)"` executes the declared values: `rebuild: cd x && npm run build` parses as the assignment `REBUILD=cd` followed by the command `x`, with `&& npm run build` waiting behind it — and `eval` still exits 0, because plain assignments follow. Blind exercise run 15 hit exactly that. These values are operator-authored shell that this lane runs only AFTER step 6c confirms a human has read them; executing a fragment of them while merely READING the row runs them before the gate that exists for them. `show-row`'s bare form is shell-quoted so the mistake is now inert, but `--field` needs no `eval` at all and is the sanctioned spelling.
|
|
57
72
|
|
|
58
|
-
|
|
73
|
+
**The payload is a list of git PATHSPEC arguments, not one shell word.** Materialize it from the dedicated subcommand, NOT from `show-row`'s `payload` field. The helper emits one argument per line, and the scratch file preserves spaces inside an argument:
|
|
59
74
|
|
|
60
75
|
```sh
|
|
61
|
-
|
|
76
|
+
PAYLOAD_PATHS_FILE=$(mktemp)
|
|
77
|
+
"$PY" "<plugin-root>/hooks/_releaselib.py" payload-pathspec "$TARGET" > "$PAYLOAD_PATHS_FILE"
|
|
78
|
+
[ -s "$PAYLOAD_PATHS_FILE" ] || { echo "STOP — the payload pathspec set is empty" >&2; exit 1; }
|
|
62
79
|
```
|
|
63
80
|
|
|
64
|
-
|
|
81
|
+
Before each payload-scoped Git command, load those lines into positional parameters without reparsing them: `set --; while IFS= read -r pathspec; do set -- "$@" "$pathspec"; done < "$PAYLOAD_PATHS_FILE"`, then pass `"$@"` after `--`. Do not use an unquoted scalar or command substitution: a valid payload such as `app dir/` would split into `app` and `dir/`, while quote characters printed by a helper would remain literal data rather than becoming shell syntax. Remove only this positively identified scratch file when the invocation ends.
|
|
82
|
+
|
|
83
|
+
`payload` minus `payload-exclude` cannot be spelled as a plain path: `git log -- <path>` has no subtraction, and the `:(exclude)` form that does appears nowhere an operator would infer it. A row that declares an exclude otherwise silently counts the excluded commits in its own bump and changelog — `show-row --field payload` returns the raw field and is the WRONG source for this argument set.
|
|
65
84
|
|
|
66
85
|
From the resolved row:
|
|
67
86
|
|
|
@@ -73,16 +92,21 @@ From the resolved row:
|
|
|
73
92
|
| `$GENERATED_MANIFEST` (`generated-manifest`) | optional subset of `$MANIFEST`; never hand-edited — regenerated by `$GENERATE` instead |
|
|
74
93
|
| `$GENERATE` (`generate`) | optional command that regenerates every path in `$GENERATED_MANIFEST`; run before the Phase-1 manifest-equality assertion |
|
|
75
94
|
| `$CHANGELOG` (`changelog`) | the file the Phase-1 section is rolled into |
|
|
76
|
-
| `$
|
|
95
|
+
| `$CHANGELOG_RECONCILIATIONS` (`changelog-reconciliations`) | optional strict JSON ledger of explicit, full-SHA changelog-note reconciliations for already-published commits; it can supply note text only and never changes classification, version policy, or release scope |
|
|
96
|
+
| payload pathspec set (`payload`, minus `payload-exclude`) | the commit-window and rebuild-freshness scope, preserved one argument per line in `$PAYLOAD_PATHS_FILE` |
|
|
77
97
|
| `$ARTIFACTS` (`artifacts`) | committed built bundles asserted clean after `$REBUILD` runs |
|
|
78
98
|
| `$REBUILD` (`rebuild`) | optional command that regenerates every path in `$ARTIFACTS`; Pre-flight runs it unconditionally, once, in a subshell (`( eval "$REBUILD" )`) so it cannot move this lane's working directory; previously missing from this table entirely |
|
|
79
99
|
| `$PRE_TAG` (`pre-tag`) | check-only commands run in declared order before tagging (DECISION-0034) |
|
|
100
|
+
| `$VERSION_POLICY` (`version-policy`) | version grammar and arithmetic; omission defaults to `semver` |
|
|
101
|
+
| `$INITIAL_VERSION` (`initial-version`) | required fixed-shape floor for `numeric-sequence`; empty for `semver` |
|
|
102
|
+
| `$RELEASE_BUILD` (`release-build`) | optional protected operator input: either the hosted-build command that produces the declared assets, or a fail-closed sentinel documenting that a reviewed exact-head CI cohort owns assembly |
|
|
103
|
+
| `$RELEASE_ASSETS` (`release-assets`) | optional repeated flat filename templates; declared together with `$RELEASE_BUILD` |
|
|
80
104
|
| `$PROVENANCE_MANIFEST` (`provenance-manifest`) | optional; Phase 3 step 5 skips (and says so) when absent |
|
|
81
105
|
| `--latest` eligibility (`latest-eligible`) | at most one declared target may claim it |
|
|
82
106
|
|
|
83
107
|
An unrecognised `$TARGET` — no row of that name — STOPs; do not guess which project was meant. With no declared file at all, this skill's own "Back-fill" lane below handles it at release time; `context-creation` (full onboarding) is the sanctioned way to create one ahead of a release. Neither ever invents a row from a guess.
|
|
84
108
|
|
|
85
|
-
**The interpreter convention extends to DECLARED row commands, not only to this skill's own invocations** (#583 MEDIUM-2 / #584 MEDIUM-3). `$PRE_TAG`, `$REBUILD`, and `$
|
|
109
|
+
**The interpreter convention extends to DECLARED row commands, not only to this skill's own invocations** (#583 MEDIUM-2 / #584 MEDIUM-3). `$PRE_TAG`, `$REBUILD`, `$GENERATE`, and a `$RELEASE_BUILD` selected by the reviewed hosted-build path are operator shell this lane EXECUTES — via `run-pre-tag` for `pre-tag`, and directly for the other executable commands — exactly the same as any command spelled directly in this file, so a row hardcoding `python3` fails on exactly the host the interpreter paragraph above exists for. A fail-closed `$RELEASE_BUILD` sentinel documenting that the retained exact-head cohort owns assembly is confirmed and reported but deliberately not executed; treating its expected refusal as an arbitrary ignored build failure is forbidden. `run-pre-tag` exports `PY` (its own resolved interpreter) into every declared `pre-tag` command's environment, and this lane's own shell defines `$PY` before the other commands run — so a row SHOULD spell `"$PY"` in place of a hardcoded interpreter, the same way this file does.
|
|
86
110
|
|
|
87
111
|
**This now extends to a Windows-hosted `pre-tag` row too (#602, closing the gap measured when the paragraph above was first written).** `run-pre-tag` resolves a POSIX-compatible shell (Git for Windows' own `bash.exe`, found deterministically relative to `git --exec-path` — never WSL's same-named `bash.exe` stub under `system32`/`WindowsApps`, which runs inside a separate Linux filesystem) and dispatches `$PRE_TAG` through it directly, rather than falling through to `subprocess.run(shell=True)`'s default `cmd.exe`, which cannot expand `$VAR`. A row spelled `"$PY"` now expands the same way on every platform this skill runs on. When no POSIX shell can be resolved on a Windows host at all — no Git for Windows install, no `bash` reachable — `run-pre-tag` reports a distinct "could not run" diagnosis (exit 9, never 5 or 7) rather than misreading the absence as drift; the remedy is installing Git for Windows (which ships `bash.exe`) or putting an existing Git-for-Windows `bash.exe` on PATH.
|
|
88
112
|
|
|
@@ -99,7 +123,7 @@ Traps worth stating rather than discovering, general to any row rather than spec
|
|
|
99
123
|
|
|
100
124
|
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`).
|
|
101
125
|
- **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.
|
|
102
|
-
- **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
|
|
126
|
+
- **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 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 both declarations VERBATIM before anything is written, so the operator may narrow the payload or strike eligibility before confirming.
|
|
103
127
|
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 `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.
|
|
104
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 `<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. Only once that is confirmed, `release-targets.md` is a marker-gated protected-state file: immediately before writing, mint the authoring marker at the path the write-guard hooks check (project root = git top level):
|
|
105
129
|
|
|
@@ -114,7 +138,7 @@ Traps worth stating rather than discovering, general to any row rather than spec
|
|
|
114
138
|
rm -f "$(git rev-parse --show-toplevel)/.codearbiter/.markers/release-targets-authoring"
|
|
115
139
|
```
|
|
116
140
|
|
|
117
|
-
**This write itself dirties the tree
|
|
141
|
+
**This write itself dirties the tree and must be committed before Pre-flight**, even though the canonical Back-fill row excludes `.codearbiter/` from the release payload. Commit it through `commit-gate` on the current branch as `chore: declare release targets` (or an equivalent non-bumping type), then re-enter Pre-flight. The exclusion keeps hook-created `gate-events.log` and the declaration commit out of the product release window; it does not authorize leaving the declaration uncommitted.
|
|
118
142
|
4. **A second invocation reads; it does not re-detect.** Once the file exists on disk, `load_targets` succeeds and this back-fill lane never runs again for this project — detection above fires ONLY when the file is genuinely absent, never once a row has been confirmed and persisted.
|
|
119
143
|
|
|
120
144
|
## Pre-flight
|
|
@@ -129,23 +153,34 @@ Read these, or STOP and surface the gap — never guess:
|
|
|
129
153
|
|
|
130
154
|
**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:
|
|
131
155
|
|
|
132
|
-
-
|
|
133
|
-
-
|
|
156
|
+
- `<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.
|
|
157
|
+
- `<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.
|
|
134
158
|
|
|
135
159
|
```sh
|
|
136
|
-
|
|
160
|
+
"$PY" "<plugin-root>/hooks/_releaselib.py" clean-tree-status "$TARGET"
|
|
137
161
|
```
|
|
138
162
|
|
|
139
|
-
|
|
163
|
+
The helper resolves the selected row and applies each scratch exclusion
|
|
164
|
+
only when that path is disjoint from every release surface declared by the
|
|
165
|
+
row. A target whose payload is `.`, or whose manifests, changelog,
|
|
166
|
+
generated manifests, provenance manifest, artifacts, or release assets
|
|
167
|
+
overlap either scratch path, receives no hiding exclusion for that path.
|
|
168
|
+
This keeps governance-owned noise out of unrelated targets without making
|
|
169
|
+
an operator-declared release surface invisible to the gate.
|
|
140
170
|
|
|
141
|
-
|
|
171
|
+
**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.
|
|
172
|
+
|
|
173
|
+
A release must still not carry uncommitted work in anything it ships or asserts against, so every declared release surface stays in scope.
|
|
142
174
|
- The current branch MUST NOT be `main`, `master`, or the default branch. Release lands through the normal branch/PR path; if HEAD is the default branch, STOP.
|
|
143
175
|
- **Fetch tags before resolving `LAST_TAG`** (LOW, #585): `git fetch --tags origin`. A clone whose local tags lag the remote silently bases the whole release on a stale baseline — a tag published from elsewhere never 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.
|
|
144
|
-
- **
|
|
176
|
+
- **Fetch the default branch before using published-history evidence:** `git 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.
|
|
177
|
+
- **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 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.
|
|
178
|
+
|
|
179
|
+
**`$BASE_VERSION` — one base, computed the same way in every case under `$VERSION_POLICY`.** It is the maximum of the bare `LAST_TAG` version (or `0.0.0` for a first SemVer release, and `$INITIAL_VERSION` for a first `numeric-sequence` release) and the highest version any declared `manifest` currently carries. Compare candidates only with `"$PY" "<plugin-root>/hooks/_releaselib.py" version-greater <candidate> <floor> "$VERSION_POLICY" "$INITIAL_VERSION"`; an invalid, regressing, or shape-changing floor STOPs. Read every manifest HERE, before anything is bumped, because a row may declare several and only their policy-valid maximum is safe.
|
|
145
180
|
|
|
146
|
-
|
|
181
|
+
**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.
|
|
147
182
|
|
|
148
|
-
**
|
|
183
|
+
**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 sentinel proves only that a version was written; it does not prove that version was published. When `LAST_TAG=<none>` and a manifest wins the maximum, present and record one choice in the release report and release PR before classification: **never published** means retain the manifest maximum as `$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. This makes the ordinary zero-tag manifest `0.1.0` plus a confirmed footer-complete `feat` derive `0.2.0`, while known untagged publication remains blocked.
|
|
149
184
|
|
|
150
185
|
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.
|
|
151
186
|
|
|
@@ -157,7 +192,7 @@ Read these, or STOP and surface the gap — never guess:
|
|
|
157
192
|
- Without taking the MAXIMUM, a project holding a `v1.2.0` tag and a `1.4.2` manifest derived `1.3.0` from the tag alone and then hard-stopped against its own manifest — a BLOCK on a legitimate release, with the fix nowhere in the file (run 7).
|
|
158
193
|
|
|
159
194
|
`0.0.0` is a placeholder that contradicts data already on disk, and the tag alone is only half the data. Deriving from the maximum honestly skips any versions the project already claimed but never tagged. **`<none>` is a sentinel, not a revision — derive `$WINDOW` from it before using it anywhere** (HIGH, adversarial review 2026-07-31, run 5): every command below spells the window `$WINDOW`, and `$WINDOW` is `${LAST_TAG}..HEAD` when a tag was found and bare `HEAD` when `LAST_TAG` is `<none>`. Substituting the sentinel into a range is a hard failure, not a soft one — `git log <none>..HEAD` exits 128 with `fatal: bad revision`. This is not an edge case: a consumer that has just declared its first target through the Back-fill lane has, by construction, no tag in that series, so the very first release of every back-filled project lands here. In shell: `if [ "$LAST_TAG" = "<none>" ]; then WINDOW=HEAD; else WINDOW="${LAST_TAG}..HEAD"; fi`. **Residual (MEDIUM, adversarial review 2026-07-31), documented rather than silently accepted:** this replacement fixes ancestry-based `git describe`'s failure mode in one direction (a sibling series' tag can no longer leak in) but has no ancestry awareness of its own in the OTHER direction — it resolves by highest SEMVER across every tag in the series, commit-graph reachability from HEAD notwithstanding. A tag pushed once from a branch of this series that was later abandoned permanently still counts as "highest tag in the series" forever after, raising the baseline for every subsequent release even though no released history actually contains it. `last_tag_select` has no way to detect that case; a project that hits it must remove the stray tag by hand (never simply retarget or delete a PUBLISHED one — see "Recovering from a bad release" below) rather than expect this helper to route around it.
|
|
160
|
-
- **Scope
|
|
195
|
+
- **Scope every release-window read to the declared payload pathspec set:** reload `$PAYLOAD_PATHS_FILE` into positional parameters as specified under Targets and pass `"$@"` after `--`, never a whole-repository log. A `feat(some-other-target)` commit must not bump `$TARGET` or land in its changelog, and vice versa. Phase 1 checks non-emptiness only after it establishes `$EFFECTIVE_WINDOW`; checking raw first-release history here would run before the adoption floor and could accept only pre-adoption work.
|
|
161
196
|
- **Manifest read:** read the `version` field of every path in `$MANIFEST` — a row may declare more than one. Phase 1 asserts the derived bump equals each of them and updates them — a tag whose version runs ahead of a manifest ships nothing, since a plugin/package installer typically no-ops on an unchanged version string. A path also listed in `$GENERATED_MANIFEST` is not "updated" directly — it is regenerated by the row's declared `generate` command, and the same equality assertion is what confirms the regeneration landed on the derived version.
|
|
162
197
|
- **`$ARTIFACTS` freshness — rebuild unconditionally:** **not under `--dry-run`** — this step EXECUTES the row's declared `rebuild` command, which routinely overwrites the very build artifact it exists to check as its normal, intended side effect (a bundled tool rebuilt from source lands back on its own committed output path); a dry run's entire premise is that nothing on disk changes. See "Dry run" below, which lists `$REBUILD`/`$ARTIFACTS`/`$GENERATE` by name instead of running them, for the identical reason it does not run `$PRE_TAG`. Otherwise, every release, regardless of whether the sources changed in the window, run the row's declared `rebuild` command (when one is declared) **in a subshell, so it cannot move this lane's working directory** — `( eval "$REBUILD" ) || { echo "STOP — the rebuild itself failed; fix the build before trusting any freshness assertion" >&2; exit 1; }` — and only THEN assert every path in `$ARTIFACTS` is in sync (`git diff --quiet -- <each artifact>`). **The subshell's own exit code MUST be checked, and a non-zero exit STOPs** (MEDIUM, #585): nothing previously said the rebuild had to SUCCEED, and a failed build leaves the PREVIOUS artifacts in place — so the freshness assertion below would bless a stale bundle the broken build failed to update, reading a build that never ran as a build that produced nothing new. The subshell is the fix for a measured HIGH (blind exercise run 17), not a style preference: a declared `rebuild` commonly BEGINS with `cd` (this repository's own row is `cd <subdir> && npm run build`), the shell an operator runs this lane in persists between steps, and nothing here previously said to come back. From the subdirectory that leaves you in, three later gates fail silently rather than loudly — `git log $WINDOW -- $PAYLOAD` returns zero commits and fires the false "nothing to release" STOP on a full window; `git diff --quiet -- <artifact>` exits 0 without ever resolving the artifact, so the freshness gate passes while blind; and the clean-tree check reads a dirty tree as clean. Two of those block a release that should have succeeded and the third is a safety gate that stops looking at the thing it guards. **This `eval` is not the one the Targets section bans.** That rule forbids evaluating a row's values while merely READING the row, which runs operator shell before the gate that exists for it. Here the value is being deliberately EXECUTED as the command it was declared to be, at the step that executes it — the same thing `run-pre-tag` does for `pre-tag` commands. Reading is not execution; the ban is on confusing the two, not on ever running a declared command. A non-empty diff means a shipped bundle is stale — a release blocker, because a target ships the built file, not its source; commit the rebuild through `commit-gate` before tagging. Scope is `$TARGET` only: another target's stale bundle is that target's release problem, not this one's. A row declaring neither `rebuild` nor `artifacts` has nothing to assert here. (The old form gated the rebuild on an in-window source change and so missed a bundle that went stale *before* the window.)
|
|
163
198
|
|
|
@@ -165,8 +200,11 @@ Read these, or STOP and surface the gap — never guess:
|
|
|
165
200
|
|
|
166
201
|
Derive the bump mechanically from the commit log; do not guess it.
|
|
167
202
|
|
|
168
|
-
|
|
169
|
-
|
|
203
|
+
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 -- "<project-root>/.codearbiter/CONTEXT.md" "<project-root>/.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 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`. This one value is then used consistently for payload emptiness, classification, bump derivation, and changelog harvesting; no later step may rediscover or reinterpret the boundary.
|
|
204
|
+
1. Reload `$PAYLOAD_PATHS_FILE` into positional parameters with the sanctioned `while IFS= read -r pathspec` loop. Require `git log "$EFFECTIVE_WINDOW" --format=%H -- "$@"` to print at least one commit; empty output STOPs as nothing to release for `$TARGET`. Then read every commit in that same payload-scoped window: `git log "$EFFECTIVE_WINDOW" --pretty=format:%H%n%s%n%b%n---- -- "$@"` (the path scope is load-bearing — it excludes every sibling's commits from the bump and changelog).
|
|
205
|
+
2. **Classify the effective window through the tested helper, not by hand:** reload the pathspec positional parameters, then run `git log "$EFFECTIVE_WINDOW" --pretty=format:%H%n%s%n%b%n---- -- "$@" | "$PY" "<plugin-root>/hooks/_releaselib.py" classify-window "$TARGET" "$DEFAULT_BRANCH"`. It prints the derived bump on the first line, then 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 `CHANGELOG:` footer — step 3's report, in step 3's exact shape. **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 declared reconciliation ledger or its Git ancestry proof failed closed. It classifies and reports; the decision stays here, in this skill.
|
|
206
|
+
|
|
207
|
+
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-character commit SHA 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.
|
|
170
208
|
|
|
171
209
|
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.
|
|
172
210
|
|
|
@@ -175,8 +213,10 @@ Derive the bump mechanically from the commit log; do not guess it.
|
|
|
175
213
|
- else any `feat` → **minor**.
|
|
176
214
|
- else any `fix`, `perf`, `refactor` → **patch**.
|
|
177
215
|
- `test` / `docs` / `chore` / `ci` only → no bump. If the whole window is non-bumping, STOP — there is nothing to release.
|
|
178
|
-
|
|
179
|
-
|
|
216
|
+
|
|
217
|
+
**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.
|
|
218
|
+
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. `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. **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.
|
|
219
|
+
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`, then run `VERSION=$("$PY" "<plugin-root>/hooks/_releaselib.py" derive-version "$BASE_VERSION" "$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" "$BASE_VERSION" "$VERSION_POLICY" "$INITIAL_VERSION"`. 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.
|
|
180
220
|
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). **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.
|
|
181
221
|
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.
|
|
182
222
|
|
|
@@ -186,23 +226,48 @@ Derive the bump mechanically from the commit log; do not guess it.
|
|
|
186
226
|
|
|
187
227
|
**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.
|
|
188
228
|
|
|
189
|
-
**6c. Confirm
|
|
229
|
+
**6c. Confirm every declared executable release input has been read.** `"$PY" "<plugin-root>/hooks/releasehash.py" check $TARGET` must exit 0. The confirmation digest binds both the ordered `pre-tag` list and the optional `release-build`; changing either invalidates the same per-target confirmation before any command executes or any retained cohort is trusted. `pre-tag` and hosted-build values are operator-authored shell this lane later executes; a retained-cohort sentinel is protected operator-authored policy this lane must verify and report without executing. A change nobody has read is the case the check exists for. Exit 1 means one changed since the last confirmation, exit 2 means the row's executable inputs have never been confirmed; both are resolved the same way — read every `pre-tag` and `release-build` value, then `"$PY" "<plugin-root>/hooks/releasehash.py" record $TARGET`. Recording without reading converts the gate into a rubber stamp, which is worse than not having it. A row declaring neither `pre-tag` nor `release-build` reports `no-commands` and exits 0, deliberately distinct from `confirmed`; a row declaring `release-build` alone still requires confirmation.
|
|
190
230
|
|
|
191
231
|
**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.
|
|
192
232
|
|
|
193
233
|
**On exit 5, do not simply re-run** (HIGH, adversarial review 2026-07-31, run 9). Reconcile the drift the command reported — a pre-tag command is a check, never a fixer — and then **discard this run's uncommitted release edits before starting over**: the manifest bump is already on disk, and leaving it there makes it the NEXT run's `$BASE_VERSION` floor, so the restart derives a HIGHER version and leaves the section this run already wrote stranded in `$CHANGELOG` under a version that was never tagged. Nothing downstream catches that — `notes-match` only checks the new section's own heading. Commit the reconciliation ALONE, then re-run from Pre-flight. A target's own extra release surfaces — a version or count badge, a catalog table, anything else that must track the tag — are exactly what a declared `pre-tag` check exists to assert consistent; this skill runs whatever the row declares and does not assume what any target's surfaces are. A row declaring no `pre-tag` commands has nothing further to check here.
|
|
194
234
|
|
|
195
|
-
**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
|
|
235
|
+
**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.
|
|
196
236
|
|
|
197
237
|
**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.
|
|
198
|
-
7. **Commit the release edits before leaving this phase — this is not conditional** (HIGH, adversarial review 2026-07-31, run 10). Steps 5 and 6 rolled `$CHANGELOG` and bumped every path in `$MANIFEST`; those edits are uncommitted, and `git tag` in Phase 2 names a COMMIT, not the working tree. Tagging with them outstanding produces a tag whose payload still carries the OLD version and no new changelog section, while its own message quotes the section that never shipped — and every guard in the lane passes, because `dates-match` compares two scratch files and `notes-match` reads only a heading, so none of them looks at the tagged tree at all. This step previously read "*if* the changelog edit … *needs* to land as a commit", which a literal traversal answers "no". Route the commit through `commit-gate`; do not reimplement the commit path here. Then assert the tree is clean before Phase 2
|
|
238
|
+
7. **Commit the release edits before leaving this phase — this is not conditional** (HIGH, adversarial review 2026-07-31, run 10). Steps 5 and 6 rolled `$CHANGELOG` and bumped every path in `$MANIFEST`; those edits are uncommitted, and `git tag` in Phase 2 names a COMMIT, not the working tree. Tagging with them outstanding produces a tag whose payload still carries the OLD version and no new changelog section, while its own message quotes the section that never shipped — and every guard in the lane passes, because `dates-match` compares two scratch files and `notes-match` reads only a heading, so none of them looks at the tagged tree at all. This step previously read "*if* the changelog edit … *needs* to land as a commit", which a literal traversal answers "no". Route the commit through `commit-gate`; do not reimplement the commit path here. Then assert the tree is clean before Phase 2 with `"$PY" "<plugin-root>/hooks/_releaselib.py" clean-tree-status "$TARGET"`; it must print nothing. 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.
|
|
239
|
+
|
|
240
|
+
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. Render the declaration mechanically: `(IFS=,; "$PY" "<plugin-root>/hooks/_releaselib.py" render-release-assets "$VERSION" "$RELEASE_TAG" $RELEASE_ASSETS) > "$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.
|
|
199
241
|
|
|
200
|
-
|
|
242
|
+
Use this exact argument-preserving function for that final verification and reuse it for Phase 3's pre-push recheck:
|
|
243
|
+
|
|
244
|
+
```sh
|
|
245
|
+
verify_declared_assets() {
|
|
246
|
+
set --
|
|
247
|
+
while IFS= read -r asset; do set -- "$@" "$asset"; done < "$RELEASE_ASSET_NAMES_FILE"
|
|
248
|
+
"$PY" "<plugin-root>/hooks/_releaselib.py" verify-release-assets "$RELEASE_ASSET_DIR" "$@"
|
|
249
|
+
}
|
|
250
|
+
verify_declared_assets > "$RELEASE_ASSET_PATHS_FILE"
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
An empty, unreadable, or malformed names file cannot pass: the helper rejects a zero-name request and all unsafe or duplicate names before inspecting the directory.
|
|
254
|
+
|
|
255
|
+
**Cross-boundary correction (HIGH, blind exercise runs 26 and 28): Phase 1 never supplies a Phase-3 asset.** The mandatory PR stop and fresh hosted checkout make local `mktemp` paths non-transferable, while this repository's qualified packages carry CI provenance and cold-execution receipts that a local rebuild cannot recreate. Remove the names scratch file after the Phase-1 report. The authorized hosted publisher takes the already-reviewed path named above and verifies the same declared inventory from exact merged `HEAD` before any tag push.
|
|
256
|
+
|
|
257
|
+
Gate: version confirmed, strictly monotonic within `$TARGET`'s declared policy and series, matching the commit log, and equal to every path in `$MANIFEST`; `$CHANGELOG` updated; every declared `pre-tag` check passes; **the release edits are COMMITTED and the tree is clean under step 7's target-aware probe**; and every declared asset name plus its reviewed hosted qualification path is reported. BLOCK if the classification disagrees with the log, the window is non-bumping, a bumping commit's `CHANGELOG:` footer is missing, any command this phase actually executes fails or mutates the tracked tree, the asset qualification path is absent or ambiguous, or the tree is dirty on exit. **A `run-pre-tag` exit of 7 (a command could not run at all) or 8 (the tree-state probe itself failed) is not a BLOCK on this gate's own merits and not a genuine drift/mutation finding either** — both STOP the lane for investigation, distinct from — and never diagnosed as — exit 5's drift or exit 6's mutation.
|
|
258
|
+
|
|
259
|
+
**The release commit must merge through a pull request before any tag is composed.** After the
|
|
260
|
+
Phase-1 commit and checks are complete, route the branch through the ordinary PR path and STOP this
|
|
261
|
+
invocation. Never continue from the release branch merely because its local commit gate passed. The
|
|
262
|
+
post-merge publication lane must bind the tag to the fetched default-branch commit, not to the PR
|
|
263
|
+
head or a pre-merge release commit.
|
|
201
264
|
|
|
202
265
|
**Exit 6 is a broken DECLARATION, not a dirty tree, and its remedy is not the same as exit 5's** (HIGH, adversarial review 2026-07-31, run 11). A command that mutates the tree does so on every invocation, so "revert and re-run this step" cannot converge — it loops without bound, and the first thing it reverts is the changelog section step 5 just composed, which exists nowhere else but the scratch file. On a FIRST release it cannot even be attempted: the lane creates `$CHANGELOG` from nothing, and `git checkout --` errors on a path git has never seen. So: **fix the declaration first.** Make the offending command check-only — assert, and exit non-zero on drift, per DECISION-0034 — or remove the row entry. Only then discard this run's edits wholesale (`git checkout --` for files that existed before, `rm` for any the lane created) and restart from Pre-flight. Discarding is safe precisely because nothing has been committed or tagged yet. The clean-tree condition is what makes the tag name a commit that actually contains this release. **Exit 7 and exit 8 are neither of the above** — see 6d's own exit-code paragraphs; neither means the declaration is broken, and neither authorizes a wholesale discard, because nothing has been checked yet on either path.
|
|
203
266
|
|
|
204
267
|
## Dry run (`--dry-run`)
|
|
205
268
|
|
|
269
|
+
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.
|
|
270
|
+
|
|
206
271
|
`--dry-run` runs Pre-flight in full — every STOP condition above applies exactly as it does on a real
|
|
207
272
|
run, so a dry run surfaces the SAME blockers (dirty tree, wrong branch, red suite, manifest-ahead-of-tag)
|
|
208
273
|
a real one would hit. **"In full" covers every STOP assertion, not every EXECUTED side effect:** the
|
|
@@ -210,8 +275,8 @@ a real one would hit. **"In full" covers every STOP assertion, not every EXECUTE
|
|
|
210
275
|
`$PRE_TAG` is listed rather than run below — both are declared commands this lane EXECUTES, and a dry
|
|
211
276
|
run's contract ("nothing is written") cannot coexist with executing a command whose job is to overwrite
|
|
212
277
|
a file. Then runs Phase 1 steps 1 through 4 only: read the window, classify it through
|
|
213
|
-
`classify-window`, verify `CHANGELOG:` footer completeness, and
|
|
214
|
-
`
|
|
278
|
+
`classify-window`, verify `CHANGELOG:` footer completeness, and derive the identity through `derive-version` +
|
|
279
|
+
`version-greater` under the declared policy. **Stop there, before step 5.** Steps 5 (compose and write the changelog section), 6
|
|
215
280
|
(bump every declared manifest, assert, confirm and run `pre-tag`), and 7 (commit the release edits)
|
|
216
281
|
do not run — nor does anything in Phase 2 or Phase 3. Nothing is written: no scratch file survives the
|
|
217
282
|
run, no path in `$MANIFEST` or `$CHANGELOG` changes, no commit, no tag.
|
|
@@ -247,34 +312,74 @@ confirmation step 3 requires, and never persist. Report the proposed block inste
|
|
|
247
312
|
Detect printed it, labeled as a dry-run preview.
|
|
248
313
|
|
|
249
314
|
Gate: every Pre-flight STOP condition still applies; Pre-flight's `$ARTIFACTS` freshness EXECUTION (the
|
|
250
|
-
`rebuild`/`generate` commands, as opposed to merely listing them) does not run; steps 5 through
|
|
315
|
+
`rebuild`/`generate` commands, as opposed to merely listing them) does not run; steps 5 through 8 of
|
|
251
316
|
Phase 1 and all of Phase 2 and Phase 3 are skipped, not merely unauthorized; the report above is
|
|
252
317
|
delivered. MUST NOT write, commit, or tag anything under `--dry-run` — this now explicitly includes not
|
|
253
318
|
executing a declared `rebuild` or `generate` command, not only `$MANIFEST`/`$CHANGELOG`/the tag.
|
|
254
319
|
|
|
255
320
|
## Phase 2 — Tag & report · gate: BLOCK
|
|
256
321
|
|
|
257
|
-
1. Compose the annotated tag message from the Phase 1 section plus a `Released-at: $RELEASE_DATE` footer (the same date derived once in Phase 1) into a message file, then assert the two dates agree: `"$PY" "<plugin-root>/hooks/_releaselib.py" dates-match <Phase-1 section file> <message-file>` must exit 0. (This check was named in the prose but had no CLI entry point until run 4, so nobody following the skill could actually run it — `release_dates_consistent` was reachable only by importing the module, which this skill never tells anyone to do.) **Do not run `git tag` yet — classify first, then tag** (MEDIUM, adversarial review 2026-07-31, run 3): writing the ref before resolving the state is what the classification below exists to authorize, so an agent following this step in written order must reach `classify` with no tag written by this invocation. The tag command itself appears below, in the `publish_fresh` branch, and nowhere else. **Resolve `<tag_exists>` and `<tag_sha>` from the LOCAL ref this way, and never from a bare `git rev-parse ${TAG_PREFIX}${VERSION}`** (HIGH-1, adversarial review 2026-07-31): an annotated tag's own object id — what `git rev-parse` on a bare tag name returns — is not the commit it names, so feeding that value straight into `classify` below would classify a perfectly healthy tag as `abort_mismatch` and hard-stop a release that needed no stopping at all. Peel it through the same tested helper a hosted publish lane already uses for the identical problem against the REMOTE tag, pointed here at LOCAL refs instead: `TAG_SHA=$(git show-ref --tags -d | "$PY" "<plugin-root>/hooks/_releaselib.py" peel-tag ${TAG_PREFIX}${VERSION})`. Dump every local tag (`git show-ref --tags -d`, no tag argument) rather than naming the one tag directly — `git show-ref` exits non-zero for a tag that does not exist yet, which is the common case here, where naming it directly would break a `set -e` lane; dumping the whole set and letting `peel-tag` find (or not find) the one line puts the PIPELINE's exit status in `peel-tag`'s hands rather than `git show-ref`'s, which is what makes it safe under `set -e`. (Correction, run 6: this previously claimed the dump form "always exits 0". It does not — with zero tags in the repository, the exact state a first release is in, `git show-ref --tags -d` itself exits 1 and prints nothing. The recommendation stands; the reason given for it was wrong.) Empty output means the tag does not exist yet (`<tag_exists>=false`, `<tag_sha>=` any value, `classify` short-circuits past it); any other output is the commit sha to pass as `<tag_sha>`. **Quote it — `"$TAG_SHA"`, always** (MEDIUM, adversarial review 2026-07-31, run 5): on the fresh-publish path, which is the COMMON case, that variable is empty, and an unquoted empty argument does not become an empty positional — it disappears, shifting the remaining five left so `classify` receives five arguments and exits 2. The failure is at least loud rather than a wrong verdict, but it fires on the ordinary path, not an edge case. **Source the other four arguments explicitly — none of them is yours to invent** (MEDIUM, adversarial review 2026-07-31, run 3: the prose named all six but sourced only two, leaving three to be supplied from agent judgment and one unobtainable): `<head_sha>` is `git rev-parse HEAD`; `<tag_version>` is the version DERIVED in Phase 1, reused verbatim and never re-parsed from a tag or a file; `<manifest_version>` is the `version` field of the row's first declared `manifest`, extracted with that FORMAT'S OWN parser rather than a line-grep (MEDIUM, run 5: every other argument here names a command and this one named none, so `jq`, `grep`, and a JSON parser could each return something different on a nested manifest). The grammar permits a row to declare manifests in different formats, so the command follows the FILE rather than a default: `"$PY" -c "import json,sys;print(json.load(open(sys.argv[1]))['version'])" <manifest>` for JSON, `"$PY" -c "import tomllib,sys;print(tomllib.load(open(sys.argv[1],'rb'))['project']['version'])" <manifest>` for TOML, the equivalent for anything else declared. Naming only the JSON form was itself the defect one run later — applied to a `pyproject.toml` it raises `JSONDecodeError` and exits 1 (MEDIUM, run 7). **This reads the FIRST declared manifest, while Pre-flight's `$BASE_VERSION` reads the MAXIMUM across all of them.** That difference is deliberate and is safe ONLY because step 6 bumps every declared path to the derived version — which step 6 now ASSERTS mechanically, because nothing else does. **Correction (HIGH, adversarial review 2026-07-31, run 12): this paragraph previously claimed `classify` catches a partial bump. It does not, on the path that matters.** `classify_publish_state` short-circuits on `if not tag_exists: return "publish_fresh"` before it ever compares versions, so that catch fires only when a tag already EXISTS — the resume path. On a fresh publish, which is every ordinary release and every first release, a lagging secondary manifest passes unnoticed, and the Traps section's own named consequence lands: a tag that installs a version string the tag does not name. Measured: `classify false "" <head> 1.4.3 1.1.0 false` → `publish_fresh`; the same disagreement with `tag_exists=true` → `abort_mismatch`. And **re-read from the file NOW — never the value Pre-flight read** (HIGH, adversarial review 2026-07-31, run 4): Pre-flight read that file before step 6 bumped it, so the carried value is the OLD version, and passing it makes `classify` return `abort_mismatch` — a terminal STOP — on a release where nothing whatsoever is wrong. `<tag_version>` is carried and `<manifest_version>` is re-read; the two arguments are sourced differently on purpose. And `<release_nondraft>` is `false` whenever `<tag_exists>` is `false` (classify short-circuits past it before it is ever consulted), otherwise `gh release view ${TAG_PREFIX}${VERSION} --json isDraft --jq '.isDraft'` **with its answer inverted**: that command prints `true` for a DRAFT release, so `<release_nondraft>` is `false` when it prints `true`, and `true` when it prints `false` (HIGH, run 4). Pass the bare literal `true` or `false` and nothing else — feeding the raw `--json` object `{"isDraft":false}` straight through makes every value coerce falsey, which silently renders `already_published` unreachable rather than failing loudly. **If that `gh` call fails for any reason other than a genuinely absent Release** — no remote configured, no network, `gh` unauthenticated — **STOP and surface it rather than passing a guessed value**: `gh` reports "no git remotes found" and "release not found" as the same non-zero exit, and this argument decides the one branch where the answer determines whether an already-published release is republished. **If the tag already exists, do not flatly abort — classify the state** with `classify_publish_state` via `"$PY" "<plugin-root>/hooks/_releaselib.py" classify <tag_exists> <tag_sha> <head_sha> <tag_version> <manifest_version> <release_nondraft>`: `publish_fresh` (the tag does not exist) → **write the ref now, and only here**: `git tag -a ${TAG_PREFIX}${VERSION} -F <message-file> --cleanup=verbatim` — never `-m` for multi-line content, never an interactive editor; this is the sole point in the whole lane at which a tag is created. **`--cleanup=verbatim` is load-bearing, not stylistic** (HIGH, adversarial review 2026-07-31, run 4; issue #569): `git tag`'s DEFAULT cleanup mode is `strip`, which deletes every line beginning with `#` as a comment. A Keep-a-Changelog section is composed entirely of `#`-prefixed headings, so the default silently destroys the `## [${VERSION}]` heading and every `### Added`/`### Fixed`/`### Performance`/`### Changed` grouping, leaving an undifferentiated bullet list that no longer says which version it describes. This has already happened to a real published tag in the repository that ships this skill. Because a published tag is immutable, a mangled message can never be repaired — only superseded by a new version. Then **verify it round-tripped, rather than trusting the flag** — dump what git actually STORED to a scratch file beside the message file and check its heading: `git cat-file tag ${TAG_PREFIX}${VERSION} > <stored-file>` — the raw tag object, not `git tag -l --format='%(contents)'`, which returns a RECONSTRUCTION that appends a trailing newline and so cannot detect a byte-level difference even in principle (LOW, run 5) — followed by `"$PY" "<plugin-root>/hooks/_releaselib.py" notes-match ${TAG_PREFIX}${VERSION} <stored-file>`, which must exit 0. (A file, not a pipe: `notes-match` takes a path, and a shell-specific stdin spelling would not survive the move to a Windows consumer.) `release_dates_consistent` cannot catch this on its own — it reads the changelog section from a separate argument and only needs the `Released-at:` line, both of which survive stripping — so a heading check against the ACTUAL stored message is the only guard that sees it; `abort_mismatch` (tag points at a non-HEAD commit, or its version disagrees with the manifest) → STOP; `already_published` (a non-draft Release already exists on the tag) → nothing to do; `resume_publish` (tag already at HEAD with the matching version but no Release) → skip re-tagging and resume at Phase 3 to create the missing Release — **this still requires explicit user authorization, exactly as a fresh publish does** (HIGH-4, adversarial review 2026-07-31): the local tag having been composed in a PRIOR invocation of Phase 2 is not itself authorization for Phase 3, which gates on it independently below; nothing about landing in `resume_publish` shortens, implies, or waives the Phase-3 STOP. (Whether the tag has actually been PUSHED to the remote — as opposed to existing only locally, the state Phase 2 itself is designed to leave behind — is not something `classify_publish_state` can tell from `tag_sha`/`head_sha`/`release_is_nondraft` alone; distinguishing it would need an added `git ls-remote` round-trip threaded through every classification call and CLI site for a case this authorization restatement already closes without one, so it is not implemented here.)
|
|
322
|
+
Phase 2 is a contract for the qualifying hosted publisher, not a local fallback. Re-enter only after
|
|
323
|
+
the release PR has merged, from a clean checkout whose `HEAD` is byte-for-byte the fetched
|
|
324
|
+
`origin/$DEFAULT_BRANCH` revision. Publication requires a hosted workflow that verifies green
|
|
325
|
+
exact-head evidence for that same commit before its write-token job can run. A project with no
|
|
326
|
+
qualifying hosted publisher STOPs before tag composition; it must add and review that capability in
|
|
327
|
+
a separate PR rather than weakening this release. The interactive agent may authorize, dispatch, or
|
|
328
|
+
observe that publisher, but MUST NOT execute the tag-composition commands below locally.
|
|
329
|
+
|
|
330
|
+
All version-sensitive helper calls use the declared policy. In particular, invoke `notes-match ${TAG_PREFIX}${VERSION} <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.
|
|
331
|
+
|
|
332
|
+
**Bind the hosted candidate to the release-surface commit, 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`.
|
|
333
|
+
|
|
334
|
+
**Reconstruct Phase 1's section in this hosted checkout before composing the tag** (HIGH, blind exercise run 25). The mandatory PR boundary means Phase 1's external `mktemp` file and shell variables are not inputs Phase 2 can possess. Create a fresh `PHASE2_SECTION_FILE=$(mktemp)` and run `"$PY" "<plugin-root>/hooks/_releaselib.py" changelog-section "<project-root>" "$HOSTED_HEAD" "$CHANGELOG" "$VERSION" "$VERSION_POLICY" "$INITIAL_VERSION" > "$PHASE2_SECTION_FILE"`; then require `notes-match "$RELEASE_TAG" "$PHASE2_SECTION_FILE" "$VERSION_POLICY" "$INITIAL_VERSION"`. `$HOSTED_HEAD` is the already-bound full candidate commit object, never a mutable revision expression. Derive `RELEASE_DATE` mechanically from that validated section's first heading with `sed -n '1s/.* \([0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]\)$/\1/p'`, STOP if it is empty, compose the tag message from this exact section plus `Released-at: $RELEASE_DATE`, and require `dates-match "$PHASE2_SECTION_FILE" <message-file> "$VERSION_POLICY" "$INITIAL_VERSION"`. This is reconstruction from the exact merged commit, not re-derivation from memory; `<Phase-1 section file>` below means this `PHASE2_SECTION_FILE`, never the vanished Phase-1 scratch path.
|
|
335
|
+
|
|
336
|
+
**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.
|
|
337
|
+
|
|
338
|
+
1. Compose the annotated tag message from the Phase 1 section plus a `Released-at: $RELEASE_DATE` footer (the same date derived once in Phase 1) into a message file, then assert the two dates agree: `"$PY" "<plugin-root>/hooks/_releaselib.py" dates-match <Phase-1 section file> <message-file>` must exit 0. (This check was named in the prose but had no CLI entry point until run 4, so nobody following the skill could actually run it — `release_dates_consistent` was reachable only by importing the module, which this skill never tells anyone to do.) **Do not run `git tag` yet — classify first, then tag** (MEDIUM, adversarial review 2026-07-31, run 3): writing the ref before resolving the state is what the classification below exists to authorize, so an agent following this step in written order must reach `classify` with no tag written by this invocation. The tag command itself appears below, in the `publish_fresh` branch, and nowhere else. **Resolve `<tag_exists>` and `<tag_sha>` from the LOCAL ref this way, and never from a bare `git rev-parse ${TAG_PREFIX}${VERSION}`** (HIGH-1, adversarial review 2026-07-31): an annotated tag's own object id — what `git rev-parse` on a bare tag name returns — is not the commit it names, so feeding that value straight into `classify` below would classify a perfectly healthy tag as `abort_mismatch` and hard-stop a release that needed no stopping at all. 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>`. **Quote it — `"$TAG_SHA"`, always** (MEDIUM, adversarial review 2026-07-31, run 5): on the fresh-publish path, which is the COMMON case, that variable is empty, and an unquoted empty argument does not become an empty positional — it disappears, shifting the remaining five left so `classify` receives five arguments and exits 2. The failure is at least loud rather than a wrong verdict, but it fires on the ordinary path, not an edge case. **Source the other four arguments explicitly — none of them is yours to invent** (MEDIUM, adversarial review 2026-07-31, run 3: the prose named all six but sourced only two, leaving three to be supplied from agent judgment and one unobtainable): `<head_sha>` is `git rev-parse HEAD`; `<tag_version>` is the version DERIVED in Phase 1, reused verbatim and never re-parsed from a tag or a file; `<manifest_version>` is the `version` field of the row's first declared `manifest`, extracted with that FORMAT'S OWN parser rather than a line-grep (MEDIUM, run 5: every other argument here names a command and this one named none, so `jq`, `grep`, and a JSON parser could each return something different on a nested manifest). The grammar permits a row to declare manifests in different formats, so the command follows the FILE rather than a default: `"$PY" -c "import json,sys;print(json.load(open(sys.argv[1]))['version'])" <manifest>` for JSON, `"$PY" -c "import tomllib,sys;print(tomllib.load(open(sys.argv[1],'rb'))['project']['version'])" <manifest>` for TOML, the equivalent for anything else declared. Naming only the JSON form was itself the defect one run later — applied to a `pyproject.toml` it raises `JSONDecodeError` and exits 1 (MEDIUM, run 7). **This reads the FIRST declared manifest, while Pre-flight's `$BASE_VERSION` reads the MAXIMUM across all of them.** That difference is deliberate and is safe ONLY because step 6 bumps every declared path to the derived version — which step 6 now ASSERTS mechanically, because nothing else does. **Correction (HIGH, adversarial review 2026-07-31, run 12): this paragraph previously claimed `classify` catches a partial bump. It does not, on the path that matters.** `classify_publish_state` short-circuits on `if not tag_exists: return "publish_fresh"` before it ever compares versions, so that catch fires only when a tag already EXISTS — the resume path. On a fresh publish, which is every ordinary release and every first release, a lagging secondary manifest passes unnoticed, and the Traps section's own named consequence lands: a tag that installs a version string the tag does not name. Measured: `classify false "" <head> 1.4.3 1.1.0 false` → `publish_fresh`; the same disagreement with `tag_exists=true` → `abort_mismatch`. And **re-read from the file NOW — never the value Pre-flight read** (HIGH, adversarial review 2026-07-31, run 4): Pre-flight read that file before step 6 bumped it, so the carried value is the OLD version, and passing it makes `classify` return `abort_mismatch` — a terminal STOP — on a release where nothing whatsoever is wrong. `<tag_version>` is carried and `<manifest_version>` is re-read; the two arguments are sourced differently on purpose. And `<release_nondraft>` is `false` whenever `<tag_exists>` is `false` (classify short-circuits past it before it is ever consulted), otherwise `gh release view ${TAG_PREFIX}${VERSION} --json isDraft --jq '.isDraft'` **with its answer inverted**: that command prints `true` for a DRAFT release, so `<release_nondraft>` is `false` when it prints `true`, and `true` when it prints `false` (HIGH, run 4). Pass the bare literal `true` or `false` and nothing else — feeding the raw `--json` object `{"isDraft":false}` straight through makes every value coerce falsey, which silently renders `already_published` unreachable rather than failing loudly. **If that `gh` call fails for any reason other than a genuinely absent Release** — no remote configured, no network, `gh` unauthenticated — **STOP and surface it rather than passing a guessed value**: `gh` reports "no git remotes found" and "release not found" as the same non-zero exit, and this argument decides the one branch where the answer determines whether an already-published release is republished. **If the tag already exists, do not flatly abort — classify the state** with `classify_publish_state` via `"$PY" "<plugin-root>/hooks/_releaselib.py" classify <tag_exists> <tag_sha> <head_sha> <tag_version> <manifest_version> <release_nondraft>`: `publish_fresh` (the tag does not exist) → **write the ref now, and only here**: `git tag -a ${TAG_PREFIX}${VERSION} -F <message-file> --cleanup=verbatim` — never `-m` for multi-line content, never an interactive editor; this is the sole point in the whole lane at which a tag is created. **`--cleanup=verbatim` is load-bearing, not stylistic** (HIGH, adversarial review 2026-07-31, run 4; issue #569): `git tag`'s DEFAULT cleanup mode is `strip`, which deletes every line beginning with `#` as a comment. A Keep-a-Changelog section is composed entirely of `#`-prefixed headings, so the default silently destroys the `## [${VERSION}]` heading and every `### Added`/`### Fixed`/`### Performance`/`### Changed` grouping, leaving an undifferentiated bullet list that no longer says which version it describes. This has already happened to a real published tag in the repository that ships this skill. Because a published tag is immutable, a mangled message can never be repaired — only superseded by a new version. Then **verify it round-tripped, rather than trusting the flag** — dump what git actually STORED to a scratch file beside the message file and check its heading: `git cat-file tag ${TAG_PREFIX}${VERSION} > <stored-file>` — the raw tag object, not `git tag -l --format='%(contents)'`, which returns a RECONSTRUCTION that appends a trailing newline and so cannot detect a byte-level difference even in principle (LOW, run 5) — followed by `"$PY" "<plugin-root>/hooks/_releaselib.py" notes-match ${TAG_PREFIX}${VERSION} <stored-file>`, which must exit 0. (A file, not a pipe: `notes-match` takes a path, and a shell-specific stdin spelling would not survive the move to a Windows consumer.) `release_dates_consistent` cannot catch this on its own — it reads the changelog section from a separate argument and only needs the `Released-at:` line, both of which survive stripping — so a heading check against the ACTUAL stored message is the only guard that sees it; `abort_mismatch` (tag points at a non-HEAD commit, or its version disagrees with the manifest) → STOP; `already_published` (a non-draft Release already exists on the tag) → nothing to do; `resume_publish` (tag already at HEAD with the matching version but no Release) → skip re-tagging and resume at Phase 3 to create the missing Release — **this still requires explicit user authorization, exactly as a fresh publish does** (HIGH-4, adversarial review 2026-07-31): the local tag having been composed in a PRIOR invocation of Phase 2 is not itself authorization for Phase 3, which gates on it independently below; nothing about landing in `resume_publish` shortens, implies, or waives the Phase-3 STOP. (Whether the tag has actually been PUSHED to the remote — as opposed to existing only locally, the state Phase 2 itself is designed to leave behind — is not something `classify_publish_state` can tell from `tag_sha`/`head_sha`/`release_is_nondraft` alone; distinguishing it would need an added `git ls-remote` round-trip threaded through every classification call and CLI site for a case this authorization restatement already closes without one, so it is not implemented here.)
|
|
339
|
+
**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 "${TAG_PREFIX}${VERSION}" < "$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.
|
|
340
|
+
|
|
258
341
|
2. Report: `$TARGET`, version, bump rationale, the per-commit classification, the changelog section, and the tag SHA.
|
|
259
342
|
3. MUST NOT push the tag or create the GitHub Release here. Publication is Phase 3, a separate step the user authorizes after reading the report.
|
|
260
343
|
|
|
261
|
-
Gate: the annotated tag
|
|
344
|
+
Gate: the hosted publisher has composed the annotated tag in its isolated checkout and delivered the report. Nothing is published.
|
|
262
345
|
|
|
263
346
|
## Phase 3 — Publish · gate: STOP
|
|
264
347
|
|
|
265
|
-
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
|
|
348
|
+
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.
|
|
266
349
|
|
|
267
|
-
**
|
|
350
|
+
**A qualifying hosted release workflow is mandatory.** Its read-only preflight holds no write token,
|
|
351
|
+
resolves exactly one target, proves the candidate equals the fetched default-branch commit, and
|
|
352
|
+
requires green merge readiness for that exact SHA before any publisher starts. Where an automatic
|
|
353
|
+
post-CI cohort already exists, observe that run instead of dispatching a competing publication.
|
|
354
|
+
Otherwise dispatch the reviewed hosted lane from the default branch with `$TARGET`'s version in that
|
|
355
|
+
target's confirmation input and every other input blank. The steps below state the required hosted
|
|
356
|
+
publisher behavior and read-back; they are not authorization for a local tag push.
|
|
268
357
|
|
|
269
358
|
On authorization:
|
|
270
359
|
|
|
271
|
-
1.
|
|
360
|
+
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 `"$PY" "<plugin-root>/hooks/_releaselib.py" clean-tree-status "$TARGET"` to print nothing, export `VERSION RELEASE_TAG RELEASE_ASSET_DIR`, run `( eval "$RELEASE_BUILD" )`, require the same `"$PY" "<plugin-root>/hooks/_releaselib.py" clean-tree-status "$TARGET"` probe 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.
|
|
361
|
+
|
|
362
|
+
**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, require policy-aware `notes-match`, and require `dates-match "$PUBLISH_SECTION_FILE" <message-file> "$VERSION_POLICY" "$INITIAL_VERSION"`. 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, date-consistent, and every deterministic `gh release create` input is ready may step 1 push the tag. Phase 3 step 2 repeats the same reconstruction after the push as a read-back guard, but it is not the first validation.
|
|
363
|
+
|
|
364
|
+
1. The authorized hosted publisher pushes the tag: `git push origin ${TAG_PREFIX}${VERSION}`.
|
|
272
365
|
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>" "${TAG_PREFIX}${VERSION}" "$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 "${TAG_PREFIX}${VERSION}" <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 "${TAG_PREFIX}${VERSION}" --title "<title>" --notes-file <Phase-1 section file> --latest --verify-tag` when the row qualifies per the rule above, otherwise `gh release create "${TAG_PREFIX}${VERSION}" --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 `### 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. 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.
|
|
366
|
+
|
|
367
|
+
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.
|
|
273
368
|
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.
|
|
274
|
-
4. **Verify publication — never assume it.** Read the Release back: `gh release view $
|
|
275
|
-
5. **Record the tag's provenance, when the row declares one.** A git tag is a mutable ref, and the commit a tag was *originally* published at is not recoverable from the API once it moves
|
|
369
|
+
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.
|
|
370
|
+
5. **Record the tag's provenance, when the row declares one. A row declaring no `$PROVENANCE_MANIFEST` skips this step — say so explicitly in the report.** 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, for an annotated tag, 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")`; never pair a remote object with `git rev-parse` of a local tag. Require `TAG_COMMIT_SHA` to equal the exact hosted `HEAD` already qualified above. Add the tag to `$PROVENANCE_MANIFEST` under `tags` as `{"object_sha": <TAG_OBJECT_SHA>, "object_type": "tag", "commit_sha": <TAG_COMMIT_SHA>}`. Both identities now come from the same remote response, so a concurrent remote move cannot create an internally inconsistent witness. If this project runs an automated tag-drift check in CI against that file, an unrecorded tag is an unguarded tag there. The entry rides in a normal commit through `commit-gate` on the release branch.
|
|
371
|
+
|
|
372
|
+
6. **Clean up declared-asset scratch state only after publication and provenance handling succeed.** When assets were declared and steps 1 through 5 have all passed, 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 any later required step fails, preserve the directory and scratch files as failure evidence and report their exact paths; cleanup never runs on a partial or unverified publication.
|
|
373
|
+
|
|
374
|
+
Gate: with authorization, the tag is pushed AND a non-draft GitHub Release on that tag is confirmed by read-back (or, on failure, the exact manual command was surfaced and the half-finished state named), AND, when the row declares `$PROVENANCE_MANIFEST`, the tag's provenance is recorded there (or the report explicitly says the row declares none), AND any declared-asset scratch state is removed only after those gates pass. A failed or unverified publish is NOT a passing gate. Without authorization, nothing is published.
|
|
375
|
+
|
|
376
|
+
### Asset recovery for `resume_publish`
|
|
377
|
+
|
|
378
|
+
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.
|
|
379
|
+
|
|
380
|
+
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.
|
|
276
381
|
|
|
277
|
-
|
|
382
|
+
Before confirming or executing any declared input, require `"$PY" "<plugin-root>/hooks/_releaselib.py" clean-tree-status "$TARGET"` to print nothing, 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` and `release-build` inputs, and `"$PY" "<plugin-root>/hooks/_releaselib.py" run-pre-tag $TARGET` must pass against that clean tagged commit. Immediately after `run-pre-tag`, require the same target-aware `clean-tree-status` command 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.
|
|
278
383
|
|
|
279
384
|
## Recovering from a bad release
|
|
280
385
|
|
|
@@ -297,19 +402,20 @@ The one case that is **not** a correction: a tag pushed by mistake with **no** G
|
|
|
297
402
|
## Hard rules
|
|
298
403
|
|
|
299
404
|
- MUST resolve `$TARGET` to exactly one declared row before anything else, and MUST STOP on an unrecognised target rather than guessing which project was meant.
|
|
300
|
-
- MUST NOT tag on a red suite
|
|
405
|
+
- MUST NOT tag on a red suite. Local `commit-gate` evidence is impact-bounded and is not publication evidence; the mandatory hosted publisher must verify its configured merge-readiness aggregate is green for the exact fetched default-branch SHA before its write-token job starts.
|
|
301
406
|
- MUST NOT write to `main`, `master`, or the default branch, and MUST NOT force-push. Releases land through the normal branch/PR path.
|
|
302
|
-
- MUST NOT
|
|
303
|
-
- MUST
|
|
407
|
+
- MUST NOT compose or publish a tag from an unmerged release branch. Phase 1 ends at the PR boundary; only the qualifying hosted publisher may tag the merged, exact-head default-branch commit.
|
|
408
|
+
- MUST NOT push the tag or create the GitHub Release without explicit user authorization; they publish together in Phase 3, even after the hosted publisher composes the tag in its isolated checkout.
|
|
409
|
+
- MUST scope tag resolution, the commit window, and the bump derivation to `$TARGET`'s series and its argument-preserving payload pathspec set; another target's tag or commit MUST NOT influence this release's version, window, or changelog. MUST NOT resolve `LAST_TAG` with bare `git describe --tags`, and MUST take `$TAG_PREFIX` from the declared file rather than typing it.
|
|
304
410
|
- MUST assert the derived version equals every path declared in `$MANIFEST`. MUST NOT hand-edit a path also listed in `$GENERATED_MANIFEST` — regenerate it via the row's declared `generate` command instead.
|
|
305
411
|
- MUST rebuild `$ARTIFACTS` (via the row's declared `rebuild`, when one exists) and assert every committed bundle is clean before tagging; a target ships the built file, not its source.
|
|
306
412
|
- MUST run every declared `pre-tag` check, in declared order, and BLOCK on a non-zero exit (DECISION-0034) — this is how a target's own extra release surfaces (a badge, a catalog table) get asserted consistent; this skill assumes nothing about what any target's surfaces are beyond what the row declares.
|
|
307
413
|
- MUST verify the published Release by read-back (`gh release view` → non-draft, correct tag); a failed or unverified publish is not a passing gate.
|
|
308
414
|
- MUST NOT assert `--latest` for any target whose row does not declare `latest-eligible: true`, and not even then unless the tag is the newest release across every declared series.
|
|
309
415
|
- MUST use the Phase-1 changelog section verbatim as the GitHub Release notes — never re-derive or hand-write them.
|
|
310
|
-
- MUST NOT guess the version — derive it from the commit log, and
|
|
311
|
-
- MUST NOT auto-fill a missing `CHANGELOG:` footer, and MUST NOT tag past one —
|
|
416
|
+
- MUST NOT guess the version — derive it from the commit log through `derive-version` under the declared policy, and confirm it with `version-greater`, never by eye. The retained `apply-bump` and `semver-greater` commands are compatibility APIs for existing callers, not the policy-general release route.
|
|
417
|
+
- MUST NOT auto-fill a missing `CHANGELOG:` footer, and MUST NOT tag past one — an ordinary missing footer on a bumping commit is a Phase-1 BLOCK, surfaced as `[NEEDS-TRIAGE]` and stopped. The sole reconciliation path is a target-declared strict ledger entry for an exact full SHA already proven on the fetched default branch; short, mismatched, wrong-target, malformed, undeclared, or unpublished entries fail closed, and the entry may supply changelog text only.
|
|
312
418
|
- MUST NOT tag a non-bumping window — `test`/`docs`/`chore`/`ci`-only sets do not release.
|
|
313
419
|
- **MUST NOT move, retarget, delete, or re-point a published tag**, in any declared target's tag namespace, for any reason — no `git push --force` on a tag, no `git push --delete`, no `gh release delete`. A bad release is corrected by publishing a NEW version; see "Recovering from a bad release". There is no break-glass path.
|
|
314
420
|
- MUST record every newly published tag in the row's declared `$PROVENANCE_MANIFEST`, when one is declared, and MUST NOT edit an existing entry to silence a red tag-immutability drift check — a red run means a ref moved, and the manifest is the evidence of where it belonged.
|
|
315
|
-
- MUST NOT write, commit, or tag anything under `--dry-run` — it runs Pre-flight's STOP assertions and Phase 1 steps 1-4 only, then reports and stops; steps 5-
|
|
421
|
+
- MUST NOT write, commit, build assets, or tag anything under `--dry-run` — it runs Pre-flight's STOP assertions and Phase 1 steps 1-4 only, then reports and stops; steps 5-8 and every phase after Phase 1 do not run (#565). This includes not EXECUTING a declared `rebuild`, `generate`, `pre-tag`, or `release-build` command — list each by name instead.
|