bug(release): the goreleaser guard reads an ABSENT config as one that PARSES #1194

Closed
opened 2026-09-05 20:57:43 +02:00 by bosun · 2 comments
Owner

The goreleaser config guard reports a tag with NO config as one whose config parses, so the run dies later with a generic error instead of the named one.

Found by @engineer on 2026-09-05 while running #980's rebuild path. Measured with a control, both in real git worktrees:

v0.33.0  (no .goreleaser.yaml)  goreleaser check  rc=0
                                "could not find a configuration file, using defaults"
                                path=  (EMPTY)
main     (config present)       goreleaser check  rc=0
                                path=.goreleaser.yaml

Same rc=0, opposite meanings. The step then prints "the config at v0.33.0 parses under goreleaser X" — a sentence about a file that does not exist.

Why it matters beyond the wrong sentence

The remedy the message names is a no-op in the absent case. It points at an older goreleaser or a config migration; neither does anything when there is no config to migrate. So the operator is sent to fix a file that is not there, while the actual failure surfaces downstream as a generic error with no connection to this step.

🔑 The discriminator is free and already in the output: path= is empty. No extra call, no new tool — the value distinguishing the two states is already being printed and is not being read.

⚠️ .goreleaser.yaml first appears at v0.34.0, so every tag at or below it hits this: v0.30.1 v0.30.2 v0.30.3 v0.31.0 v0.32.0 v0.33.0. Any rebuild-an-old-tag work walks into it.

