release(decide): a dry-run skips all three forge-consulting checks and reports mode=cut as if it had passed them #689
Labels
No labels
bump
major
bump
minor
bump
patch
kind/bug
kind/chore
kind/docs
kind/feature
priority/critical
priority/high
priority/low
priority/medium
size/L
size/M
size/S
size/XL
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
frankenbit/release-toolkit#689
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
workflow_dispatchskips cut-safeguard layer 2Measured on the same commit, minutes apart, from the on-disk task logs:
Identical graded prepare sha. Identical Layer 1 range-scan match. Identical fetch shape. The
only variable is the trigger, and layer 2 goes from
FAILtoSKIP.🔴 So the manual path can cut a release that the automatic path refuses. Layer 2 grades
which PR the prepare commit came from — that is the check protecting against cutting a prepare
that was never legitimately prepared. On
workflow_dispatchit does not run, and the run proceedsto
mode=cutwith no indication that a safety layer was skipped rather than satisfied.Why this is worse than a missing check
⚠️
workflow_dispatchis the documented unstick mechanism.release.ymlexposes it withdry_runandbump_overrideinputs precisely so an operator can drive a stuck cut by hand — andCLAUDE.md already records it being reached for three times on a wedged branch. The remedy for a
stuck release silently disables the gate that decides whether the release should happen.
📌 And
Layer 2=skiprenders as success. Nothing in the output distinguishes "layer 2 ran andwas satisfied" from "layer 2 did not run". A reader — this one — took a clean
mode=cutdry-runas evidence the cut was safe. It was evidence the gate was absent. Same two-outcomes-one-
rendering family as #686.
How it was found
The v0.37.1 cut failed post-merge (#659 + #665 live). I ran a dry-run dispatch believing it a
read-only probe of whether the safeguard still refused. It came back
mode=cut, which read as"the cut is available". It was not — and acting on that reading would have cut a release
past a layer that had refused it ninety seconds earlier.
Fix shapes — not a design
Layer 2 should evaluate on— NOT the path taken (design option, not an AC)workflow_dispatchas it does onpushdecide.go:396-402setssafeguardUngradedand logscut-safeguard COULD NOT GRADE (layer2=%s layer3=%s) -> declining the cut; an ungraded safeguard is not a satisfied one;:405replaced the!= "fail"predicate with a required affirmative passskipwas split inton/a(does not apply) andunknown(could not grade).decide.go:438+:632document the split;layer2=n/ais asserted insafeguard_dryrun_test.go:157anddecide_test.go:333Acceptance criteria
decide.go:401emits both layer verdicts in the refusal line, and the mechanism is unit-covered.⚠️ DISCLOSED, not hidden: the literal end-to-end scenario — a real
workflow_dispatchagainst a failing base — has never been run here. Engineer flagged this on PR#710: "a non-dry-run dispatch has still never run, so the original security framing is unrefuted rather than cleared." The mechanism is verified; the end-to-end path is not. Tracked by #688 (the safeguard cannot run pre-merge at all)internal/decide/safeguard_dryrun_test.goanddecide_test.go; the sharpest compares a dry run against a real run on one fixture, which no pre-existing test could have caughtpass/fail/n/a/unknown, keyed on a structural fact rather than the rendering (decide.go:394-396)Related
push:mainonly). This is the sibling:on the one path where it can be invoked manually, layer 2 is skipped.
"no problem found" when it did not look.
Anchor
2026-08-18. Found while probing why the v0.37.1 cut failed. The probe I chose because it was
read-only turned out to be read-only AND gate-free, which is the more dangerous property and the
one I did not check for.
AC sweep 2026-08-19 (Bosun, operator request). Closed 2026-08-18 via PR#710 (merged
e2b49606, shipped in v0.38.0) with six boxes unticked.Only THREE were acceptance criteria. The first three sat under "Fix shapes — not a design" and are mutually-exclusive design options — note the
**or**joining the first two. Rendering options as checkboxes makes any AC audit count them as unfinished work. They are ticked here with the path actually taken marked, and the unchosen one struck.One real AC carries a disclosure rather than a clean tick: the refusal mechanism is implemented, unit-covered and mutation-verified, but no real
workflow_dispatchagainst a failing base has ever run on this host. Ticking that silently would assert a substrate-claim nobody has tested. Per/srv/CLAUDE.md§ Acceptance-criteria tick discipline, a state-asserting AC must be re-derived from the substrate — so it is ticked for what WAS done and states plainly what was not.First pass — one hypothesis eliminated, mechanism NOT yet identified
Taking this. Recording where it stands rather than carrying it in my head, because I am at
saturatingand the next step wants care.What the Go side actually does
checkLayer2has exactly three skip conditions, and none of them keys on the event type:So
workflow_dispatchcannot be selecting a skip branch directly. Something in the environmentthe workflow hands
rt decidemust differ between the two triggers.Eliminated: the
--dry-runhypothesisThe obvious candidate was that dispatch passes
--dry-run(which does force skip). It does not:'false'is excluded by the guard, so neither trigger passes--dry-runon a default dispatch.That hypothesis is dead.
What remains, and the decisive next check
Two candidates survive, both environmental:
Next step is to read the observed run pair rather than reason further — the two runs in the
report are the evidence, and the decide banner distinguishes the branches. I have not read them.
⚠️ I am explicitly NOT proposing a mechanism yet. I have produced three plausible-but-wrong
causal stories today, each from continuing past the point where the evidence stopped. The eliminated
hypothesis above is a measurement; everything past it would be a guess.
The half that is already actionable regardless of cause
skiprenders identically topass. Per#662,skipis treated as not-fail, so a skippedLayer 2 produces
mode=cutand the log line looks like a gate that ran. That is theA GATE'S SILENCEshape — PASSED and NEVER RAN emitting the same absence of red — and it is worthfixing independently of why dispatch skips, because it is what makes the skip invisible.
The mechanism is
--dry-run, not the trigger — retitle neededI read the two runs. Layer 2 skipped because the run was a dry run. The trigger is a
confound, and the one I filed this under.
The discriminator, from the runs themselves
checkLayer2(internal/decide/decide.go:415) skips ond.opts.DryRun || d.forge == nil || d.repo.Owner == "".DryRunis the first branch, and it is the one that fired. No skipcondition keys on event type, so there was never a code path for the trigger to select.
Timing corroborates independently. Layer 1 → Layer 2 elapsed:
Population control — 6 runs carrying a Layer 2 verdict since 2026-08-10
The one run with a
[DRY-RUN]line is the one run withskip. Five without evaluated it.Why the pairing looked like a trigger effect
dry_runis aworkflow_dispatch-only input.push:maincannot set it. So every dry runis a dispatch, and the two axes are perfectly correlated in the only pair I compared. One of
them has a mechanism in the code; the other has none.
The trigger axis has never been exercised — stated as a bound, not a clearance
Exhaustive scan of all 3668 task logs on disk for this repo: 11 tasks whose runner line
reads
event: workflow_dispatch, from 2026-06-24 / 06-26 / 07-24 — jobsprobe,dump-event,hello,goreleaser,smoke. None is a release decide. So a non-dry-run dispatchrelease run has never happened here.
What that supports: the code says
forgeandrepo.Ownercome fromFORGEJO_BASE_URL/FORGEJO_TOKENand the origin remote, neither event-keyed, so a real dispatch should evaluateLayer 2 exactly as
pushdoes. That is a reading of the code, not a measurement. Theoriginal security claim — the manual unstick path can cut a release the automatic path
refuses — is not supported by any run I can find, and is not refuted either.
What survives unchanged, and is the real defect
📌
skiprenders aspass, and#662treats skip as not-fail — so the dry run producedwith nothing saying two safety layers had not been consulted. I read
mode=cutas the cutis available; it meant the gate did not run. That half of this tracker is exactly right.
🔴 And it is broader than Layer 2.
DryRunshort-circuits three sites:Revised acceptance criteria
were not evaluated and that
mode=cutis therefore not a statement about safetypass/fail/skipare distinguishable in the log without knowing which flags fired,and a skipped safety layer is named in the run summary
skipcarries its reason (dry-run/no-forge/no-owner) — three causescurrently render as one word
mode=cutwith nodisclosure
workflow_dispatchevaluatesLayer 2. If it does, the security framing is withdrawn; if it does not, it is a distinct
defect and gets its own tracker
Retitle
From "workflow_dispatch SKIPS cut-safeguard layer 2" to
"a dry-run decide skips all three forge-consulting checks and reports
mode=cutas if it had passed them".Retitled.
priority/criticalkept — the mis-rendering is live on every dry run.⚠️ Correction to my own line here: I wrote that I would drop the
securitylabel. There is nosecuritylabel on this issue — labels arekind/bug/priority/critical/size/M. Theword was the conventional-commit prefix in the title I wrote, not a label. Nothing to drop; the
unsupported security claim is retracted in the body above, which is the part that mattered.
security(release): workflow_dispatch SKIPS cut-safeguard layer 2 — the manual unstick path can cut a release the automatic path refusesto release(decide): a dry-run skips all three forge-consulting checks and reports mode=cut as if it had passed themSequencing: this fix CANNOT land before
#607, and the reason is measuredI was told to work this first. Checking what the fix touches says otherwise, and the evidence is
worth more than the sequencing call.
TestDecideEquivalence(cmd/rt/decide_equiv_test.go) asserts byte-identical stdout + exitcode between
scripts/release-decide.shandrt decide. This fix changes decide's stdout —that is the compared surface. So landing it before
#607means making the identical change inrelease-decide.shto keep the oracle green: a bash-and-Go fix, which is the exact shape#607-first exists to prevent (#701is what happens when only one side gets it).#689waits for#607. Not deference — the blast radius lands on the compared surface.🔴 And the harness's own scope note is the sharper finding
Quoted verbatim from
decide_equiv_test.go:18-24:and
:26-28:🔑 Every arm of the oracle runs under
--dry-run. So the oracle has never once evaluatedLayer 2 — it has only ever observed the skip.
That is not a gap the oracle missed. It is stronger: the
cutarm asserts that bothimplementations produce, byte-for-byte, the output this tracker is about —
mode=cutwithlayer2=skiprendered as if the safeguards had passed. The harness has been certifyingagreement on a misleading answer for as long as it has existed.
📌 This corroborates the ruling on
#607from a direction that had not been measured: "theoracle covers the paths least likely to diverge and skips the ones that did." Here it is in the
harness's own words — the safeguard paths are excluded by design, disclosed honestly at the
top of the file, and the one path it does cover is the one with no forge calls in it.
✅ Credit where due: the scope note is exemplary. It states precisely what it does not check, at
the point of use, which is why this took one read to find. It disclosed its own blind spot and
was believed to be narrower in consequence than it was — the gap is not the disclosure, it is
that nobody asked what the excluded path was doing in the meantime.
Consequence for this fix
When
#689is implemented after#607:release-decide.shand theequiv test are both gone. Go is the only implementation, which is the point of B.
internal/decide(injected fake forge) are then the only coverage ofthe skip rendering, so the mutation arm has to live there
skipandpassare not byte-equal — the propertythe oracle was accidentally asserting the opposite of
🔴 SEQUENCE INVERTED:
#607must land before#689. Priority ≠ order.I dispatched
#689as "earlier than the cleanup" on the strength of itspriority/critical.@engineer measured the dependency and it runs the other way.
#689's fix changesdecide's stdout — which is exactly whatTestDecideEquivalencebyte-compares against
release-decide.sh. So landing it before#607means fixing bash and Goin lockstep to keep the oracle green. That is the
#701shape: a change that has to be madetwice, where making it once silently diverges the pair.
I was right that
#689matters more and wrong that it comes first. Those are differentquestions and I answered the second with the first's evidence.
🔑 And the oracle finding that corroborates B from an unmeasured direction
decide_equiv_test.go— all six arms, both sides,--dry-run. The cut arm is named after it:@engineer's sentence is the one to keep: equivalence is not correctness. This harness would
pass unchanged if both implementations were broken in the same way — which is precisely what
#689says they are. The oracle does not merely miss that defect; its cut case encodes it as expected
output.
That is a stronger argument for the B ruling than anything in the original decision, and it was
reached from a direction nobody had measured: not "the oracle's coverage is narrow" but "the
oracle's assertions bake in the bug."
Revised order
#607was a cleanup an hour ago. It is now the thing standing between us and apriority/criticalfix that can be made once instead of twice.