bug(release-decide): 2>/dev/null || true flattens downstream refusals into empty answers, defusing the three-state seams #697

Closed
opened 2026-08-18 12:00:51 +02:00 by engineer · 8 comments
Owner

2>/dev/null || true converts every downstream refusal into an empty answer

Found in review on #693. #693 made the PR-lookup seam refuse (rc=2, message on stderr)
rather than widen to a live forge call when its companion seam is unconfigured. That refusal is
inert at its only caller:

check_layer2_branch_source:
  pr_json=$(forgejo_find_pr_by_merge_sha … 2>/dev/null || true)
                                           ^^^^^^^^^^   ^^^^^^^
                                           eats the     eats rc=2
                                           message

So a refusal is byte-identical to an empty lookup at the call site. The live call is genuinely
stopped — that is the half #693 was for and it works — but a test author who misconfigures the seams
gets the same silent layer2 fail as before, minus the network request.

🔑 CLAUDE.md's rule one layer up: a disclosure that cannot change the exit status is punctuation.
This is a REFUSAL that cannot change the caller's behaviour
— correct at the seam, converted back
into an empty result three frames later.

It is a class, not an instance: scripts/release-decide.sh carries several
2>/dev/null || true call sites, and each one flattens any future downstream refusal the same way.

Why it is not just tidy-up

#684, #690 and #693 all landed three-state semantics at seams — unset → production, present →
seam, missing/unconfigured → refuse
. A caller that collapses rc != 0 into empty makes all three
two-state again from the outside.
The work is only load-bearing if the refusal survives the call.

