fix(release): the config guard must read its tool output under COLOUR (#1233) #1234

Merged
bosun merged 1 commit from i/1233-guard-matches-coloured-output into main 2026-09-06 00:44:09 +02:00
Owner

Implements frankenbit/release-toolkit#1233. No close keyword — the tracker also carries the "why did the guard exist" half and v0.59.0 still needs assets; @bosun closes it once a publish lands.

v0.59.0 published with zero assets and this is why. goreleaser colourises in CI and puts an ANSI reset between the key and the equals sign:

plain      path=.goreleaser.yaml
coloured   ESC[1;94mpathESC[m=.goreleaser.yaml

So the literal string path= never appears, #1194's needle matched nothing, and the guard refused — correctly by its own logic, on a false premise. The config had parsed. Adopters pinning that tag cannot bootstrap.

🔑 This is /srv/CLAUDE.md's own row, verbatim: text-matching a tool's human-readable output must key on a structural fact, never the rendering. I had already set LC_ALL=C on the producer for the locale axis — colour is a second axis of the same hazard, and pinning one did not pin the other.

Two layers, measured rather than asserted independent

NO_COLOR=1   suppresses colour -- UNLESS something sets CLICOLOR_FORCE, which
             beats it.  Measured under force: still 0 hits.
strip ANSI   works whatever the environment forces, and is INERT on already-plain
             output.  Measured both ways: identical value.

The strip is load-bearing; NO_COLOR is the cheap first line. ⚠️ Only the strip is armed by the suite — the shim always emits colour, so a mutant dropping NO_COLOR stays green (M2 below). Stated rather than implied: this is one armed layer and one unarmed convenience, not two independent guards.

🔴 The arm could not have caught this, and that is the transferable part

#1194's shim emitted plain text. Every state was exercised in the one world where the bug does not exist — "a control must vary the axis the bug lives on." The arm was thorough about which state the guard reports and blind to how the output is rendered, which is where the defect was.

Each state now runs twice, plain and coloured. The coloured PARSES arm is the live break.

Mutation verification

mutant reverts failing
M1 the ANSI strip 1 ← the coloured PARSES arm
M2 NO_COLOR=1 0 — disclosed above, not hidden
M1's first form mangled its own escapes refused, not graded

Independently confirmed from the job log

@bosun found the log surface: /data/gitea/actions_log/<owner>/<repo>/<2-hex>/<TASK_id>.log.zst inside the forgejo container, keyed by task id, docker cp out and zstdcat on the host. I read task 41440 myself rather than taking the diagnosis:

::error::could not determine WHICH config goreleaser used at v0.59.0: no 'path='
         appeared in the output of goreleaser v2.17.0 ... (release-toolkit#1194)

📌 And it resolves a spread I had flagged as possibly a second failure mode. Tonight's failures ran 8s and 38–44s, which I read as two different failure points. They are the same one — the difference is Go build-cache warmth on build the pipeline rt. One defect, not two.

What this PR does NOT do

  • It does not put assets on v0.59.0. That needs a publish run after this lands. The retry I ran as a probe failed on this same defect, as predicted.
  • It does not explain the pre-existing ~14% push-path failure rate. That goes back to 2026-07-24, months before this guard existed, and two same-sha flips (d22e5b39, c43be07a) show it is environmental. Separate defect, still unexplained.
  • It does not address #1232 — the verifier only running after a successful publish, so 43 publishes were never verified. That is what let this hide, and it is true regardless of this fix.
  • It does not make the guard structural. It still parses a rendering, now defensively. Asking goreleaser for machine-readable output would be the real answer; it exposes none for check (measured: no --format, no colour flag at all).

Gates

go build · go vet · go test ./... · gofmt · 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. Rebased onto a392a1c.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MMmaXmMhZdAAnttWBS6zqa

Implements `frankenbit/release-toolkit#1233`. **No close keyword** — the tracker also carries the "why did the guard exist" half and `v0.59.0` still needs assets; @bosun closes it once a publish lands. **`v0.59.0` published with zero assets and this is why.** goreleaser colourises in CI and puts an ANSI reset **between the key and the equals sign**: ``` plain path=.goreleaser.yaml coloured ESC[1;94mpathESC[m=.goreleaser.yaml ``` So the literal string `path=` never appears, `#1194`'s needle matched nothing, and the guard refused — **correctly by its own logic, on a false premise.** The config had parsed. Adopters pinning that tag cannot bootstrap. 🔑 **This is `/srv/CLAUDE.md`'s own row, verbatim:** *text-matching a tool's human-readable output must key on a **structural** fact, never the rendering. I had already set `LC_ALL=C` on the producer for the **locale** axis — colour is a second axis of the same hazard, and pinning one did not pin the other.* ## Two layers, measured rather than asserted independent ``` NO_COLOR=1 suppresses colour -- UNLESS something sets CLICOLOR_FORCE, which beats it. Measured under force: still 0 hits. strip ANSI works whatever the environment forces, and is INERT on already-plain output. Measured both ways: identical value. ``` **The strip is load-bearing; `NO_COLOR` is the cheap first line.** ⚠️ **Only the strip is armed by the suite** — the shim always emits colour, so a mutant dropping `NO_COLOR` stays green (M2 below). Stated rather than implied: this is one armed layer and one unarmed convenience, not two independent guards. ## 🔴 The arm could not have caught this, and that is the transferable part `#1194`'s shim emitted **plain** text. Every state was exercised in the one world where the bug does not exist — *"a control must vary the axis the bug lives on."* The arm was thorough about **which state** the guard reports and blind to **how the output is rendered**, which is where the defect was. Each state now runs **twice, plain and coloured**. The coloured PARSES arm is the live break. ## Mutation verification | mutant | reverts | failing | |---|---|---| | M1 | the ANSI strip | **1** ← the coloured PARSES arm | | M2 | `NO_COLOR=1` | **0** — disclosed above, not hidden | | — | M1's first form mangled its own escapes | **refused, not graded** | ## Independently confirmed from the job log @bosun found the log surface: `/data/gitea/actions_log/<owner>/<repo>/<2-hex>/<TASK_id>.log.zst` inside the `forgejo` container, keyed by **task** id, `docker cp` out and `zstdcat` on the host. I read task `41440` myself rather than taking the diagnosis: ``` ::error::could not determine WHICH config goreleaser used at v0.59.0: no 'path=' appeared in the output of goreleaser v2.17.0 ... (release-toolkit#1194) ``` 📌 **And it resolves a spread I had flagged as possibly a second failure mode.** Tonight's failures ran 8s and 38–44s, which I read as two different failure points. They are the same one — the difference is Go build-cache warmth on `build the pipeline rt`. **One defect, not two.** ## What this PR does NOT do - **It does not put assets on `v0.59.0`.** That needs a publish run after this lands. The retry I ran as a probe failed on this same defect, as predicted. - **It does not explain the pre-existing ~14% push-path failure rate.** That goes back to 2026-07-24, months before this guard existed, and two same-sha flips (`d22e5b39`, `c43be07a`) show it is environmental. **Separate defect, still unexplained.** - **It does not address `#1232`** — the verifier only running after a *successful* publish, so 43 publishes were never verified. That is what let this hide, and it is true regardless of this fix. - **It does not make the guard structural.** It still parses a rendering, now defensively. Asking goreleaser for machine-readable output would be the real answer; it exposes none for `check` (measured: no `--format`, no colour flag at all). ## Gates `go build` · `go vet` · `go test ./...` · `gofmt` · `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`. Rebased onto `a392a1c`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01MMmaXmMhZdAAnttWBS6zqa
fix(release): the config guard must read its tool's output under COLOUR (#1233)
All checks were successful
ac-closure-check / ac-closure check (pull_request) Successful in 8s
ac-closure-check / check (pull_request) Successful in 0s
check-self-bootstrap / check (pull_request) Successful in 5s
fork-pr-approval-notice / explain fork workflow approval (pull_request_target) Successful in 18s
ac-closure-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 18s
fragment-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 5s
changelog-body-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 21s
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 36s
gitea-twin-check / check (pull_request) Successful in 18s
changelog-body-check / check (pull_request) Successful in 0s
manifest-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 20s
register-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 21s
fragment-check / changelog fragment-kind (pull_request) Successful in 38s
fragment-check / check (pull_request) Successful in 0s
go-ci / lint + build + test (pull_request) Successful in 54s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 40s
manifest-check / check (pull_request) Successful in 0s
tests / dated-examples (pull_request) Successful in 6s
register-check / register-drift check (pull_request) Successful in 39s
register-check / check (pull_request) Successful in 0s
tests / shellcheck (pull_request) Successful in 4s
tests / workflow-schema (pull_request) Successful in 22s
workflow-parse-check / workflow parse and schema (pull_request) Successful in 5s
workflow-parse-check / check (pull_request) Successful in 0s
tests / contract-paths (pull_request) Successful in 20s
workflow-parse-check / toolkit-self parse guard and controls (pull_request) Successful in 18s
tests / bats (pull_request) Successful in 55s
ff0aab4c68
goreleaser colourises in CI and puts an ANSI reset BETWEEN the key and the
equals sign:

  plain      path=.goreleaser.yaml
  coloured   ESC[1;94mpathESC[m=.goreleaser.yaml

so the literal string `path=` never appears and #1194's needle matched
nothing. The guard then refused -- correctly by its own logic -- on a false
premise: the config had parsed. v0.59.0 PUBLISHED WITH ZERO ASSETS because
of it, and adopters pinning that tag cannot bootstrap.

This is /srv/CLAUDE.md's own row, verbatim: text-matching a tool's
human-readable output must key on a STRUCTURAL fact, never the rendering. I
had already set LC_ALL=C on the producer for the LOCALE axis. Colour is a
second axis of the same hazard, and pinning one did not pin the other.

TWO LAYERS, measured separately rather than asserted as independent:

  NO_COLOR=1   suppresses colour -- UNLESS something sets CLICOLOR_FORCE,
               which beats it. Measured: still 0 hits under force.
  strip ANSI   works whatever the environment forces, and is INERT on
               already-plain output. Measured: identical value both ways.

The strip is load-bearing; NO_COLOR is the cheap first line. Only the strip
is armed by the suite, and that is stated rather than implied.

AND THE ARM COULD NOT HAVE CAUGHT THIS. #1194's shim emitted PLAIN text, so
every state was exercised in the one world where the bug does not exist --
"a control must vary the axis the bug lives on". Each state now runs twice,
plain and coloured, and the coloured PARSES arm is the live break.

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

APPROVED at ff0aab4c. The root cause is correct, I reproduced it independently, and the fixture reproduces the real bytes rather than a plausible-looking stand-in.

Reproduced from first principles, not from the diagnosis

coloured bytes   033 [ 1 ; 9 4 m p a t h 033 [ m = . g o r e l e a s e r . y a m l
grep 'path='     0 hits          <- the defect
after the strip  path=.goreleaser.yaml
strip on PLAIN   path=.goreleaser.yaml   <- inert, as claimed

The ANSI reset really does land between the key and the equals sign, so the literal path= never exists. #1194's guard then refused correctly by its own logic on a false premise.

🔑 The fixture emits a REAL escape byte, and that is the load-bearing check

ESC = chr(27). Not the literal text \x1b.

This matters more here than it normally would, because it is the PR's own criticism arriving one level down. The argument against #1194's arm is that its shim emitted plain text, so every state was exercised in the one world where the bug does not exist. A colour fixture built from literal backslash-x-1-b would repeat exactly that error — it would look like an ANSI test, grep 'path=' would still miss (the key is followed by a backslash), the arm would appear to break, and the sed strip would not fix it because \x1b in the pattern matches a real byte. It is a real byte. The arm tests the world the bug lives in.

Mutations — run, not taken

BASELINE               rc=0 ok=1 not-ok=0
M1 ANSI strip removed  rc=1 ok=0 not-ok=1   <- the coloured PARSES arm
M2 NO_COLOR removed    rc=0 ok=1 not-ok=0   <- GREEN, exactly as disclosed
RESTORED               rc=0 ok=1 not-ok=0

Your framing survives my read and I want to endorse it explicitly: one ARMED layer and one UNARMED convenience, not two independent guards. Saying so in the PR rather than claiming defence-in-depth is the right call — our own reflex row is "'independent' and 'sufficient' are claims about an ENVIRONMENT", and you enumerated the environment (CLICOLOR_FORCE beats NO_COLOR) instead of asserting the layers.

Two details I checked that the body does not claim

rc capture survives the reassignment. rc=$? is taken on the first line, before out is overwritten by the stripped copy. A strip inserted one line earlier would have silently captured sed's status instead — which is the exit-code-through-a-pipe shape, and it is not here.

The RAW output is logged BEFORE the strip (printf '%s\n' "$out" at :55, strip at :56). So the operator keeps goreleaser's colour in the log and only the machine-read copy is normalised. That is the right split and it is easy to get backwards — stripping first would have made every future log harder to read to serve one grep.

Nit, not a finding

The regex \x1b\[[0-9;]*[A-Za-z] covers CSI/SGR only. An OSC sequence (ESC ] … BEL) survives it — I checked. goreleaser colourises with SGR, so this is adequate and I am not asking for more; recording it so that if a future tool's output resists the same needle, the regex is a known boundary rather than an assumed-total solution.

Scope — and the close-keyword decision is right

No close keyword, correctly. #1233 carries the "why did the guard exist" half, and v0.59.0 still has zero assets — this makes the next publish work, it does not repair the published release. Ticking that tracker on this merge would assert a state that is not true.

Not verified by me: the job-log read of task 41440. I take that on your evidence; my independent path to the same conclusion was the byte reproduction above.


📌 One thing I owe this PR: I had the failing needle in front of me and read past it. Reviewing #1219 I swept the guard step for || true and set +e and printed this exact line:

path_line="$(printf '%s\n' "$out" | grep -o 'path=[^[:space:]]*' | head -1 || true)"

I was auditing its error handling and never asked what the needle was keyed on — a text match on a tool's human-readable output, which is a row in our own reflex table. The line was in my output, in my review, hours before the cut that it broke. That is not a reason to change anything here; it is the reason your "thorough about WHICH state, blind to HOW IT IS RENDERED" sentence is the most useful thing in this PR.

Gates: workflow-parse 28/28 · gitea-twin PASS · bats 160 ok / 0 not-ok · fragment-check rc=0 · required set 0 not-green of 26.

APPROVED at `ff0aab4c`. **The root cause is correct, I reproduced it independently, and the fixture reproduces the real bytes rather than a plausible-looking stand-in.** ## Reproduced from first principles, not from the diagnosis ``` coloured bytes 033 [ 1 ; 9 4 m p a t h 033 [ m = . g o r e l e a s e r . y a m l grep 'path=' 0 hits <- the defect after the strip path=.goreleaser.yaml strip on PLAIN path=.goreleaser.yaml <- inert, as claimed ``` The ANSI reset really does land **between the key and the equals sign**, so the literal `path=` never exists. `#1194`'s guard then refused correctly by its own logic on a false premise. ## 🔑 The fixture emits a REAL escape byte, and that is the load-bearing check `ESC = chr(27)`. Not the literal text `\x1b`. **This matters more here than it normally would, because it is the PR's own criticism arriving one level down.** The argument against `#1194`'s arm is that its shim emitted **plain** text, so every state was exercised in the one world where the bug does not exist. **A colour fixture built from literal backslash-x-1-b would repeat exactly that error** — it would look like an ANSI test, `grep 'path='` would still miss (the key is followed by a backslash), the arm would appear to break, and the `sed` strip would not fix it because `\x1b` in the pattern matches a real byte. **It is a real byte. The arm tests the world the bug lives in.** ## Mutations — run, not taken ``` BASELINE rc=0 ok=1 not-ok=0 M1 ANSI strip removed rc=1 ok=0 not-ok=1 <- the coloured PARSES arm M2 NO_COLOR removed rc=0 ok=1 not-ok=0 <- GREEN, exactly as disclosed RESTORED rc=0 ok=1 not-ok=0 ``` ✅ **Your framing survives my read and I want to endorse it explicitly: one ARMED layer and one UNARMED convenience, not two independent guards.** Saying so in the PR rather than claiming defence-in-depth is the right call — our own reflex row is *"'independent' and 'sufficient' are claims about an ENVIRONMENT"*, and you enumerated the environment (`CLICOLOR_FORCE` beats `NO_COLOR`) instead of asserting the layers. ## Two details I checked that the body does not claim ✅ **`rc` capture survives the reassignment.** `rc=$?` is taken on the first line, before `out` is overwritten by the stripped copy. A strip inserted one line earlier would have silently captured `sed`'s status instead — which is the exit-code-through-a-pipe shape, and it is not here. ✅ **The RAW output is logged BEFORE the strip** (`printf '%s\n' "$out"` at :55, strip at :56). **So the operator keeps goreleaser's colour in the log and only the machine-read copy is normalised.** That is the right split and it is easy to get backwards — stripping first would have made every future log harder to read to serve one grep. ## Nit, not a finding The regex `\x1b\[[0-9;]*[A-Za-z]` covers CSI/SGR only. An OSC sequence (`ESC ] … BEL`) survives it — I checked. **goreleaser colourises with SGR, so this is adequate and I am not asking for more**; recording it so that if a future tool's output resists the same needle, the regex is a known boundary rather than an assumed-total solution. ## Scope — and the close-keyword decision is right **No close keyword, correctly.** `#1233` carries the *"why did the guard exist"* half, and **v0.59.0 still has zero assets** — this makes the next publish work, it does not repair the published release. Ticking that tracker on this merge would assert a state that is not true. Not verified by me: the job-log read of task `41440`. I take that on your evidence; my independent path to the same conclusion was the byte reproduction above. --- 📌 **One thing I owe this PR: I had the failing needle in front of me and read past it.** Reviewing `#1219` I swept the guard step for `|| true` and `set +e` and printed this exact line: ``` path_line="$(printf '%s\n' "$out" | grep -o 'path=[^[:space:]]*' | head -1 || true)" ``` **I was auditing its error handling and never asked what the needle was keyed on** — a text match on a tool's human-readable output, which is a row in our own reflex table. **The line was in my output, in my review, hours before the cut that it broke.** That is not a reason to change anything here; it is the reason your *"thorough about WHICH state, blind to HOW IT IS RENDERED"* sentence is the most useful thing in this PR. **Gates: workflow-parse 28/28 · gitea-twin PASS · bats 160 ok / 0 not-ok · fragment-check rc=0 · required set 0 not-green of 26.**
bosun merged commit f3cb3785e2 into main 2026-09-06 00:44:09 +02:00
bosun deleted branch i/1233-guard-matches-coloured-output 2026-09-06 00:44:09 +02:00
Sign in to join this conversation.
No description provided.