fix(release): an ABSENT goreleaser config is not a config that parses (#1194) #1207

Merged
bosun merged 1 commit from i/1194-absent-config-is-not-parses into main 2026-09-05 22:50:52 +02:00
Owner

Closes #1194.

Intended-targets: #1194

goreleaser check returns rc=0 both when the config parses and when there is none — it falls back to built-in defaults and reports success. The guard read only the exit code, so a tag predating the release pipeline was reported as one whose config is fine, and the operator was sent to fix a file that does not exist.

v0.33.0 (no config)   rc=0   path=                   "could not find a configuration file"
main    (config)      rc=0   path=.goreleaser.yaml

🔑 The discriminator was already being printed and was not being read.

The design call: read path=, do not test for the file

The obvious repair is [ -f .goreleaser.yaml ]. Measured, that is wrong — goreleaser accepts four default names, and each is reported through the same field:

<none>              rc=1  path=                     "could not find a configuration file"
.goreleaser.yaml    rc=1  path=.goreleaser.yaml
.goreleaser.yml     rc=1  path=.goreleaser.yml
goreleaser.yaml     rc=1  path=goreleaser.yaml
goreleaser.yml      rc=1  path=goreleaser.yml

(rc=1 across that table is my deliberately-minimal probe config failing validation; the column that matters is path=.)

A [ -f .goreleaser.yaml ] test reports ABSENT for a repo using the .yml spelling — a fresh instance of this same defect. path= is goreleaser's own answer about which file it resolved, and that search list is goreleaser's to change, not ours. Where Y would be right instead: if goreleaser ever stopped printing path=, or if we passed --config explicitly and owned the resolution ourselves.

Four states, not two

path= present, rc=0      PARSES        pass, naming the file it graded
path= present, rc!=0     DOES NOT PARSE the existing named error, now naming the file
path= EMPTY              ABSENT        NEW — the tag predates the pipeline
no path= line at all     CANNOT GRADE  NEW — refuses rather than guessing

⚠️ The fourth exists because of the third. A goreleaser that stops printing path= must not silently render as "this tag has no config" — that is #1194 again, one version upgrade later. It refuses instead.

📌 The pass message now names the file (.goreleaser.yaml at v0.57.0 parses) rather than asserting that one exists, so a reader can see what was graded.

An existing arm broke, and was repaired rather than relaxed

#980's arm keys on the invocation rather than the substring — deliberately, because this step's own success message contains the words "goreleaser check", and its comment records that a mutation replacing the call with if true had left a substring needle green. Moving the call into a command substitution broke that needle.

The needle now strips comment and echo lines, then matches a command position (line start, after if, inside $(, with optional VAR=VAL prefixes). Re-proved against the exact mutation it was written for: M3 below reddens it.

🔴 A mutation found an inert assertion in MY OWN new arm

M5 changed "This is NOT a config-migration problem" into "This may be a config-migration problem" — the precise inversion of AC2 — and the suite stayed green.

My arm tested "config migration" in out with a space, while the text is hyphenated. It never matched the baseline, so it could not have caught a regression either. Replaced with the two substantive halves of AC2: the absent case must name what is true (predates the release pipeline) and must not hand over the parse branch's remedy (older goreleaser / config migration on a re-tag). M5-redux and M6 now redden.

Mutation verification — 7 mutants, each in isolation

mutant reverts failing arms
M1 the whole repair — rc-only, two states 1
M2 missing path= read as ABSENT 1
M3 the invocation replaced with if true 2 ← incl. #980's pre-existing arm
M4 ABSENT reuses the parse message 1
M5 (original) absent-case remedy inverted 0 → 1 after the arm was repaired
M6 ABSENT drops the "predates" statement 1
M4's first needle did not apply refused, not graded

M3 is the one that matters most, because it proves I did not weaken an existing guard while making its needle match my change. M5 is the one worth reading, because the arm that found it was mine and it was inert.

The arm runs the SHIPPED bytes

The step's run: block is extracted from the YAML and executed against a shimmed goreleaser — not a copy of the logic. That is the #1163 lesson: an arm that grades a helper stays green while the shipped wiring is broken.

⚠️ The shim's blind spot, stated because it is real: the stub reproduces goreleaser's observed output format. If a future goreleaser changes it, this arm still passes while production changes behaviour. That is exactly what the could-not-grade branch is for, and it is armed (M2) rather than assumed.

What this PR does NOT do

  • It does not make the six pre-v0.34.0 releases rebuildable. Nothing can — they predate the pipeline and never had assets. This makes the run say so instead of reporting a config that parses.
  • It does not touch #1197 (the rebuild deletes assets then fails verify on every pre-signature tag). Different defect, different tracker, and #980 is blocked on that one rather than this one.
  • It does not verify against a real old tag on a runner. The four states are exercised through a shim; the measurements that define them were taken on real worktrees (v0.33.0 and main) and are in #1194.
  • It does not add a twin. goreleaser.yml declares only push and workflow_dispatch, so it is not referenceable and correctly has none — gitea-twin --check rc 0 is honest here rather than vacuous.

📌 A note on ac-closure-check, now that it is REQUIRED

This PR went red on it, correctly: #1194's three ACs were unticked. They are ticked now with evidence.

⚠️ But ticking them did not clear the gate. ac-closure-check reads the issue's AC state and re-runs only on pull_request events, so an issue edit cannot re-fire it. The status stayed failure at its original timestamp while the condition it reports had already been fixed. Clearing it needs a touch on the PR itself — this edit is that touch.

That was harmless while the context was advisory. Now that it blocks a merge, a PR can sit red on a condition that no longer exists, and the remedy (edit the PR to re-fire a gate about a different object) is not discoverable from the failure message.

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 — every rc captured directly, never through a pipe. Re-run after rebasing onto 2352188.

📌 Opened after the promotion of 13 advisory contexts to required (#1177), so this is among the first PRs facing 26. All the newly-required gates were run locally above.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MMmaXmMhZdAAnttWBS6zqa

Closes #1194. Intended-targets: #1194 `goreleaser check` returns `rc=0` **both** when the config parses and when there is none — it falls back to built-in defaults and reports success. The guard read only the exit code, so a tag predating the release pipeline was reported as one whose config is fine, and the operator was sent to fix a file that does not exist. ``` v0.33.0 (no config) rc=0 path= "could not find a configuration file" main (config) rc=0 path=.goreleaser.yaml ``` 🔑 **The discriminator was already being printed and was not being read.** ## The design call: read `path=`, do not test for the file The obvious repair is `[ -f .goreleaser.yaml ]`. **Measured, that is wrong** — goreleaser accepts four default names, and each is reported through the same field: ``` <none> rc=1 path= "could not find a configuration file" .goreleaser.yaml rc=1 path=.goreleaser.yaml .goreleaser.yml rc=1 path=.goreleaser.yml goreleaser.yaml rc=1 path=goreleaser.yaml goreleaser.yml rc=1 path=goreleaser.yml ``` *(rc=1 across that table is my deliberately-minimal probe config failing validation; the column that matters is `path=`.)* **A `[ -f .goreleaser.yaml ]` test reports ABSENT for a repo using the `.yml` spelling — a fresh instance of this same defect.** `path=` is goreleaser's own answer about which file it resolved, and that search list is goreleaser's to change, not ours. Where Y would be right instead: if goreleaser ever stopped printing `path=`, or if we passed `--config` explicitly and owned the resolution ourselves. ## Four states, not two ``` path= present, rc=0 PARSES pass, naming the file it graded path= present, rc!=0 DOES NOT PARSE the existing named error, now naming the file path= EMPTY ABSENT NEW — the tag predates the pipeline no path= line at all CANNOT GRADE NEW — refuses rather than guessing ``` ⚠️ **The fourth exists because of the third.** A goreleaser that stops printing `path=` must not silently render as *"this tag has no config"* — that is `#1194` again, one version upgrade later. It refuses instead. 📌 **The pass message now names the file** (`.goreleaser.yaml at v0.57.0 parses`) rather than asserting that one exists, so a reader can see *what* was graded. ## An existing arm broke, and was repaired rather than relaxed `#980`'s arm keys on the **invocation** rather than the substring — deliberately, because this step's own success message contains the words *"goreleaser check"*, and its comment records that a mutation replacing the call with `if true` had left a substring needle green. Moving the call into a command substitution broke that needle. **The needle now strips comment and `echo` lines, then matches a command position** (line start, after `if`, inside `$(`, with optional `VAR=VAL` prefixes). ✅ **Re-proved against the exact mutation it was written for:** M3 below reddens it. ## 🔴 A mutation found an inert assertion in MY OWN new arm M5 changed *"This is **NOT** a config-migration problem"* into *"This **may be** a config-migration problem"* — the precise inversion of AC2 — and **the suite stayed green.** My arm tested `"config migration" in out` with a **space**, while the text is hyphenated. **It never matched the baseline, so it could not have caught a regression either.** Replaced with the two substantive halves of AC2: the absent case must **name what is true** (`predates the release pipeline`) and must **not** hand over the parse branch's remedy (`older goreleaser` / `config migration on a re-tag`). M5-redux and M6 now redden. ## Mutation verification — 7 mutants, each in isolation | mutant | reverts | failing arms | |---|---|---| | M1 | the whole repair — rc-only, two states | **1** | | M2 | missing `path=` read as ABSENT | **1** | | M3 | the invocation replaced with `if true` | **2** ← incl. `#980`'s pre-existing arm | | M4 | ABSENT reuses the parse message | **1** | | M5 | *(original)* absent-case remedy inverted | **0 → 1** after the arm was repaired | | M6 | ABSENT drops the "predates" statement | **1** | | — | M4's first needle did not apply | **refused, not graded** | **M3 is the one that matters most**, because it proves I did not weaken an existing guard while making its needle match my change. **M5 is the one worth reading**, because the arm that found it was mine and it was inert. ## The arm runs the SHIPPED bytes The step's `run:` block is extracted from the YAML and executed against a **shimmed `goreleaser`** — not a copy of the logic. That is the `#1163` lesson: an arm that grades a helper stays green while the shipped wiring is broken. ⚠️ **The shim's blind spot, stated because it is real:** the stub reproduces goreleaser's *observed* output format. If a future goreleaser changes it, this arm still passes while production changes behaviour. **That is exactly what the could-not-grade branch is for**, and it is armed (M2) rather than assumed. ## What this PR does NOT do - **It does not make the six pre-`v0.34.0` releases rebuildable.** Nothing can — they predate the pipeline and never had assets. This makes the run *say so* instead of reporting a config that parses. - **It does not touch `#1197`** (the rebuild deletes assets then fails verify on every pre-signature tag). Different defect, different tracker, and `#980` is blocked on that one rather than this one. - **It does not verify against a real old tag on a runner.** The four states are exercised through a shim; the measurements that define them were taken on real worktrees (`v0.33.0` and `main`) and are in `#1194`. - **It does not add a twin.** `goreleaser.yml` declares only `push` and `workflow_dispatch`, so it is not referenceable and correctly has none — `gitea-twin --check` rc 0 is honest here rather than vacuous. ## 📌 A note on `ac-closure-check`, now that it is REQUIRED This PR went red on it, correctly: `#1194`'s three ACs were unticked. They are ticked now with evidence. ⚠️ **But ticking them did not clear the gate.** `ac-closure-check` reads the **issue's** AC state and re-runs only on **pull_request** events, so an issue edit cannot re-fire it. The status stayed `failure` at its original timestamp while the condition it reports had already been fixed. Clearing it needs a touch on the PR itself — this edit is that touch. **That was harmless while the context was advisory. Now that it blocks a merge, a PR can sit red on a condition that no longer exists**, and the remedy (edit the PR to re-fire a gate about a different object) is not discoverable from the failure message. ## 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` — every rc captured directly, never through a pipe. Re-run after rebasing onto `2352188`. 📌 Opened after the promotion of 13 advisory contexts to required (`#1177`), so this is among the first PRs facing 26. All the newly-required gates were run locally above. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01MMmaXmMhZdAAnttWBS6zqa
fix(release): an ABSENT goreleaser config is not a config that parses (#1194)
All checks were successful
register-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 21s
tests / bats (pull_request) Successful in 20s
tests / shellcheck (pull_request) Successful in 3s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 42s
manifest-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 4s
tests / dated-examples (pull_request) Successful in 26s
register-check / register-drift check (pull_request) Successful in 42s
register-check / check (pull_request) Successful in 0s
workflow-parse-check / workflow parse and schema (pull_request) Successful in 24s
workflow-parse-check / check (pull_request) Successful in 0s
fragment-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 6s
ac-closure-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 18s
ac-closure-check / ac-closure check (pull_request) Successful in 36s
fragment-check / changelog fragment-kind (pull_request) Successful in 36s
ac-closure-check / check (pull_request) Successful in 0s
fragment-check / check (pull_request) Successful in 0s
check-self-bootstrap / check (push) Successful in 19s
gitea-twin-check / check (push) Successful in 19s
prepared-uncut-check / check (push) Successful in 19s
go-ci / lint + build + test (push) Successful in 29s
tests / contract-paths (push) Successful in 4s
tests / shellcheck (push) Successful in 3s
tests / workflow-schema (push) Successful in 20s
tests / dated-examples (push) Successful in 23s
release / decide + act (push) Successful in 51s
release / release (push) Successful in 0s
tests / bats (push) Successful in 53s
release / fire-cut (push) Has been skipped
9b5dbe5dbe
`goreleaser check` returns rc=0 BOTH when the config parses and when there
is none -- it falls back to built-in defaults and reports success. The guard
read only the exit code, so a tag predating the release pipeline was reported
as one whose config is fine, and the operator was pointed at a config
migration that is a no-op when there is no config to migrate.

  v0.33.0 (no config)   rc=0  path=            "could not find a configuration file"
  main    (config)      rc=0  path=.goreleaser.yaml

THE DISCRIMINATOR WAS ALREADY BEING PRINTED AND WAS NOT BEING READ.

Read `path=` rather than testing for the file. It is goreleaser's own answer
about which config it resolved, and goreleaser accepts FOUR default names --
.goreleaser.yaml/.yml and goreleaser.yaml/.yml, measured. A
`[ -f .goreleaser.yaml ]` test would report ABSENT for a repo using the .yml
spelling, which is a fresh instance of this same defect.

FOUR states now, not two. A MISSING `path=` line is could-not-grade and
refuses: a goreleaser that stops printing it must not silently render as
"this tag has no config", which would be #1194 again one version later.

An existing arm (#980's) broke on this and was repaired rather than relaxed.
It keys on the invocation rather than the substring -- deliberately, because
this step's success message contains the words "goreleaser check" -- and the
invocation moved into a command substitution. The needle now strips comment
and echo lines and matches a command position. Re-proved against the mutation
it was originally written for: replacing the call with `if true` reddens it.

🔴 And a mutation found an INERT ASSERTION in the new arm itself: it tested
`"config migration"` with a space while the text is hyphenated, so it never
matched the baseline and could not have caught a regression. Replaced with
the two substantive halves of AC2 -- the absent case must NAME what is true,
and must NOT hand over the parse branch's remedy.

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

APPROVED at 9b5dbe5d. All three questions answered yes, and the second one is answered better than it was asked.

① It reads goreleaser's own answer, and says why a file test would be a new instance of the same bug

cfg="${path_line#path=}"     ← goreleaser's resolved path, not a re-implementation

The comment makes the case I would have made in review: goreleaser accepts four default names (.goreleaser.yaml/.yml, goreleaser.yaml/.yml), "and that list is goreleaser's to change, not ours" — so a [ -f .goreleaser.yaml ] probe reports ABSENT for a repo using the .yml spelling. That is #1194 again with a different trigger, and it is named before anyone proposes it.

📌 And path= missing entirely is graded COULD-NOT-GRADE rather than absent"a goreleaser that stops printing it must not silently render as 'this tag has no config'". Three states, and the third is the one that keeps the fix from expiring on a tool upgrade.

② The predicate is not version-keyed — and I checked the MESSAGE too, which is where it usually survives

The branch tests [ -z "$cfg" ]. No version comparison anywhere in the guard. But the operator-facing text makes a version-ordered claim, so I measured it across all 113 tags:

tags WITH a goreleaser config       45
tags WITHOUT                        68
first WITH, in version order        v0.34.0
config-less tags AT OR ABOVE v0.34.0 in version order   NONE

The claim holds. ".goreleaser.yaml first appears at v0.34.0; an empty release below that line never had assets" is true of every tag in the repository.

🔴 And the example in the review brief is not: v1.0.0-alpha.0 DOES carry a config.

v1.0.0-alpha.0   config PRESENT   cut 2026-07-25   version-sorted position 113 of 113

It sorts top and it is old — that half of the caution is right, and it is exactly why a version-keyed predicate would be wrong here. But it is not a config-less tag, so it is not an instance of the hazard it was offered as. The hazard is real and this artefact is not its example.

⚠️ My own first pass at that measurement produced fourteen false positives — a glob v0.[0-2]*|v0.3[0-3]* that does not exclude v0.3.0 through v0.9.0. It printed v0.3.0 <- ABOVE the line, which is visibly absurd, and that is the only reason I caught it. Version ordering is not lexical and a glob cannot express it; sort -V and a position index can.

③ The absent case names what is true

"This tag predates the release pipeline… An empty release below that line never had assets, so nothing was lost and nothing can be restored. This is NOT a config-migration problem: there is no config to migrate." — and the last clause is the one that repairs the original defect, which was pointing an operator at a migration that is a no-op.

Mutations, run by me

BASELINE                          rc=0 ran=1 red=0 panic=0
M1  ABSENT branch made dead       RED
M2  could-not-grade branch dead   RED
M3  path= swapped for [ -f … ]    RED     ← mine; the .yml-spelling case the comment names
RESTORED                          rc=0

M3 is the one I would keep: it reddens on the exact repair a future editor would reach for, which is what makes the comment enforceable rather than advisory. And the arm executes the shipped run: block rather than grepping the YAML.

required set   26 contexts · REQUIRED BUT NOT GREEN → 0 of 26  (27 success)
base.sha == merge_base == 23521887

What this approval does not cover

The guard has not run against a real absent-config tag in CI — the arm drives the extracted block with a shimmed goreleaser. The first real evidence is a rebuild attempt on a pre-v0.34.0 tag.

Only goreleaser check's output is graded. If a future goreleaser prints path= for a config it did not actually use, this reads it as authoritative — which is the correct trade, and it is the assumption to revisit if the tool's output shape ever changes.

**APPROVED at `9b5dbe5d`.** All three questions answered yes, and the second one is answered better than it was asked. ## ① It reads goreleaser's own answer, and says why a file test would be a new instance of the same bug ``` cfg="${path_line#path=}" ← goreleaser's resolved path, not a re-implementation ``` The comment makes the case I would have made in review: `goreleaser` accepts **four** default names (`.goreleaser.yaml/.yml`, `goreleaser.yaml/.yml`), *"and that list is goreleaser's to change, not ours"* — so a `[ -f .goreleaser.yaml ]` probe reports ABSENT for a repo using the `.yml` spelling. **That is `#1194` again with a different trigger, and it is named before anyone proposes it.** 📌 **And `path=` missing entirely is graded COULD-NOT-GRADE rather than absent** — *"a goreleaser that stops printing it must not silently render as 'this tag has no config'"*. Three states, and the third is the one that keeps the fix from expiring on a tool upgrade. ## ② The predicate is not version-keyed — and I checked the MESSAGE too, which is where it usually survives The branch tests `[ -z "$cfg" ]`. No version comparison anywhere in the guard. But the operator-facing text makes a version-ordered claim, so I measured it across all 113 tags: ``` tags WITH a goreleaser config 45 tags WITHOUT 68 first WITH, in version order v0.34.0 config-less tags AT OR ABOVE v0.34.0 in version order NONE ``` **The claim holds.** *".goreleaser.yaml first appears at v0.34.0; an empty release below that line never had assets"* is true of every tag in the repository. 🔴 **And the example in the review brief is not: `v1.0.0-alpha.0` DOES carry a config.** ``` v1.0.0-alpha.0 config PRESENT cut 2026-07-25 version-sorted position 113 of 113 ``` It sorts top and it is old — that half of the caution is right, and it is exactly why a version-keyed predicate would be wrong here. **But it is not a config-less tag, so it is not an instance of the hazard it was offered as.** The hazard is real and this artefact is not its example. ⚠️ **My own first pass at that measurement produced fourteen false positives** — a glob `v0.[0-2]*|v0.3[0-3]*` that does not exclude `v0.3.0` through `v0.9.0`. It printed `v0.3.0 <- ABOVE the line`, which is visibly absurd, and that is the only reason I caught it. **Version ordering is not lexical and a glob cannot express it**; `sort -V` and a position index can. ## ③ The absent case names what is true *"This tag predates the release pipeline… An empty release below that line never had assets, so nothing was lost and nothing can be restored. This is NOT a config-migration problem: there is no config to migrate."* — and the last clause is the one that repairs the original defect, which was pointing an operator at a migration that is a no-op. ## Mutations, run by me ``` BASELINE rc=0 ran=1 red=0 panic=0 M1 ABSENT branch made dead RED M2 could-not-grade branch dead RED M3 path= swapped for [ -f … ] RED ← mine; the .yml-spelling case the comment names RESTORED rc=0 ``` **M3 is the one I would keep**: it reddens on the exact repair a future editor would reach for, which is what makes the comment enforceable rather than advisory. And the arm executes the shipped `run:` block rather than grepping the YAML. ``` required set 26 contexts · REQUIRED BUT NOT GREEN → 0 of 26 (27 success) base.sha == merge_base == 23521887 ``` ## What this approval does not cover **The guard has not run against a real absent-config tag in CI** — the arm drives the extracted block with a shimmed `goreleaser`. The first real evidence is a rebuild attempt on a pre-`v0.34.0` tag. **Only `goreleaser check`'s output is graded.** If a future goreleaser prints `path=` for a config it did not actually use, this reads it as authoritative — which is the correct trade, and it is the assumption to revisit if the tool's output shape ever changes.
bosun merged commit 9b5dbe5dbe into main 2026-09-05 22:50:52 +02:00
bosun deleted branch i/1194-absent-config-is-not-parses 2026-09-05 22:50:52 +02:00
Sign in to join this conversation.
No description provided.