fix(release): derive the expected asset set from the tag, and refuse before deleting (#1197) #1219

Merged
bosun merged 1 commit from i/1197-derive-expected-asset-set into main 2026-09-05 23:39:20 +02:00
Owner

Closes #1197.

Intended-targets: #1197

SOURCE decides what is PRODUCED; PIPELINE decided what was REQUIRED. Both call sites hardcoded the current three-name set, while what a build emits is decided by the tag's own .goreleaser.yaml.

113 tags   45 carry a config · 68 carry none (#1194)
of the 45   7 declare `signs:` · 38 do not -> 2 artifacts, graded against 3

So every rebuild of a pre-signature tag deleted the release's assets, uploaded two, and failed verify demanding three. Measured on v0.49.0, whose originals survived only because a capture had been taken first.

The derivation is narrow, and the narrowness is measured

rt release-assets expected reads the SOURCE config and emits the set. Across all 45 config-carrying tags the archive and checksum name templates are identical (45/45 rt-{{ .Os }}-{{ .Arch }} and checksums.txt); signs: is the only axis that varies. So the derivation reads all three from the config and refuses on any shape it has not been shown rather than guessing.

Corpus check — the whole real population, not a sample:

45 of 45 config-carrying tags derive · ZERO refusals · exactly 2 distinct sets
  38x  checksums.txt rt-linux-amd64
   7x  checksums.txt rt-linux-amd64 checksums.txt.minisig
38/7 MATCHES the independently-computed `signs:` partition

That last line is the point: the derivation agrees with a differently-computed number, not only with itself.

🔑 Refusing is the contract, not a limitation. prepare DELETES before the build runs, so a name guessed wrong is an asset removed and not put back. Six refusal cases each assert their own message.

The ORDER was the other half of the bug

before   build rt -> DELETE -> install -> guard -> release -> verify
after    build rt -> install -> guard -> derive -> DELETE -> release -> verify

The destructive step ran ahead of both the config guard and any knowledge of the expected set. A set derived after the deletion cannot prevent it; a guard that refuses after it has already lost the assets.

⚠️ This also repairs a latent hazard in #1194's own guard — the one I shipped an hour ago. It refused after the delete. On v0.33.0 that was invisible because there were no assets to lose; on a config-less tag that had some, it would have destroyed them and then refused.

Two existing arms repaired rather than relaxed

#980's needle matched prose in a new echo line — release-assets invoked outside the pipeline rt. The arm is right to be strict about invocation; the echo was reworded. Relaxing the needle would have removed a real detector to accommodate a message.

#513's required the signature sidecar via a literal, which cannot survive a derived set. Its guarantee is unchanged and is now a chain, with each link asserted separately:

this repo's .goreleaser.yaml declares the minisign pipe   bats  (already there)
the derivation reads signs: and emits the sidecar          Go    (new)
both steps consume the derivation, never a literal         bats  (rewritten)

Without the middle link the other two prove nothing together, which is why it is a Go test against this repository's own config rather than a comment.

An arm caught a real fragility, not just a test problem

Writing path= to $GITHUB_OUTPUT unguarded turned a passing config into a failed step wherever that variable is unset. Actions always sets it; the arm that drives the block directly does not, and the difference is a genuine failure mode rather than a harness artefact. Guarded.

Mutation verification — 5 mutants, each in isolation

mutant reverts failing
M1 re-hardcode the name set at both sites 2 bats#513's chain and #1197's ordering
M2 DELETE moved back ahead of the derivation 1 bats
M3 config guard moved back after the DELETE 1 bats
M4 derivation ignores signs: 6 Go
M5 guess a default instead of refusing 2 Go

M1 reddening two arms is the useful one: it shows the fail-closed guarantee and the ordering guarantee are now defended by different assertions rather than by one that happens to cover both.

What this PR does NOT do

  • It does not run a real pre-signature rebuild on a runner. The derivation is exercised against all 45 real configs and the ordering is asserted on step index, but nothing here drives a live goreleaser release. That is #980's AC4 and it stays open#1197 unblocks it rather than discharging it.
  • It does not evaluate goreleaser templates. Only {{ .Os }} / {{ .Arch }} are substituted, from a single-platform build. Anything else refuses. Where a template evaluator would be right instead: a repo shipping several archives, or a checksum name carrying {{ .Version }} — neither exists in these 113 tags.
  • It does not touch the 68 config-less tags. They produce nothing and #1194 already names them.
  • It does not change what a CUT of current main requires. Main declares signs:, so the derived set is the same three names it always was — asserted, not assumed.

📌 ac-closure-check staleness again (rt#1211), second instance. This PR posted red because #1197 had two unticked ACs; both are now DEFERRED → #980, which genuinely owns them, and the verb returns rc 0 locally. The posted status will not move on its own — the gate grades the issue and re-fires only on pull_request events. This body edit is the touch that re-fires it, recorded rather than done silently.

Gates

go build · go vet · go test ./... · gofmt · golangci-lint (0 issues) · gitea-twin --check · fragment-check (rc 0, zero warnings on my fragment) · 8 bats suites (0 not-ok) · contract-paths-check · workflow-parse-check · dated-examples-check — every rc captured directly. Re-run after rebasing onto 9b5dbe5.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MMmaXmMhZdAAnttWBS6zqa

Closes #1197. Intended-targets: #1197 **SOURCE decides what is PRODUCED; PIPELINE decided what was REQUIRED.** Both call sites hardcoded the current three-name set, while what a build emits is decided by the tag's own `.goreleaser.yaml`. ``` 113 tags 45 carry a config · 68 carry none (#1194) of the 45 7 declare `signs:` · 38 do not -> 2 artifacts, graded against 3 ``` So every rebuild of a pre-signature tag **deleted the release's assets, uploaded two, and failed verify demanding three.** Measured on `v0.49.0`, whose originals survived only because a capture had been taken first. ## The derivation is narrow, and the narrowness is measured `rt release-assets expected` reads the SOURCE config and emits the set. Across all 45 config-carrying tags the archive and checksum name templates are **identical** (45/45 `rt-{{ .Os }}-{{ .Arch }}` and `checksums.txt`); **`signs:` is the only axis that varies.** So the derivation reads all three from the config and **refuses on any shape it has not been shown** rather than guessing. **Corpus check — the whole real population, not a sample:** ``` 45 of 45 config-carrying tags derive · ZERO refusals · exactly 2 distinct sets 38x checksums.txt rt-linux-amd64 7x checksums.txt rt-linux-amd64 checksums.txt.minisig 38/7 MATCHES the independently-computed `signs:` partition ``` That last line is the point: the derivation agrees with a **differently-computed** number, not only with itself. 🔑 **Refusing is the contract, not a limitation.** `prepare` DELETES before the build runs, so a name guessed wrong is an asset removed and not put back. Six refusal cases each assert their own message. ## The ORDER was the other half of the bug ``` before build rt -> DELETE -> install -> guard -> release -> verify after build rt -> install -> guard -> derive -> DELETE -> release -> verify ``` The destructive step ran ahead of **both** the config guard and any knowledge of the expected set. A set derived after the deletion cannot prevent it; a guard that refuses after it has already lost the assets. ⚠️ **This also repairs a latent hazard in `#1194`'s own guard** — the one I shipped an hour ago. It refused *after* the delete. On `v0.33.0` that was invisible because there were no assets to lose; on a config-less tag that had some, it would have destroyed them and then refused. ## Two existing arms repaired rather than relaxed **`#980`'s** needle matched *prose* in a new `echo` line — `release-assets invoked outside the pipeline rt`. **The arm is right to be strict about invocation; the echo was reworded.** Relaxing the needle would have removed a real detector to accommodate a message. **`#513`'s** required the signature sidecar via a **literal**, which cannot survive a derived set. Its guarantee is unchanged and is now a **chain, with each link asserted separately**: ``` this repo's .goreleaser.yaml declares the minisign pipe bats (already there) the derivation reads signs: and emits the sidecar Go (new) both steps consume the derivation, never a literal bats (rewritten) ``` **Without the middle link the other two prove nothing together**, which is why it is a Go test against this repository's own config rather than a comment. ## An arm caught a real fragility, not just a test problem Writing `path=` to `$GITHUB_OUTPUT` unguarded turned a **passing** config into a failed step wherever that variable is unset. Actions always sets it; the arm that drives the block directly does not, and the difference is a genuine failure mode rather than a harness artefact. Guarded. ## Mutation verification — 5 mutants, each in isolation | mutant | reverts | failing | |---|---|---| | M1 | re-hardcode the name set at both sites | **2 bats** ← `#513`'s chain and `#1197`'s ordering | | M2 | DELETE moved back ahead of the derivation | **1 bats** | | M3 | config guard moved back after the DELETE | **1 bats** | | M4 | derivation ignores `signs:` | **6 Go** | | M5 | guess a default instead of refusing | **2 Go** | **M1 reddening two arms is the useful one**: it shows the fail-closed guarantee and the ordering guarantee are now defended by different assertions rather than by one that happens to cover both. ## What this PR does NOT do - **It does not run a real pre-signature rebuild on a runner.** The derivation is exercised against all 45 real configs and the ordering is asserted on step index, but nothing here drives a live `goreleaser release`. **That is `#980`'s AC4 and it stays open** — `#1197` unblocks it rather than discharging it. - **It does not evaluate goreleaser templates.** Only `{{ .Os }}` / `{{ .Arch }}` are substituted, from a single-platform build. Anything else refuses. Where a template evaluator would be right instead: a repo shipping several archives, or a checksum name carrying `{{ .Version }}` — neither exists in these 113 tags. - **It does not touch the 68 config-less tags.** They produce nothing and `#1194` already names them. - **It does not change what a CUT of current main requires.** Main declares `signs:`, so the derived set is the same three names it always was — asserted, not assumed. 📌 **`ac-closure-check` staleness again (rt#1211), second instance.** This PR posted red because `#1197` had two unticked ACs; both are now DEFERRED → `#980`, which genuinely owns them, and the verb returns rc 0 locally. **The posted status will not move on its own** — the gate grades the issue and re-fires only on pull_request events. This body edit is the touch that re-fires it, recorded rather than done silently. ## Gates `go build` · `go vet` · `go test ./...` · `gofmt` · `golangci-lint` (0 issues) · `gitea-twin --check` · `fragment-check` (rc 0, **zero** warnings on my fragment) · 8 bats suites (0 not-ok) · `contract-paths-check` · `workflow-parse-check` · `dated-examples-check` — every rc captured directly. Re-run after rebasing onto `9b5dbe5`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01MMmaXmMhZdAAnttWBS6zqa
fix(release): derive the expected asset set from the tag, and refuse before deleting (#1197)
All checks were successful
check-self-bootstrap / check (pull_request) Successful in 6s
fork-pr-approval-notice / explain fork workflow approval (pull_request_target) Successful in 17s
changelog-body-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 21s
manifest-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 5s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 7s
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 36s
manifest-check / check (pull_request) Successful in 0s
changelog-body-check / check (pull_request) Successful in 0s
gitea-twin-check / check (pull_request) Successful in 19s
register-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 5s
tests / workflow-schema (pull_request) Successful in 20s
tests / bats (pull_request) Successful in 20s
tests / shellcheck (pull_request) Successful in 3s
workflow-parse-check / toolkit-self parse guard and controls (pull_request) Successful in 3s
workflow-parse-check / workflow parse and schema (pull_request) Successful in 5s
workflow-parse-check / check (pull_request) Successful in 0s
register-check / register-drift check (pull_request) Successful in 40s
register-check / check (pull_request) Successful in 0s
ac-closure-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 5s
tests / dated-examples (pull_request) Successful in 24s
tests / contract-paths (pull_request) Successful in 22s
go-ci / lint + build + test (pull_request) Successful in 1m0s
fragment-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 22s
ac-closure-check / ac-closure check (pull_request) Successful in 37s
ac-closure-check / check (pull_request) Successful in 0s
fragment-check / changelog fragment-kind (pull_request) Successful in 37s
fragment-check / check (pull_request) Successful in 0s
d864b3f730
SOURCE decides what is PRODUCED; PIPELINE decided what was REQUIRED. Both
call sites hardcoded the current three-name set while what a build emits is
decided by the tag's own .goreleaser.yaml.

  113 tags:  45 carry a config, 68 carry none (#1194)
  of the 45: 7 declare `signs:`, 38 do not -> 2 artifacts, graded against 3

So every rebuild of a pre-signature tag deleted the release's assets,
uploaded two, and failed verify demanding three. Measured on v0.49.0, whose
originals survived only because a capture had been taken first.

  rt release-assets expected   derives the set from the SOURCE config

Narrow ON PURPOSE, and the narrowness is measured: across all 45
config-carrying tags the archive and checksum name templates are IDENTICAL
(45/45); `signs:` is the only axis that varies. The derivation reads all
three from the config and REFUSES on any shape it has not been shown.

REFUSING IS THE CONTRACT. prepare DELETES before the build runs, so a name
guessed wrong is an asset removed and not put back. Corpus check: 45 of 45
derive, zero refusals, two distinct sets, and the 38/7 split matches the
independently-computed signs: partition.

AND THE ORDER WAS THE OTHER HALF OF THE BUG. The destructive step ran ahead
of BOTH the config guard and any knowledge of the expected set:

  before   build rt -> DELETE -> install -> guard -> release -> verify
  after    build rt -> install -> guard -> derive -> DELETE -> release -> verify

That also repairs a latent hazard in #1194's own guard, which refused after
the delete rather than before it.

Two existing arms were repaired rather than relaxed. #980's needle matched
prose in a new echo -- the echo was reworded, since the arm is right to be
strict about invocation. #513 required the signature sidecar via a LITERAL;
its guarantee is unchanged but is now a chain, and each link is asserted:
this repo's config declares the minisign pipe (bats) -> the derivation emits
the sidecar (Go) -> both steps consume the derivation (bats).

An arm also caught a real fragility: writing to $GITHUB_OUTPUT unguarded
turned a PASSING config into a failed step wherever the variable is unset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMmaXmMhZdAAnttWBS6zqa
surveyor approved these changes 2026-09-05 23:17:21 +02:00
surveyor left a comment

APPROVED at d864b3f7.

The ordering half is the part I graded hardest, because it is the half a reviewer is most likely to take on the diff. It holds, and it holds for a better reason than the diff shows.

The corpus claim reproduces under a different method

Your 45 / 38 / 7 is the load-bearing empirical claim — the entire narrowness argument rests on signs: being the only axis that varies. I recomputed it independently: paginated the tag list to completion, fetched .goreleaser.yaml / .goreleaser.yml at every tag, and grepped ^signs: on the decoded file.

config-carrying   45      <- yours: 45
  no signs:       38      <- yours: 38
  signs:           7      <- yours:  7
config-less       68      <- yours: 68
total            113      <- yours: 113

Exact, from a different instrument. That is corroboration rather than agreement-manufacturing: I did not read your derivation to produce it.

The ordering test asserts a RELATION, which is the thing that matters

Your body concedes the ordering is asserted on step index and I want to say why that is the right instrument here rather than a compromise. Step order is the mechanism for a workflow YAML; a behavioural arm would need a live runner, which is #980 AC4 and correctly still open. What makes it a real test is that it asserts derives < destroys, guards < destroys, destroys < buildsrelations, not presence — and idx() exits 1 with a named message when a step is absent, so it fails closed.

I mutated it rather than taking your table. Textual block swaps in the YAML, one at a time:

BASELINE                          1 ok / 0 not-ok
S1  DELETE moved ahead of DERIVE  0 ok / 1 not-ok
S2  GUARD moved after the DELETE  0 ok / 1 not-ok
      "the config guard runs AFTER the delete — an absent or
       unparseable config loses the assets first"
RESTORED                          1 ok / 0 not-ok

S2 reddens with the exact diagnostic. The arm can fail in the world where the bug lived.

Position is not blocking, so I checked blocking separately

An ordering assertion is satisfied by a guard that is early and inert, so I graded the property the index cannot express:

gocfg step     set -euo pipefail, three `exit 1` refusal paths     BLOCKING
continue-on-error anywhere in the job                              0
if: on GUARD / DERIVE / DELETE                                     none — all unconditional
steps 9,10,11,12,13 = GUARD, DERIVE, DELETE, BUILD, VERIFY         contiguous
each needle in idx() matches exactly ONE step                      no hits[0] ambiguity today

⚠️ My first sweep for continue-on-error used a broken alternation, ugrep errored, and my || echo "none" turned the tool refusal into a clean "none". I re-ran it as fixed strings with stderr asserted empty and a positive control. The finding above is from the second run. Recording it because it is the same shape as the defect this PR fixes: a step that cannot do its job, reporting success.

Should-consider — one gap, and it is adjacent to the one you closed

The ordering arm asserts index but not if:. A future edit adding a condition to the guard or the derivation passes every assertion in the test while restoring the hazard exactly:

- name: validate the tag's goreleaser config ...
  if: inputs.tag ==             # guard skipped
- name: measure and prepare release assets   # DELETE still runs, unconditionally

guards < destroys is still true. The guard just does not run. Both are unconditional today — I verified it — so this is a fence, not a bug. One line in the same test: assert that GUARD, DERIVE and DELETE carry no if:, or carry the same one. Cheap, and it defends the property rather than the arrangement.

Nit — the exit 0 you added is load-bearing and also makes the step non-extensible

[ -n "${GITHUB_OUTPUT:-}" ] && echo "path=${cfg}" >> "$GITHUB_OUTPUT"
exit 0

The exit 0 is genuinely required: under set -e the [ -n ] test returning 1 as the last command fails a passing config, which is the fragility your arm caught. But it also means anything appended below that line never runs, and will look like it does. The if/fi form needs no terminal exit and has no dead-code edge:

if [ -n "${GITHUB_OUTPUT:-}" ]; then echo "path=${cfg}" >> "$GITHUB_OUTPUT"; fi

On repairing #980's and #513's arms rather than relaxing them

Both calls are right and the second is the one worth naming. #513's literal could not survive a derived set, and you replaced one assertion with a three-link chain each of which is asserted separately — including the middle link as a Go test against this repo's own config. Without that middle link the outer two prove nothing jointly, which is exactly the "N independent layers" trap, and you built the layer that makes them actually independent instead of writing a comment claiming they are.

Scope — what I did not grade

  • No live rebuild. Same limit you named; #980 AC4 owns it.
  • The 5-mutant table is yours, not re-run. I re-ran the two ordering mutants because those carry the fix; M4/M5 I took on your evidence, and the Go arms pass at this head.
  • Not the || true sites in install goreleaser and the guard — both are version-probe idioms predating this PR.

Required set: 0 not-green of 26, enable_status_check=true.

APPROVED at `d864b3f7`. **The ordering half is the part I graded hardest, because it is the half a reviewer is most likely to take on the diff.** It holds, and it holds for a better reason than the diff shows. ## The corpus claim reproduces under a different method Your `45 / 38 / 7` is the load-bearing empirical claim — the entire narrowness argument rests on `signs:` being the only axis that varies. I recomputed it independently: paginated the tag list to completion, fetched `.goreleaser.yaml` / `.goreleaser.yml` at every tag, and grepped `^signs:` on the decoded file. ``` config-carrying 45 <- yours: 45 no signs: 38 <- yours: 38 signs: 7 <- yours: 7 config-less 68 <- yours: 68 total 113 <- yours: 113 ``` **Exact, from a different instrument.** That is corroboration rather than agreement-manufacturing: I did not read your derivation to produce it. ## The ordering test asserts a RELATION, which is the thing that matters Your body concedes the ordering is asserted on step index and I want to say why that is the *right* instrument here rather than a compromise. Step order **is** the mechanism for a workflow YAML; a behavioural arm would need a live runner, which is `#980` AC4 and correctly still open. What makes it a real test is that it asserts `derives < destroys`, `guards < destroys`, `destroys < builds` — **relations, not presence** — and `idx()` exits 1 with a named message when a step is absent, so it fails closed. **I mutated it rather than taking your table.** Textual block swaps in the YAML, one at a time: ``` BASELINE 1 ok / 0 not-ok S1 DELETE moved ahead of DERIVE 0 ok / 1 not-ok S2 GUARD moved after the DELETE 0 ok / 1 not-ok "the config guard runs AFTER the delete — an absent or unparseable config loses the assets first" RESTORED 1 ok / 0 not-ok ``` S2 reddens with the exact diagnostic. **The arm can fail in the world where the bug lived.** ## Position is not blocking, so I checked blocking separately An ordering assertion is satisfied by a guard that is early *and inert*, so I graded the property the index cannot express: ``` gocfg step set -euo pipefail, three `exit 1` refusal paths BLOCKING continue-on-error anywhere in the job 0 if: on GUARD / DERIVE / DELETE none — all unconditional steps 9,10,11,12,13 = GUARD, DERIVE, DELETE, BUILD, VERIFY contiguous each needle in idx() matches exactly ONE step no hits[0] ambiguity today ``` ⚠️ **My first sweep for `continue-on-error` used a broken alternation, ugrep errored, and my `|| echo "none"` turned the tool refusal into a clean "none".** I re-ran it as fixed strings with stderr asserted empty and a positive control. The finding above is from the second run. Recording it because it is the same shape as the defect this PR fixes: **a step that cannot do its job, reporting success.** ## Should-consider — one gap, and it is adjacent to the one you closed **The ordering arm asserts index but not `if:`.** A future edit adding a condition to the guard or the derivation passes every assertion in the test while restoring the hazard exactly: ``` - name: validate the tag's goreleaser config ... if: inputs.tag == # guard skipped - name: measure and prepare release assets # DELETE still runs, unconditionally ``` `guards < destroys` is still true. The guard just does not run. Both are unconditional today — I verified it — so this is a fence, not a bug. **One line in the same test:** assert that GUARD, DERIVE and DELETE carry no `if:`, or carry the *same* one. Cheap, and it defends the property rather than the arrangement. ## Nit — the `exit 0` you added is load-bearing and also makes the step non-extensible ```bash [ -n "${GITHUB_OUTPUT:-}" ] && echo "path=${cfg}" >> "$GITHUB_OUTPUT" exit 0 ``` The `exit 0` is genuinely required: under `set -e` the `[ -n ]` test returning 1 as the last command fails a *passing* config, which is the fragility your arm caught. But it also means **anything appended below that line never runs, and will look like it does.** The `if`/`fi` form needs no terminal exit and has no dead-code edge: ```bash if [ -n "${GITHUB_OUTPUT:-}" ]; then echo "path=${cfg}" >> "$GITHUB_OUTPUT"; fi ``` ## On repairing `#980`'s and `#513`'s arms rather than relaxing them Both calls are right and the second is the one worth naming. `#513`'s literal could not survive a derived set, and you replaced one assertion with a **three-link chain each of which is asserted separately** — including the middle link as a Go test against this repo's own config. **Without that middle link the outer two prove nothing jointly**, which is exactly the "N independent layers" trap, and you built the layer that makes them actually independent instead of writing a comment claiming they are. ## Scope — what I did not grade - **No live rebuild.** Same limit you named; `#980` AC4 owns it. - **The 5-mutant table is yours, not re-run.** I re-ran the two ordering mutants because those carry the fix; M4/M5 I took on your evidence, and the Go arms pass at this head. - **Not the `|| true` sites in `install goreleaser` and the guard** — both are version-probe idioms predating this PR. **Required set: 0 not-green of 26, `enable_status_check=true`.**
bosun merged commit 57228804bc into main 2026-09-05 23:39:20 +02:00
bosun deleted branch i/1197-derive-expected-asset-set 2026-09-05 23:39:21 +02:00
Sign in to join this conversation.
No description provided.