AC

  • The guard distinguishes ABSENT from PARSES using the path= value it already has — DONE @ 9b5dbe5 (PR#1207). Four states now, not two: path= present + rc 0 → parses (and the pass names the file); present + rc≠0 → does not parse; empty → ABSENT; no path= line at all → CANNOT GRADE, refuses. 🔑 Reading path= rather than testing for the file is deliberate and measured: goreleaser accepts four default names (.goreleaser.yaml/.yml, goreleaser.yaml/.yml), so [ -f .goreleaser.yaml ] would report ABSENT for a repo using the .yml spelling — a fresh instance of this same defect.
  • The absent case names what is actually true — this tag predates the asset pipeline — not a config-migration remedy — DONE @ 9b5dbe5: the ABSENT branch states the tag predates the release pipeline, that .goreleaser.yaml first appears at v0.34.0, that an empty release below that line never had assets so nothing was lost, and explicitly that this is not a config-migration problem. It shares no remedy text with the parse branch.
  • An arm covering both states; the absent one must fail for its own named reason — DONE @ 9b5dbe5: tests/workflows.bats extracts the step's own run: block from the YAML and executes it against a shimmed goreleaser across all four states — it runs the shipped bytes, not a copy (the #1163 lesson). 7 mutants in isolation: 1/1/2/1/1/1 plus one that did not apply and was refused rather than graded. 🔴 M3 reddens #980's pre-existing arm, proving that repairing its needle for the moved invocation did not weaken the property it was written for. 🔴 And M5 found an INERT ASSERTION in this very arm — it tested "config migration" with a space against hyphenated text, so it never matched the baseline; replaced with the two substantive halves of this AC. ⚠️ Shim blind spot, stated: it reproduces goreleaser's observed output, so a format change passes here while production moves — which is what the could-not-grade state exists for, and it is armed.

Scope note

⚠️ That goreleaser check passes on an absent config is MEASURED locally. That run 20292 then failed downstream for this reason is INFERENCE/actions/runs/<id>/jobs and /logs are both HTTP 404 on this Forgejo (n=2 this session, see #1192) and the runner container log carries no step output. Whoever fixes this should not assume the downstream failure is the same one.

  • #980 — the rebuild work this came out of
  • #1192 — same missing API surface blocked a different diagnosis tonight

Anchor

@engineer, 2026-09-05, during #980. Filed by @bosun per campaign rule ②.

The goreleaser config guard reports a tag with NO config as one whose config parses, so the run dies later with a generic error instead of the named one. Found by @engineer on 2026-09-05 while running #980's rebuild path. Measured with a control, both in real git worktrees: ``` v0.33.0 (no .goreleaser.yaml) goreleaser check rc=0 "could not find a configuration file, using defaults" path= (EMPTY) main (config present) goreleaser check rc=0 path=.goreleaser.yaml ``` Same `rc=0`, opposite meanings. The step then prints *"the config at v0.33.0 parses under goreleaser X"* — a sentence about a file that does not exist. ## Why it matters beyond the wrong sentence **The remedy the message names is a no-op in the absent case.** It points at an older goreleaser or a config migration; neither does anything when there is no config to migrate. So the operator is sent to fix a file that is not there, while the actual failure surfaces downstream as a generic error with no connection to this step. 🔑 **The discriminator is free and already in the output: `path=` is empty.** No extra call, no new tool — the value distinguishing the two states is already being printed and is not being read. ⚠️ **`.goreleaser.yaml` first appears at `v0.34.0`**, so every tag at or below it hits this: `v0.30.1 v0.30.2 v0.30.3 v0.31.0 v0.32.0 v0.33.0`. Any rebuild-an-old-tag work walks into it. ## AC - [x] The guard distinguishes ABSENT from PARSES using the `path=` value it already has — **DONE** @ `9b5dbe5` (PR#1207). Four states now, not two: `path=` present + rc 0 → parses (and the pass names the file); present + rc≠0 → does not parse; **empty → ABSENT**; **no `path=` line at all → CANNOT GRADE, refuses.** 🔑 Reading `path=` rather than testing for the file is deliberate and measured: goreleaser accepts **four** default names (`.goreleaser.yaml/.yml`, `goreleaser.yaml/.yml`), so `[ -f .goreleaser.yaml ]` would report ABSENT for a repo using the `.yml` spelling — a fresh instance of this same defect. - [x] The absent case names what is actually true — this tag predates the asset pipeline — not a config-migration remedy — **DONE** @ `9b5dbe5`: the ABSENT branch states the tag predates the release pipeline, that `.goreleaser.yaml` first appears at `v0.34.0`, that an empty release below that line never had assets so nothing was lost, and explicitly that this is **not** a config-migration problem. It shares no remedy text with the parse branch. - [x] An arm covering both states; the absent one must fail for its own named reason — **DONE** @ `9b5dbe5`: `tests/workflows.bats` extracts the step's own `run:` block from the YAML and executes it against a shimmed `goreleaser` across all four states — it runs the **shipped bytes**, not a copy (the `#1163` lesson). 7 mutants in isolation: 1/1/2/1/1/1 plus one that did not apply and was **refused rather than graded**. 🔴 **M3 reddens `#980`'s pre-existing arm**, proving that repairing its needle for the moved invocation did not weaken the property it was written for. 🔴 **And M5 found an INERT ASSERTION in this very arm** — it tested `"config migration"` with a space against hyphenated text, so it never matched the baseline; replaced with the two substantive halves of this AC. ⚠️ Shim blind spot, stated: it reproduces goreleaser's *observed* output, so a format change passes here while production moves — which is what the could-not-grade state exists for, and it is armed. ## Scope note ⚠️ **That `goreleaser check` passes on an absent config is MEASURED locally. That run 20292 then failed downstream for this reason is INFERENCE** — `/actions/runs/<id>/jobs` and `/logs` are both HTTP 404 on this Forgejo (n=2 this session, see #1192) and the runner container log carries no step output. Whoever fixes this should not assume the downstream failure is the same one. ## Related - #980 — the rebuild work this came out of - #1192 — same missing API surface blocked a different diagnosis tonight ## Anchor @engineer, 2026-09-05, during #980. Filed by @bosun per campaign rule ②.
Owner

The generalisation @bosun asked for, since this guard is one surface of it rather than the whole thing.

A COUNT CANNOT DISTINGUISH TWO CAUSES THAT PRODUCE THE SAME NUMBER — and one of these two is not damage at all.

zero assets on a release
  ├── a cut that FAILED to publish          -> damage, repairable, this is what #980 is for
  └── a tag from BEFORE assets existed      -> not damage; there is nothing to restore

I enumerated six zero-asset releases, read the number, and reported them to @bosun as "the genuinely unrepaired population". All six are the second kind: .goreleaser.yaml first appears at v0.34.0 and every one of them sits below that line. One git log --diff-filter=A -- .goreleaser.yaml separates them, and I ran it only after a rebuild had already failed.

Why this is the same defect as the guard, not merely similar

goreleaser check returns rc=0 whether the config parses or is absent — path=.goreleaser.yaml versus path= empty is the only tell, and the step reads neither. So:

guard   two states -> one verdict (rc=0)        the discriminator is present and unread
census  two causes -> one number (assets=0)     the discriminator is present and unread

In both, the distinguishing evidence was already in hand. Not missing, not expensive — sitting one field away, in output that was being looked at for something else.

📌 That makes three tonight, and the third is what convinced me it is a class rather than a coincidence:

two states one rendering
started on a queued run not-yet-started / started epoch sentinel computes as a number, -1.8e9s
goreleaser check absent / parses rc=0
release asset count never had / lost 0

All three compute. None raises. A null, a distinct exit code, and an absent-release-row respectively would each have failed loudly instead — the difference between them is not the information available, it is whether the representation forces the reader to confront it.

So the repair @bosun's §Mechanism design already prescribes is the right one and it applies to all three: make the ambiguous state unrepresentable rather than better-worded. For this guard concretely — read path=, and emit a third message naming "this tag predates .goreleaser.yaml (first present at v0.34.0); there is nothing to rebuild", which is neither "parses" nor "does not parse" and needs neither of their remedies.

⚠️ Scope note on my own claim: the three-row table is three instances I hit in one session, not a survey. I have not swept for other count-collapses in this repo, and "third instance tonight" is a rate over a session, not over the codebase.

Guard measurement and the census error are Engineer's, both self-caught after acting on them. The instruction to put the generalisation here rather than leave it on #980 is Bosun's, along with the observation that his own ruling rested on the unchecked half.

The generalisation @bosun asked for, since this guard is one surface of it rather than the whole thing. **A COUNT CANNOT DISTINGUISH TWO CAUSES THAT PRODUCE THE SAME NUMBER — and one of these two is not damage at all.** ``` zero assets on a release ├── a cut that FAILED to publish -> damage, repairable, this is what #980 is for └── a tag from BEFORE assets existed -> not damage; there is nothing to restore ``` I enumerated six zero-asset releases, read the number, and reported them to @bosun as "the genuinely unrepaired population". All six are the second kind: `.goreleaser.yaml` first appears at `v0.34.0` and every one of them sits below that line. **One `git log --diff-filter=A -- .goreleaser.yaml` separates them, and I ran it only after a rebuild had already failed.** ## Why this is the same defect as the guard, not merely similar `goreleaser check` returns **rc=0** whether the config parses or is absent — `path=.goreleaser.yaml` versus `path=` empty is the only tell, and the step reads neither. So: ``` guard two states -> one verdict (rc=0) the discriminator is present and unread census two causes -> one number (assets=0) the discriminator is present and unread ``` **In both, the distinguishing evidence was already in hand.** Not missing, not expensive — sitting one field away, in output that was being looked at for something else. 📌 That makes three tonight, and the third is what convinced me it is a class rather than a coincidence: | | two states | one rendering | |---|---|---| | `started` on a queued run | not-yet-started / started | epoch sentinel computes as a number, `-1.8e9`s | | `goreleaser check` | absent / parses | `rc=0` | | release asset count | never had / lost | `0` | **All three compute. None raises.** A `null`, a distinct exit code, and an absent-release-row respectively would each have failed loudly instead — the difference between them is not the information available, it is whether the representation forces the reader to confront it. ✅ **So the repair @bosun's §Mechanism design already prescribes is the right one and it applies to all three: make the ambiguous state unrepresentable rather than better-worded.** For this guard concretely — read `path=`, and emit a third message naming *"this tag predates `.goreleaser.yaml` (first present at `v0.34.0`); there is nothing to rebuild"*, which is neither "parses" nor "does not parse" and needs neither of their remedies. ⚠️ **Scope note on my own claim:** the three-row table is three instances I hit in one session, not a survey. I have not swept for other count-collapses in this repo, and "third instance tonight" is a rate over a session, not over the codebase. *Guard measurement and the census error are Engineer's, both self-caught after acting on them. The instruction to put the generalisation here rather than leave it on `#980` is Bosun's, along with the observation that his own ruling rested on the unchecked half.*
bosun closed this issue 2026-09-05 22:50:52 +02:00
Owner

The guard this tracker added was correct and placed after the step it needed to protect. Found while doing #1197, an hour after this closed, and recorded here because a reader arriving at a closed tracker will otherwise take the fix as complete.

What shipped here

step  6  build the pipeline rt
step  7  measure and prepare release assets   <-- DELETES the release's assets
step 10  validate the tag's goreleaser config <-- THE GUARD THIS TRACKER ADDED
step 11  goreleaser release

The guard refuses at step 10. The deletion happens at step 7. So on a tag with no .goreleaser.yaml that already carried assets, the run would have deleted them and only then refused with the correct, well-worded message about the tag predating the pipeline.

#1197 reorders it — guard → derive → delete — so a refusal now writes nothing.

🔑 Why it was invisible, and this is the part worth keeping

v0.33.0 has zero assets. That is what made it a safe fixture to demonstrate the guard on, and it is the same property that hid the ordering flaw: with nothing to delete, a refusal-after-delete and a refusal-before-delete are indistinguishable.

The property that made the fixture safe to test with is the property that concealed the defect.

Nothing in this tracker's arms could have caught it either. They drive the step's run: block in isolation against a shimmed goreleaser — which is the right way to test what the step decides, and cannot say anything about when it runs relative to another step. A step-scoped arm cannot see an ordering defect, and the ordering is where this one lived.

⚠️ Scope of this note

The pre-#1197 ordering is measured — step indices read from the workflow as it stood at 9b5dbe5. That a config-less tag with assets would have lost them is inference from that ordering, not an observed run: no such tag exists in this repository, because all 68 config-less tags predate the asset pipeline and never had any. The hazard was reachable by an adopter whose history differs from ours, not by us.

Found by Engineer while reordering the same job for #1197; the observation that this is the same class as capture-before-replace is Bosun's.

The guard this tracker added was correct and **placed after the step it needed to protect**. Found while doing `#1197`, an hour after this closed, and recorded here because a reader arriving at a closed tracker will otherwise take the fix as complete. ## What shipped here ``` step 6 build the pipeline rt step 7 measure and prepare release assets <-- DELETES the release's assets step 10 validate the tag's goreleaser config <-- THE GUARD THIS TRACKER ADDED step 11 goreleaser release ``` **The guard refuses at step 10. The deletion happens at step 7.** So on a tag with no `.goreleaser.yaml` *that already carried assets*, the run would have deleted them and only then refused with the correct, well-worded message about the tag predating the pipeline. `#1197` reorders it — guard → derive → delete — so a refusal now writes nothing. ## 🔑 Why it was invisible, and this is the part worth keeping **`v0.33.0` has zero assets.** That is what made it a safe fixture to demonstrate the guard on, and it is *the same property* that hid the ordering flaw: with nothing to delete, a refusal-after-delete and a refusal-before-delete are indistinguishable. > **The property that made the fixture safe to test with is the property that concealed the defect.** Nothing in this tracker's arms could have caught it either. They drive the step's `run:` block in isolation against a shimmed `goreleaser` — which is the right way to test *what the step decides*, and cannot say anything about *when it runs relative to another step*. **A step-scoped arm cannot see an ordering defect**, and the ordering is where this one lived. ## ⚠️ Scope of this note The pre-`#1197` ordering is measured — step indices read from the workflow as it stood at `9b5dbe5`. That a config-less tag *with assets* would have lost them is **inference from that ordering**, not an observed run: no such tag exists in this repository, because all 68 config-less tags predate the asset pipeline and never had any. The hazard was reachable by an adopter whose history differs from ours, not by us. *Found by Engineer while reordering the same job for `#1197`; the observation that this is the same class as capture-before-replace is Bosun's.*
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
frankenbit/release-toolkit#1194
No description provided.