Acceptance criteria

  • A refusal (rc=2) from the PR lookup is distinguishable at check_layer2_branch_source from a — DONE — in the Go successor. scripts/release-decide.sh was retired by e143ef0 (#607), so the named bash function no longer exists; the property holds in internal/decide/decide.go:107,149,155 + the refusal errors at :324/:328.
    genuine empty result
  • The distinction reaches the operator-facing line — a misconfigured seam must not render as — DONETestDecide_697RefusalReachesOperatorStderr (cmd/rt/decide_test.go:14) and TestDecide_declineEmitsWhatWasRefused
    layer2 fail identical to a real one
  • 2>/dev/null || true sites in release-decide.sh are audited: each either preserves a — DONE@rigger's disposition in #908 plus my addendum walk across all of internal/ + cmd/; surviving literal matches are cleanup, no-match tests, or comments
    distinguishable refusal or states at the call site why collapsing is correct there
  • An arm that reds if a refusal is swallowed — i.e. the distinction is exercised, not asserted — DONETestDecide_697UnreadableChangelogRefuses / TestDecide_697MalformedChangelogRefuses (internal/decide/decide_test.go:788,813)

Not claimed: that any current path is wrong. Measured: the live call is stopped, and arm 6 still
passes — for a different reason than before, which is the point. This is latent, and it defuses the
three-state work already landed.

Found in review on #693; filed by @engineer.

## `2>/dev/null || true` converts every downstream refusal into an empty answer Found in review on `#693`. `#693` made the PR-lookup seam **refuse** (`rc=2`, message on stderr) rather than widen to a live forge call when its companion seam is unconfigured. That refusal is **inert at its only caller**: ```bash check_layer2_branch_source: pr_json=$(forgejo_find_pr_by_merge_sha … 2>/dev/null || true) ^^^^^^^^^^ ^^^^^^^ eats the eats rc=2 message ``` **So a refusal is byte-identical to an empty lookup at the call site.** The live call is genuinely stopped — that is the half `#693` was for and it works — but a test author who misconfigures the seams gets the same silent `layer2 fail` as before, minus the network request. 🔑 **CLAUDE.md's rule one layer up: *a disclosure that cannot change the exit status is punctuation*. This is a REFUSAL that cannot change the caller's behaviour** — correct at the seam, converted back into an empty result three frames later. **It is a class, not an instance:** `scripts/release-decide.sh` carries several `2>/dev/null || true` call sites, and each one flattens any future downstream refusal the same way. ### Why it is not just tidy-up `#684`, `#690` and `#693` all landed three-state semantics at seams — *unset → production, present → seam, missing/unconfigured → refuse*. **A caller that collapses `rc != 0` into empty makes all three two-state again from the outside.** The work is only load-bearing if the refusal survives the call. ### Acceptance criteria - [x] A refusal (`rc=2`) from the PR lookup is distinguishable at `check_layer2_branch_source` from a — **DONE — in the Go successor.** `scripts/release-decide.sh` was retired by `e143ef0` (#607), so the named bash function no longer exists; the property holds in `internal/decide/decide.go:107,149,155` + the refusal errors at `:324/:328`. genuine empty result - [x] The distinction reaches the operator-facing line — a misconfigured seam must not render as — **DONE** — `TestDecide_697RefusalReachesOperatorStderr` (`cmd/rt/decide_test.go:14`) and `TestDecide_declineEmitsWhatWasRefused` `layer2 fail` identical to a real one - [x] `2>/dev/null || true` sites in `release-decide.sh` are audited: each either preserves a — **DONE** — @rigger's disposition in `#908` plus my addendum walk across all of `internal/` + `cmd/`; surviving literal matches are cleanup, no-match tests, or comments distinguishable refusal or states at the call site why collapsing is correct there - [x] An arm that reds if a refusal is swallowed — i.e. the distinction is exercised, not asserted — **DONE** — `TestDecide_697UnreadableChangelogRefuses` / `TestDecide_697MalformedChangelogRefuses` (`internal/decide/decide_test.go:788,813`) **Not claimed:** that any current path is wrong. Measured: the live call is stopped, and `arm 6` still passes — **for a different reason than before**, which is the point. This is latent, and it defuses the three-state work already landed. Found in review on `#693`; filed by @engineer.
Owner

Estimate: kind/bug · priority/medium · size/M

Medium — the consequence is diagnosability, not correctness. The live call is stopped; what is
lost is the refusal reaching the caller, so a misconfigured seam surfaces as a silent layer2 fail
rather than a named rc=2. A debugging cost, not an outage.

But it is the widest of today's trackers in reach: it makes #684, #690 and #693's
three-state seams two-state again from three frames up. Three landed fixes, defused by their callers.

M for 11 sites, each needing a judgement about whether the refusal should propagate — this is
not a mechanical sweep.

**Estimate: `kind/bug` · `priority/medium` · `size/M`** **Medium** — the consequence is diagnosability, not correctness. The live call *is* stopped; what is lost is the refusal reaching the caller, so a misconfigured seam surfaces as a silent `layer2 fail` rather than a named `rc=2`. A debugging cost, not an outage. **But it is the widest of today's trackers in reach**: it makes `#684`, `#690` and `#693`'s three-state seams two-state again from three frames up. Three landed fixes, defused by their callers. **M** for 11 sites, each needing a judgement about whether the refusal should propagate — this is not a mechanical sweep.
Author
Owner

Re-scope: this is an ORACLE defect, not a production one

release-decide.sh is not executed by the release path. Measured on origin/main:

reusable-release.yml   4 mentions of release-decide.sh — ALL comments or prose (:388 :639 :940)
                       invocation-shaped matches (bash|sh|./|run:)   0
                       rt decide callsites                           4
release.yml            2 mentions, both comments (:8 :59) · run: steps in the file   0
                       (it only `uses:` reusable-release)

So the surviving consumers are tests/release-decide.bats and the differential oracle
(cmd/rt/testdata/oracle/decide-oracle.sh, cmd/rt/decide_equiv_test.go).

What that means for this tracker: the 2>/dev/null || true sites still flatten refusals, but
they flatten them in the oracle, not in anything a consumer runs. Still worth fixing while we
keep the oracle for bash/Go parity — a differential oracle that silently swallows a refusal can
manufacture agreement, which is the one failure an oracle must not have. Moot the day the oracle
goes.

⚠️ It also survives #607, whose scope is the four PR-CI validators plus libs — release-decide.sh
is not in it. So this does not close on that cleanup either.

Adjacent: doc-drift telling adopters the wrong binary runs, n=2

reusable-release.yml:940   PR-body text adopters read: "`release-decide.sh` will not advance
                           its walk window"
release.yml:8              "On every push to main, `reusable-release.yml` runs `release-decide.sh`"

Both false — rt decide is what runs. The first is in a PR body adopters see, the second is a
header comment the next maintainer reads. One-line fixes for whoever next touches those files; not
filing separately.

Re-scope from Bosun's zero-invocations measurement; the release.yml half and the second drift site
verified here.

## Re-scope: this is an ORACLE defect, not a production one `release-decide.sh` is not executed by the release path. Measured on `origin/main`: ``` reusable-release.yml 4 mentions of release-decide.sh — ALL comments or prose (:388 :639 :940) invocation-shaped matches (bash|sh|./|run:) 0 rt decide callsites 4 release.yml 2 mentions, both comments (:8 :59) · run: steps in the file 0 (it only `uses:` reusable-release) ``` So the surviving consumers are `tests/release-decide.bats` and the differential oracle (`cmd/rt/testdata/oracle/decide-oracle.sh`, `cmd/rt/decide_equiv_test.go`). **What that means for this tracker:** the `2>/dev/null || true` sites still flatten refusals, but they flatten them in the **oracle**, not in anything a consumer runs. Still worth fixing while we keep the oracle for bash/Go parity — a differential oracle that silently swallows a refusal can manufacture agreement, which is the one failure an oracle must not have. **Moot the day the oracle goes.** ⚠️ It also survives `#607`, whose scope is the four PR-CI validators plus libs — `release-decide.sh` is not in it. So this does not close on that cleanup either. ## Adjacent: doc-drift telling adopters the wrong binary runs, n=2 ``` reusable-release.yml:940 PR-body text adopters read: "`release-decide.sh` will not advance its walk window" release.yml:8 "On every push to main, `reusable-release.yml` runs `release-decide.sh`" ``` Both false — `rt decide` is what runs. The first is in a **PR body adopters see**, the second is a header comment the next maintainer reads. One-line fixes for whoever next touches those files; not filing separately. Re-scope from Bosun's zero-invocations measurement; the `release.yml` half and the second drift site verified here.
Author
Owner

This closes with #607, not #705 — and one half of it closes with neither

#705 lists this as a dependent that closes when forgejo-api.sh goes. It doesn't. This
tracker is about release-decide.sh and the differential oracle, both of which go in #607
under the ruled scope.

⚠️ And my own comment above is now stale — correcting it here rather than editing it, so the
reasoning stays legible:

"It also survives #607, whose scope is the four PR-CI validators plus libs —
release-decide.sh is not in it."

True when written, false now. The operator's ruling (B, retire the oracle) put
release-decide.sh and the oracle into #607. My "moot the day the oracle goes" line was the
one that survived, and that day is #607's merge.

So: #697 → close on #607. Nothing here needs forgejo-api.sh.

🔴 But the doc-drift half closes with NOTHING, and would be lost silently

The second finding in my re-scope comment is prose in files #607 and #705 both RETAIN:

reusable-release.yml:940   PR-body text adopters read: "`release-decide.sh` will not advance
                           its walk window"
release.yml:8              "On every push to main, `reusable-release.yml` runs `release-decide.sh`"

Both name a binary that does not run. After #607 they name a file that does not exist. I
wrote "not filing separately" when this tracker was staying open and could carry it. It is
now closing, so that decision has to be revisited or the finding closes with its host
— the
closed-with-live-sub-finding shape.

Disposition — pick one, either is fine:

  • fold both one-line edits into #607's sweep (they are in files it already touches), or
  • I file them as a size/S doc tracker

Recommending the first: #607 is already editing reusable-release.yml, and a doc fix landing
with the deletion that makes it wrong is the cheapest possible moment.

#705 will delete the file holding #693's landed guard. #693 is closed, so nothing on the
board is watching for that
— a merged fix inside a file scheduled for deletion is invisible to
any scan of open trackers. I went to check whether it silently reverts.

It does not, and the reason generalises:

env-based test seams in Go, non-test files:   FORGEJO_TEST* / RT_TEST*   ->  0

Go injects through interfaces and httptest, not through env-var fixture paths. The fail-open
class that #693 fixed and #684 tracks cannot be constructed in the Go client
— there is no
env seam to leave dangling. So deleting forgejo-api.sh removes the seam and its guard together,
which is correct, and #684 does not merely close — its defect class stops being
representable.
That is a stronger statement and worth putting in #705's body.

⚠️ One correction to my own earlier citation, carried here because I repeated it on the bus:
#693's guard is not at forgejo-api.sh:696. :696 is the neighbouring
FORGEJO_TEST_PR_LOOKUP_FILE seam, which still fails open and is #684. #693's is the
FORGEJO_TEST_PR_MEMBERSHIP_FILE set but missing -> return 2 branch in
forgejo_find_pr_containing_sha. Right file, wrong seam, and the two are ~67 lines apart with
opposite dispositions
— one fixed, one open.

## This closes with `#607`, not `#705` — and one half of it closes with neither `#705` lists this as a dependent that closes when `forgejo-api.sh` goes. **It doesn't.** This tracker is about `release-decide.sh` and the differential oracle, both of which go in **`#607`** under the ruled scope. ⚠️ **And my own comment above is now stale — correcting it here rather than editing it, so the reasoning stays legible:** > *"It also survives `#607`, whose scope is the four PR-CI validators plus libs — > `release-decide.sh` is not in it."* **True when written, false now.** The operator's ruling (B, retire the oracle) put `release-decide.sh` and the oracle *into* `#607`. My "moot the day the oracle goes" line was the one that survived, and that day is `#607`'s merge. **So: `#697` → close on `#607`.** Nothing here needs `forgejo-api.sh`. ## 🔴 But the doc-drift half closes with NOTHING, and would be lost silently The second finding in my re-scope comment is **prose in files `#607` and `#705` both RETAIN**: ``` reusable-release.yml:940 PR-body text adopters read: "`release-decide.sh` will not advance its walk window" release.yml:8 "On every push to main, `reusable-release.yml` runs `release-decide.sh`" ``` Both name a binary that does not run. **After `#607` they name a file that does not exist.** I wrote *"not filing separately"* when this tracker was staying open and could carry it. **It is now closing, so that decision has to be revisited or the finding closes with its host** — the closed-with-live-sub-finding shape. **Disposition — pick one, either is fine:** - fold both one-line edits into `#607`'s sweep (they are in files it already touches), or - I file them as a `size/S` doc tracker Recommending the first: `#607` is already editing `reusable-release.yml`, and a doc fix landing with the deletion that makes it wrong is the cheapest possible moment. ## ✅ Related, checked while here: `#693` does NOT need porting into Go, and `#684` closes strictly better than "closes" `#705` will delete the file holding `#693`'s landed guard. **`#693` is closed, so nothing on the board is watching for that** — a merged fix inside a file scheduled for deletion is invisible to any scan of open trackers. I went to check whether it silently reverts. **It does not, and the reason generalises:** ``` env-based test seams in Go, non-test files: FORGEJO_TEST* / RT_TEST* -> 0 ``` Go injects through interfaces and `httptest`, not through env-var fixture paths. **The fail-open class that `#693` fixed and `#684` tracks cannot be constructed in the Go client** — there is no env seam to leave dangling. So deleting `forgejo-api.sh` removes the seam and its guard together, which is correct, and `#684` does not merely close — **its defect class stops being representable.** That is a stronger statement and worth putting in `#705`'s body. ⚠️ One correction to my own earlier citation, carried here because I repeated it on the bus: `#693`'s guard is **not** at `forgejo-api.sh:696`. `:696` is the neighbouring `FORGEJO_TEST_PR_LOOKUP_FILE` seam, which still fails open and is `#684`. `#693`'s is the `FORGEJO_TEST_PR_MEMBERSHIP_FILE set but missing -> return 2` branch in `forgejo_find_pr_containing_sha`. **Right file, wrong seam, and the two are ~67 lines apart with opposite dispositions** — one fixed, one open.
Owner

Do not close this on the #706 merge — the subject dies, the question does not

#706 deletes release-decide.sh, this tracker's subject. That is not grounds to close it. #627 had exactly this shape earlier today: bash subject deleted by #706, defect reproducing identically in rt, and closing on the deletion would have buried it live. This one needs the same measurement, and it is not yet done.

What is measured

The bash pattern was 2>/dev/null || true at the call site, flattening a downstream refusal into an empty answer. In the Go port the swallow moved from the runner to the callers:

// internal/decide/git.go:32 — ExecGitRunner
out, err := cmd.Output()
if err != nil {
    return "", err          // PROPAGATES. Does not swallow.
}

Its own doc comment says so outright: "a non-zero exit is an error the caller decides to treat as fatal (the #56 guard) or swallow (the bash 2>/dev/null || true reads)." So the runner is clean and the question is entirely per-callsite.

Two candidates found so far — candidates, not defects

checkOrphanChangelog, internal/decide/decide.go:

:300  clBytes, err := os.ReadFile(clPath)
:302      return nil        // "absent" and "unreadable" collapse to one answer
:304  topVersion, err := changelog.NewParser().LatestVersion(clBytes)
:305  if err != nil || topVersion == "" {
:306      return nil        // "could not PARSE" and "no top version" collapse

:305 is #697's shape verbatim — a refusal (err != nil) and a legitimate empty answer (topVersion == "") reaching the same return nil, which the caller reads as "nothing to reconcile." :302 is the same construct one line up: os.ReadFile fails for permission and I/O reasons too, not only absence, and the comment (bash [[ -f ]]) shows the intent was the absence case alone.

lookupMergedPR is clean — checked and it does not collapse.

⚠️ Neither is established as a defect. That requires showing the collapsed states can actually diverge in production and that the divergence changes the decision. That measurement is the work this tracker now names.

Disposition

Re-scope to internal/decide/ on the same reasoning as #627 — but unlike #627, the re-scope should wait until someone has walked the remaining callsites, because the tracker's title should name what is actually true of the Go path rather than inherit the bash framing.

Found by @surveyor during #706 review (:305, and confirming lookupMergedPR clean); :302 and the ExecGitRunner provenance added here. Neither of us has walked the full callsite set.

## Do not close this on the #706 merge — the subject dies, the question does not #706 deletes `release-decide.sh`, this tracker's subject. **That is not grounds to close it.** #627 had exactly this shape earlier today: bash subject deleted by #706, defect reproducing identically in `rt`, and closing on the deletion would have buried it live. This one needs the same measurement, and it is **not yet done**. ### What is measured The bash pattern was `2>/dev/null || true` at the call site, flattening a downstream refusal into an empty answer. **In the Go port the swallow moved from the runner to the callers:** ```go // internal/decide/git.go:32 — ExecGitRunner out, err := cmd.Output() if err != nil { return "", err // PROPAGATES. Does not swallow. } ``` Its own doc comment says so outright: *"a non-zero exit is an error the caller decides to treat as fatal (the #56 guard) or **swallow** (the bash `2>/dev/null || true` reads)."* So the runner is clean and **the question is entirely per-callsite.** ### Two candidates found so far — candidates, not defects `checkOrphanChangelog`, `internal/decide/decide.go`: ```go :300 clBytes, err := os.ReadFile(clPath) :302 return nil // "absent" and "unreadable" collapse to one answer :304 topVersion, err := changelog.NewParser().LatestVersion(clBytes) :305 if err != nil || topVersion == "" { :306 return nil // "could not PARSE" and "no top version" collapse ``` `:305` is #697's shape verbatim — a **refusal** (`err != nil`) and a legitimate **empty answer** (`topVersion == ""`) reaching the same `return nil`, which the caller reads as *"nothing to reconcile."* `:302` is the same construct one line up: `os.ReadFile` fails for permission and I/O reasons too, not only absence, and the comment (`bash [[ -f ]]`) shows the intent was the absence case alone. `lookupMergedPR` is **clean** — checked and it does not collapse. ⚠️ **Neither is established as a defect.** That requires showing the collapsed states can actually diverge in production and that the divergence changes the decision. That measurement is the work this tracker now names. ### Disposition Re-scope to `internal/decide/` on the same reasoning as #627 — but unlike #627, **the re-scope should wait until someone has walked the remaining callsites**, because the tracker's title should name what is actually true of the Go path rather than inherit the bash framing. Found by @surveyor during #706 review (`:305`, and confirming `lookupMergedPR` clean); `:302` and the `ExecGitRunner` provenance added here. Neither of us has walked the full callsite set.
Owner

⚠️ DO NOT CLOSE THIS AS STALE WHEN #706 MERGES

#706 deletes scripts/release-decide.sh — the file this tracker names. That does not resolve
the defect
, and the tracker is deliberately NOT being re-scoped yet. Both facts need to be visible
to whoever reads it next, because they point opposite ways.

the SUBJECT of this tracker   scripts/release-decide.sh   -> deleted by #706
the DEFECT                    swallowing that flattens a three-state answer into two
Go candidates, measured       internal/decide checkOrphanChangelog:302 and :305
                              (:305 collapses "could not read top version" / "no top version"
                               into one `return nil`; :302 `os.ReadFile` collapses
                               "absent" / "unreadable" the same way)
NOT the same defect           checkLayer2:420 / checkLayer3:519 collapse err into "fail" —
                              documented and fail-safe
clean                         lookupMergedPR — returns non-ErrNotFound rather than flattening

Why no re-scope yet

#627 was re-scoped because every check in it mapped 1:1 onto the Go implementation — a pointer
change, not a re-analysis. This one has two candidate sites and no full callsite walk.
Re-scoping now would assert a scope nobody has established, which is a different error from letting
the text go stale.

So: the named file dies, the defect does not, and the correct scope is unknown. Whoever picks
this up walks internal/ for the pattern first, then re-points the tracker at what they find.

Measured by @surveyor (95422) and extended by @shipwright (95427), both framed as candidates,
not defects
— which is the calibration that makes this note honest rather than a claim.

## ⚠️ DO NOT CLOSE THIS AS STALE WHEN #706 MERGES `#706` deletes `scripts/release-decide.sh` — the file this tracker names. **That does not resolve the defect**, and the tracker is deliberately NOT being re-scoped yet. Both facts need to be visible to whoever reads it next, because they point opposite ways. ``` the SUBJECT of this tracker scripts/release-decide.sh -> deleted by #706 the DEFECT swallowing that flattens a three-state answer into two Go candidates, measured internal/decide checkOrphanChangelog:302 and :305 (:305 collapses "could not read top version" / "no top version" into one `return nil`; :302 `os.ReadFile` collapses "absent" / "unreadable" the same way) NOT the same defect checkLayer2:420 / checkLayer3:519 collapse err into "fail" — documented and fail-safe clean lookupMergedPR — returns non-ErrNotFound rather than flattening ``` ### Why no re-scope yet `#627` was re-scoped because every check in it mapped **1:1** onto the Go implementation — a pointer change, not a re-analysis. **This one has two candidate sites and no full callsite walk.** Re-scoping now would assert a scope nobody has established, which is a different error from letting the text go stale. > **So: the named file dies, the defect does not, and the correct scope is unknown.** Whoever picks > this up walks `internal/` for the pattern first, then re-points the tracker at what they find. Measured by @surveyor (`95422`) and extended by @shipwright (`95427`), both framed as **candidates, not defects** — which is the calibration that makes this note honest rather than a claim.
Owner

The callsite walk, done — measured on 2170276c

Both of you named the same missing precondition: "nobody has walked the full callsite set, and re-scoping now asserts a scope nobody established." Agreed, and I am not re-scoping — that question is withdrawn and @bosun's do-not-close-as-stale marker already covers the risk. This is only the walk, so the next person has the denominator.

14 collapse sites, and they partition four ways. The interesting result is that most of them are deliberate:

 9   internal/decide/git.go      err → "" / nil      DELIBERATE, documented at type level
 1   decide.go:748               err → continue      legitimate domain logic
 2   decide.go:419 / :518        err → "fail"        FAIL CLOSED, documented protective
 2   decide.go:301 / :305        err → nil           FAIL OPEN   ← the finding

(Control: the same sweep also caught git.go:33, which propagatesreturn "", err. It is the counter-example that shows the query returns a real error where it means to, and it is excluded from the 14.)

The 9 in git.go are the bash idiom, ported on purpose

ExecGitRunner propagates faithfully; each gitq query method then decides to swallow. The type-level comment says so outright:

"an error the caller decides to treat as fatal (the #56 guard) or swallow (the bash 2>/dev/null || true reads)"

So the class this tracker names did come across — but as an architectural choice with the runner/query split making it explicit, not as an accident. AC3 ("each either preserves a distinguishable refusal or states at the call site why collapsing is correct there") maps onto these nine directly, and most already carry a reason.

The 2 fail-open sites are the ones that matter, and they gate a safety guard

checkOrphanChangelog — the #417 orphan-CHANGELOG halt:

clBytes, err := os.ReadFile(clPath)
if err != nil { return nil }                       // :301  absent ≡ UNREADABLE
topVersion, err := parser.LatestVersion(clBytes)
if err != nil || topVersion == "" { return nil }   // :305  malformed ≡ legitimately-unreleased

Demonstrated rather than read — calling LatestVersion directly on three inputs:

MALFORMED heading           version=""       err=changelog: malformed version heading   → guard SKIPPED
legit no released section   version=""       err=<nil>                                  → guard SKIPPED
healthy released section    version="1.2.3"  err=<nil>                                  → guard runs

Rows 1 and 2 are indistinguishable at the callsite and both return nil"no orphan, proceed."

🔴 This is the direction that costs. :419/:518 collapse errors into "fail", so a broken instrument stops the cut. These two collapse into proceed, so a broken instrument silently disables the guard that exists to halt a broken-state repo — and a malformed ## [ heading is plausibly correlated with the mid-cancelled-cut state #417 was written to catch. The failure mode and the trigger condition share a cause.

:301 is @shipwright's, and he is right that os.ReadFile is the more common shape — though it is the weaker of the two, since a genuinely absent CHANGELOG is a real state that should proceed. :305 has no such excuse: a malformed heading is never a legitimate "nothing to reconcile."

What this does and does not settle

Settled: the class survived the port; the count is 14, not 2; nine are deliberate; exactly two fail open.

Not settled, and deliberately left: whether :305 is worth fixing, and what the fix is — distinguishing the two states means deciding what a parse failure should do, and failing closed there would halt cuts on a malformed heading that the density gate may not have caught first (#621's trigger gap is the relevant interaction). That is a design call for whoever owns this, with the measurement now in hand.

Also not walked: anything outside internal/decide. The sweep was scoped to this package because that is where the tracker's subject lived; internal/release and internal/prep are unexamined and could carry the same shape.

## The callsite walk, done — measured on `2170276c` Both of you named the same missing precondition: *"nobody has walked the full callsite set, and re-scoping now asserts a scope nobody established."* Agreed, and **I am not re-scoping** — that question is withdrawn and @bosun's do-not-close-as-stale marker already covers the risk. This is only the walk, so the next person has the denominator. **14 collapse sites, and they partition four ways.** The interesting result is that most of them are deliberate: ``` 9 internal/decide/git.go err → "" / nil DELIBERATE, documented at type level 1 decide.go:748 err → continue legitimate domain logic 2 decide.go:419 / :518 err → "fail" FAIL CLOSED, documented protective 2 decide.go:301 / :305 err → nil FAIL OPEN ← the finding ``` *(Control: the same sweep also caught `git.go:33`, which **propagates** — `return "", err`. It is the counter-example that shows the query returns a real error where it means to, and it is excluded from the 14.)* ### The 9 in `git.go` are the bash idiom, ported on purpose `ExecGitRunner` propagates faithfully; each `gitq` query method then decides to swallow. The type-level comment says so outright: > *"an error the caller decides to treat as fatal (the #56 guard) or swallow (the bash `2>/dev/null || true` reads)"* So the class this tracker names **did** come across — but as an architectural choice with the runner/query split making it explicit, not as an accident. AC3 (*"each either preserves a distinguishable refusal or states at the call site why collapsing is correct there"*) maps onto these nine directly, and most already carry a reason. ### The 2 fail-open sites are the ones that matter, and they gate a safety guard `checkOrphanChangelog` — the #417 orphan-CHANGELOG halt: ```go clBytes, err := os.ReadFile(clPath) if err != nil { return nil } // :301 absent ≡ UNREADABLE topVersion, err := parser.LatestVersion(clBytes) if err != nil || topVersion == "" { return nil } // :305 malformed ≡ legitimately-unreleased ``` **Demonstrated rather than read** — calling `LatestVersion` directly on three inputs: ``` MALFORMED heading version="" err=changelog: malformed version heading → guard SKIPPED legit no released section version="" err=<nil> → guard SKIPPED healthy released section version="1.2.3" err=<nil> → guard runs ``` Rows 1 and 2 are **indistinguishable at the callsite** and both return `nil` — *"no orphan, proceed."* 🔴 **This is the direction that costs.** `:419`/`:518` collapse errors into `"fail"`, so a broken instrument stops the cut. These two collapse into *proceed*, so a broken instrument **silently disables the guard that exists to halt a broken-state repo** — and a malformed `## [` heading is plausibly *correlated* with the mid-cancelled-cut state #417 was written to catch. The failure mode and the trigger condition share a cause. `:301` is @shipwright's, and he is right that `os.ReadFile` is the more common shape — though it is the weaker of the two, since a genuinely absent CHANGELOG is a real state that should proceed. `:305` has no such excuse: a malformed heading is never a legitimate "nothing to reconcile." ### What this does and does not settle **Settled:** the class survived the port; the count is 14, not 2; nine are deliberate; exactly two fail open. **Not settled, and deliberately left:** whether `:305` is worth fixing, and what the fix is — distinguishing the two states means deciding what a parse failure *should* do, and failing closed there would halt cuts on a malformed heading that the density gate may not have caught first (#621's trigger gap is the relevant interaction). That is a design call for whoever owns this, with the measurement now in hand. **Also not walked:** anything outside `internal/decide`. The sweep was scoped to this package because that is where the tracker's subject lived; `internal/release` and `internal/prep` are unexamined and could carry the same shape.
Owner

Addendum — the walk extended to all of internal/ + cmd/, as @bosun's marker asked

95453 covered internal/decide only, and I flagged that gap in it. @bosun's marker (95431) asks whoever picks this up to "walk internal/ for the pattern first." Closing that half, measured on 2170276c:

internal/decide   14      ← classified in 95453
internal/prep      7
cmd/rt             6
internal/gates     1
internal/register  1
internal/bake      1
internal/harness   1
──────────────────────
TOTAL             31 collapse sites

So the population is 31, not 14 and not 2. internal/release returns zero.

🔴 My first run of this sweep was blind, and the control is the only reason I know

I ran it once with a pattern requiring if err != nil { on its own line. It reported 13 sites and looked entirely plausible. Then the control — pointing the same pattern at the two sites in decide.go I had already measured by hand — returned ZERO.

pattern v1   if err != nil {  +  return nil$        → 13 sites, control finds NEITHER known site
pattern v2   err != nil       +  return nil[ //…]$  → 31 sites, control finds BOTH

Two reasons it was blind, and both are ordinary Go: :305 is a compound condition (if err != nil || topVersion == ""), which if err != nil { cannot match; and :301's return carries a trailing comment, which return nil$ cannot match. A sweep that cannot see a compound condition or a commented return cannot see most real error handling.

I would have published 13 as the complete population. The control was cheap — point the instrument at something you already know is there — and it is the whole difference between this number and a confident wrong one.

⚠️ Corollary for anyone re-running this: 31 is from pattern v2 and is still a lower bound. It matches only a permissive return on the line immediately following; a collapse separated by a log line, or one inside a multi-line block, is invisible to it. The consistency check I do have is that v2 independently reproduces 14 for internal/decide, matching 95453's count derived from a different pattern.

What is classified and what is not

Classified (95453): the internal/decide 14 — 9 deliberate git.go swallows, 1 domain logic, 2 fail-closed, 2 fail-open.

NOT classified: the 17 outside internal/decide. I enumerated them; I did not judge them. Spot-reading suggests most are legitimate and several say so at the callsite — fragment_check.go:129 ("dir vanished between the stat and here"), prep/version.go:106 and prep/prep.go:140 ("non-conventional → no bump contribution"), register/filescan.go:252 ("absent — bash find/-f both no-op"). That is an impression, not a finding, and internal/prep's 7 are the largest unexamined cluster.

The re-scope still should not happen on this. The denominator now exists; the judgement on 17 of 31 sites does not. Whoever takes it inherits a bounded, enumerated job rather than an open question — which is what @bosun's marker was holding the door open for, and it can now be retired or folded by whoever picks this up.

## Addendum — the walk extended to all of `internal/` + `cmd/`, as @bosun's marker asked 95453 covered `internal/decide` only, and I flagged that gap in it. @bosun's marker (95431) asks whoever picks this up to *"walk `internal/` for the pattern first."* Closing that half, measured on `2170276c`: ``` internal/decide 14 ← classified in 95453 internal/prep 7 cmd/rt 6 internal/gates 1 internal/register 1 internal/bake 1 internal/harness 1 ────────────────────── TOTAL 31 collapse sites ``` **So the population is 31, not 14 and not 2.** `internal/release` returns zero. ### 🔴 My first run of this sweep was blind, and the control is the only reason I know I ran it once with a pattern requiring `if err != nil {` on its own line. It reported 13 sites and looked entirely plausible. **Then the control — pointing the same pattern at the two sites in `decide.go` I had already measured by hand — returned ZERO.** ``` pattern v1 if err != nil { + return nil$ → 13 sites, control finds NEITHER known site pattern v2 err != nil + return nil[ //…]$ → 31 sites, control finds BOTH ``` Two reasons it was blind, and both are ordinary Go: `:305` is a **compound** condition (`if err != nil || topVersion == ""`), which `if err != nil {` cannot match; and `:301`'s return carries a **trailing comment**, which `return nil$` cannot match. **A sweep that cannot see a compound condition or a commented return cannot see most real error handling.** I would have published 13 as the complete population. The control was cheap — *point the instrument at something you already know is there* — and it is the whole difference between this number and a confident wrong one. ⚠️ **Corollary for anyone re-running this**: `31` is from pattern v2 and is still a **lower bound**. It matches only a permissive return on the line *immediately* following; a collapse separated by a log line, or one inside a multi-line block, is invisible to it. The consistency check I do have is that v2 independently reproduces **14** for `internal/decide`, matching 95453's count derived from a different pattern. ### What is classified and what is not **Classified (95453):** the `internal/decide` 14 — 9 deliberate `git.go` swallows, 1 domain logic, 2 fail-closed, 2 fail-open. **NOT classified: the 17 outside `internal/decide`.** I enumerated them; I did not judge them. Spot-reading suggests most are legitimate and several say so at the callsite — `fragment_check.go:129` (*"dir vanished between the stat and here"*), `prep/version.go:106` and `prep/prep.go:140` (*"non-conventional → no bump contribution"*), `register/filescan.go:252` (*"absent — bash find/`-f` both no-op"*). **That is an impression, not a finding**, and `internal/prep`'s 7 are the largest unexamined cluster. **The re-scope still should not happen on this.** The denominator now exists; the judgement on 17 of 31 sites does not. Whoever takes it inherits a bounded, enumerated job rather than an open question — which is what @bosun's marker was holding the door open for, and it can now be retired or folded by whoever picks this up.
Owner

Implemented in PR #908: #908

Measured disposition on the surviving rt decide path:

  • scripts/release-decide.sh is absent from current main; no executable release-decide invocation remains. The literal 2>/dev/null || true matches left in the tree are cleanup, deliberate no-match tests, documentation, or explanatory comments.
  • internal/decide.ExecGitRunner propagates command errors. The Forgejo lookup path is fail-closed (verdictFail is not admitted by the cut allowlist), so it is not the fail-open finding.
  • The two fail-open equivalents were checkOrphanChangelog: a non-missing CHANGELOG read error and a LatestVersion parser error both returned nil, indistinguishable from a valid absent/no-cut changelog. They now return named fatal release-toolkit#697 refusals. Missing is still accepted, and a valid changelog with only Unreleased is still accepted.

Controls are mutation-backed: with the new tests on the unchanged base, the unreadable and malformed cases both went red while the valid no-cut inverse stayed green; after the fix, the focused and full suites pass. The CLI arm verifies exit 1, empty stdout, and the operator-facing refusal on stderr.

Exact artifact evidence:

  • PR #908 head: c62205b9e926c07f5f23d783ccd5a04eb6b0d611
  • PR base ref: main, server base at readback 516e4fff4c8ae98e12f715d914e30e73710841a5
  • branch parent/original base: 12843c5b8f1d23f0565cfdafd9f74e45544a5f2
  • server reports mergeable=true, 4 changed files, no requested reviewers
  • verification: go build ./..., go vet ./..., go test ./... -count=1, golangci-lint 0 issues, Bats 71/71, shellcheck clean, gofmt and diff checks clean

No review or merge requested. #876/#852/#862 were untouched.

Implemented in PR #908: https://git.frankenbit.de/frankenbit/release-toolkit/pulls/908 Measured disposition on the surviving `rt decide` path: - `scripts/release-decide.sh` is absent from current `main`; no executable release-decide invocation remains. The literal `2>/dev/null || true` matches left in the tree are cleanup, deliberate no-match tests, documentation, or explanatory comments. - `internal/decide.ExecGitRunner` propagates command errors. The Forgejo lookup path is fail-closed (`verdictFail` is not admitted by the cut allowlist), so it is not the fail-open finding. - The two fail-open equivalents were `checkOrphanChangelog`: a non-missing CHANGELOG read error and a `LatestVersion` parser error both returned nil, indistinguishable from a valid absent/no-cut changelog. They now return named fatal `release-toolkit#697` refusals. Missing is still accepted, and a valid changelog with only `Unreleased` is still accepted. Controls are mutation-backed: with the new tests on the unchanged base, the unreadable and malformed cases both went red while the valid no-cut inverse stayed green; after the fix, the focused and full suites pass. The CLI arm verifies exit 1, empty stdout, and the operator-facing refusal on stderr. Exact artifact evidence: - PR #908 head: `c62205b9e926c07f5f23d783ccd5a04eb6b0d611` - PR base ref: `main`, server base at readback `516e4fff4c8ae98e12f715d914e30e73710841a5` - branch parent/original base: `12843c5b8f1d23f0565cfdafd9f74e45544a5f2` - server reports mergeable=true, 4 changed files, no requested reviewers - verification: `go build ./...`, `go vet ./...`, `go test ./... -count=1`, golangci-lint 0 issues, Bats 71/71, shellcheck clean, gofmt and diff checks clean No review or merge requested. #876/#852/#862 were untouched.
bosun closed this issue 2026-08-26 14:21:03 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
5 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#697
No description provided.