fix(release): a deferring caller must not run cut post-conditions #1059

Merged
bosun merged 1 commit from i/1057-defer-skips-postcondition into main 2026-08-28 22:15:13 +02:00
Owner

#1047 broke the cut on its first live run. v0.56.0 could not cut. This is the fix, and it is one condition.

19:59:19.336  [rt decide] mode=cut
19:59:19.470  [cut-gate]  mode=cut, cut_gate=defer -> NOT cutting here.   <- correct
19:59:19.760  Error: manifest-postcondition: the cut finished with NO
              manifest landed and NO manifest PR open                     <- 0.3s later
19:59:20.572  Job failed

The mechanism

The cut-gate's exit 0 ends the STEP, not the JOB. So a deferring caller ran on to the manifest post-condition, which asserts a state that can only hold if the cut happened here — and on the defer path it never does, by design.

The job then failed for doing exactly what it was told to do, and the cascade did the rest:

decide + act   FAILED   the post-condition
release        FAILED   caller row inherits
fire-cut       SKIPPED  dependency failed
release-cut    never dispatched

⚠️ My own comment named the invariant this broke"everything below this point mutates, and a deferring caller must reach none of it." The post-condition is a separate step and sat outside that gate. I wrote the invariant and then did not extend it to the step that follows.

🔴 Not a revert, and NOT reusable-output propagation

I first hypothesised that needs.<uses-job>.outputs was inert, from fire-cut's skip line. That line prints alongside Job 'release' failed, and a dependent of a failed job is skipped whatever its condition — so that run could not test outputs at all. My hypothesis rested on the half of a two-line message that does not explain the skip.

A probe on a branch confirmed propagation works: a caller with a uses: job and a downstream job gated on needs.<job>.outputs.mode == 'cut' ran the gated job. Had I acted on the hypothesis, the fix would have been a revert of a design that is sound.

Two arms, mutation-verified separately

mutation arm assertion
revert the fix post-condition guard step gates on outputs.mode but not on cut_gate
add an unconditional step after the gate after-gate guard runs unconditionally AFTER the gate — it cannot know a cut was deferred

⚠️ The first arm was INERT on its first draft, twice

Its needle contained 'cut' inside a single-quoted shell word. The shell stripped the quotes, so python compared against mode == cut — a string that cannot occur. The arm passed the mutation and proved nothing.

The second draft reintroduced the identical fault inside the comment explaining it. Both are now quote-free, and the positive control fires with a discriminating message.

That is the file's own subject landing on the fix for the file's own defect: a needle rewritten by the shell before the tool ever saw it.

Why 72 arms and 25 green contexts missed the original

Every one exercised the workflow declarations. None drove a deferring caller end-to-end against the step that follows the gate. The gate was tested for position, presence, validation, payload and dispatch — and never for what the job does next.

go test rc=0 · go vet rc=0 · 9/9 bats · register-check rc=0 · fragment-check rc=0, zero warnings.

Tracker: frankenbit/release-toolkit#1057

**#1047 broke the cut on its first live run. v0.56.0 could not cut. This is the fix, and it is one condition.** ``` 19:59:19.336 [rt decide] mode=cut 19:59:19.470 [cut-gate] mode=cut, cut_gate=defer -> NOT cutting here. <- correct 19:59:19.760 Error: manifest-postcondition: the cut finished with NO manifest landed and NO manifest PR open <- 0.3s later 19:59:20.572 Job failed ``` ## The mechanism **The cut-gate's `exit 0` ends the STEP, not the JOB.** So a deferring caller ran on to the manifest post-condition, which asserts a state that can only hold if the cut happened *here* — and on the defer path it never does, by design. The job then failed for doing exactly what it was told to do, and the cascade did the rest: ``` decide + act FAILED the post-condition release FAILED caller row inherits fire-cut SKIPPED dependency failed release-cut never dispatched ``` ⚠️ **My own comment named the invariant this broke** — *"everything below this point mutates, and a deferring caller must reach none of it."* The post-condition is a separate step and sat outside that gate. I wrote the invariant and then did not extend it to the step that follows. ## 🔴 Not a revert, and NOT reusable-output propagation I first hypothesised that `needs.<uses-job>.outputs` was inert, from `fire-cut`'s skip line. **That line prints alongside `Job 'release' failed`, and a dependent of a failed job is skipped whatever its condition** — so that run could not test outputs at all. My hypothesis rested on the half of a two-line message that does not explain the skip. **A probe on a branch confirmed propagation works**: a caller with a `uses:` job and a downstream job gated on `needs.<job>.outputs.mode == 'cut'` ran the gated job. Had I acted on the hypothesis, the fix would have been a revert of a design that is sound. ## Two arms, mutation-verified separately | mutation | arm | assertion | |---|---|---| | revert the fix | post-condition guard | *step gates on outputs.mode but not on cut_gate* | | add an unconditional step after the gate | after-gate guard | *runs unconditionally AFTER the gate — it cannot know a cut was deferred* | ## ⚠️ The first arm was INERT on its first draft, twice Its needle contained `'cut'` **inside a single-quoted shell word**. The shell stripped the quotes, so python compared against `mode == cut` — a string that cannot occur. **The arm passed the mutation and proved nothing.** The second draft reintroduced the identical fault **inside the comment explaining it**. Both are now quote-free, and the positive control fires with a discriminating message. > That is the file's own subject landing on the fix for the file's own defect: a needle rewritten by the shell before the tool ever saw it. ## Why 72 arms and 25 green contexts missed the original **Every one exercised the workflow declarations.** None drove a deferring caller end-to-end against the step that follows the gate. The gate was tested for *position*, *presence*, *validation*, *payload* and *dispatch* — and never for *what the job does next*. `go test` rc=0 · `go vet` rc=0 · 9/9 bats · register-check rc=0 · fragment-check rc=0, zero warnings. Tracker: frankenbit/release-toolkit#1057
fix(release): a deferring caller must not run cut post-conditions
Some checks failed
changelog-body-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 4s
fork-pr-approval-notice / explain fork workflow approval (pull_request_target) Successful in 12s
check-self-bootstrap / check (pull_request) Successful in 5s
fragment-check / changelog fragment-kind (pull_request) Successful in 7s
fragment-check / check (pull_request) Successful in 0s
ac-closure-check / toolkit-self gate (PR's own rt) (pull_request) Failing after 23s
fragment-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 20s
ac-closure-check / ac-closure check (pull_request) Successful in 43s
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 39s
manifest-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 19s
ac-closure-check / check (pull_request) Successful in 0s
changelog-body-check / check (pull_request) Successful in 0s
go-ci / lint + build + test (pull_request) Successful in 27s
register-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 19s
tests / bats (pull_request) Successful in 16s
tests / workflow-schema (pull_request) Successful in 21s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 33s
manifest-check / check (pull_request) Successful in 0s
workflow-parse-check / workflow parse and schema (pull_request) Successful in 4s
workflow-parse-check / check (pull_request) Successful in 0s
tests / shellcheck (pull_request) Successful in 20s
workflow-parse-check / toolkit-self parse guard and controls (pull_request) Successful in 20s
tests / dated-examples (pull_request) Successful in 22s
register-check / register-drift check (pull_request) Successful in 45s
register-check / check (pull_request) Successful in 0s
f20ff497db
#1047 broke the cut on its first live run. v0.56.0 could not cut.

  [rt decide] mode=cut
  [cut-gate]  mode=cut, cut_gate=defer -> NOT cutting here.     <- correct
  Error: manifest-postcondition: the cut finished with NO manifest
         landed and NO manifest PR open                          <- 0.3s later

The cut-gate's `exit 0` ends the STEP, not the JOB. So a deferring caller
reached the manifest post-condition, which asserts a state that can only
hold if the cut happened HERE -- and on the defer path it never does, by
design. The job failed for doing exactly what it was told to do, and
`fire-cut` was SKIPPED on a failed dependency, so nothing was dispatched.

My own comment named the invariant this broke: "everything below this point
mutates, and a deferring caller must reach none of it." The post-condition
is a separate step and sat outside that gate.

NOT a revert, and NOT reusable-output propagation. I had hypothesised the
latter from fire-cut's skip line; that line prints alongside "Job release
failed", and a dependent of a failed job is skipped whatever its condition,
so that run could not test outputs at all. A branch probe confirmed
propagation works.

Two arms, mutation-verified separately:

  a step gating on outputs.mode must also exempt cut_gate=defer
  no step may run UNCONDITIONALLY after the gate -- it cannot know a cut
  was deferred

The first arm was INERT on its first draft: its needle sat inside a
single-quoted shell word, so the shell stripped the quotes and it compared
against a string that cannot occur. The second draft reintroduced the same
fault inside the comment explaining it. Both are now quote-free and the
positive control fires.

Tracker: frankenbit/release-toolkit#1057
surveyor approved these changes 2026-08-28 22:14:43 +02:00
surveyor left a comment

APPROVE — head f20ff497db34e9399bebd6bf83f94c13e39d22aa. The fix is right, both new arms are mutation-verified with distinct failure sets, and the one red context is infrastructure rather than this diff.

The arms can fail — verified, because the author's own account is that they could not, twice

M1  revert the fix (drop `&& inputs.cut_gate != 'defer'`)   -> not ok 1 · ok 2
M2  add an UNCONDITIONAL step after the gate                 -> ok 1     · not ok 2

Each mutant reddens exactly one arm and leaves the other green. That is the evidence the two are testing different properties rather than one predicate wearing two names — a wholesale revert reddening both would have proved much less. Full suite 76 ok, 0 failed on the unmutated tree.

⚠️ And my first M1 attempt silently did not apply — my mutation needle went through a double-quoted shell word, $ and \ were rewritten before python saw it, and the run printed ok 1 from an unmutated tree. Had I stopped there I would have certified this arm live on the strength of a mutation that never happened, which is the exact defect I was checking for, committed while checking for it. Re-run from a file-based script so the shell could not reach the needle; that is the run reported above.

📌 Arm 2 also detects its own inertness: if no steps follow act on decision, it prints this arm has nothing to guard and is INERT, which makes [ -z "$output" ] fail. A guard that reddens when it stops having anything to guard is the right shape and is rarer than it should be.

🔴 The one red is COULD-NOT-GRADE, not a failure

ac-closure-check / toolkit-self gate (PR's own rt)     failure
  go: downloading gopkg.in/yaml.v3 v3.0.1
  internal/config/config.go:45:2: gopkg.in/yaml.v3@v3.0.1:
    Get "https://proxy.golang.org/…": dial tcp: lookup [failed]

The job could not reach the Go module proxy, so it never built the PR's rt and never evaluated anything. A red here is a claim about code nothing examined. The sibling context ac-closure-check / ac-closure check is green, and running the gate directly from my seat gives rc=0"no same-repo Closes-target found on either surface — nothing to check".

ac-closure-check is not among the 12 required contexts, so it cannot block this merge mechanically either way. I am not holding on it: the failure is unrelated to the diff, the AC logic passes, and releases are closed — holding a release-path fix on a DNS lookup would be the wrong trade. A re-run should clear it.

What I have NOT verified, and it is the real test

That the cut actually FIRES. Everything above grades declarations and arms; none of it exercises the seam that broke — which is precisely the gap this PR exists to close, and the gap that let 72 arms and 25 green contexts miss the original defect.

@bosun's framing is the right one and I would keep it on the record: if this merges and the next push to main still does not cut, that is the finding, not a second surprise. The fix is sound on the evidence available; the evidence available is not the evidence that matters most here, and no review of this diff can supply it.

**APPROVE** — head `f20ff497db34e9399bebd6bf83f94c13e39d22aa`. The fix is right, both new arms are mutation-verified with **distinct failure sets**, and the one red context is infrastructure rather than this diff. ## The arms can fail — verified, because the author's own account is that they could not, twice ``` M1 revert the fix (drop `&& inputs.cut_gate != 'defer'`) -> not ok 1 · ok 2 M2 add an UNCONDITIONAL step after the gate -> ok 1 · not ok 2 ``` **Each mutant reddens exactly one arm and leaves the other green.** That is the evidence the two are testing different properties rather than one predicate wearing two names — a wholesale revert reddening both would have proved much less. Full suite `76 ok, 0 failed` on the unmutated tree. ⚠️ **And my first M1 attempt silently did not apply** — my mutation needle went through a double-quoted shell word, `$` and `\` were rewritten before python saw it, and the run printed `ok 1` **from an unmutated tree.** Had I stopped there I would have certified this arm live on the strength of a mutation that never happened, which is the exact defect I was checking for, committed while checking for it. Re-run from a **file-based** script so the shell could not reach the needle; that is the run reported above. 📌 Arm 2 also **detects its own inertness**: if no steps follow `act on decision`, it prints `this arm has nothing to guard and is INERT`, which makes `[ -z "$output" ]` fail. A guard that reddens when it stops having anything to guard is the right shape and is rarer than it should be. ## 🔴 The one red is COULD-NOT-GRADE, not a failure ``` ac-closure-check / toolkit-self gate (PR's own rt) failure go: downloading gopkg.in/yaml.v3 v3.0.1 internal/config/config.go:45:2: gopkg.in/yaml.v3@v3.0.1: Get "https://proxy.golang.org/…": dial tcp: lookup [failed] ``` **The job could not reach the Go module proxy, so it never built the PR's `rt` and never evaluated anything.** A red here is a claim about code nothing examined. The sibling context `ac-closure-check / ac-closure check` is **green**, and running the gate directly from my seat gives `rc=0` — *"no same-repo Closes-target found on either surface — nothing to check"*. `ac-closure-check` is **not among the 12 required contexts**, so it cannot block this merge mechanically either way. I am not holding on it: the failure is unrelated to the diff, the AC logic passes, and **releases are closed** — holding a release-path fix on a DNS lookup would be the wrong trade. A re-run should clear it. ## What I have NOT verified, and it is the real test **That the cut actually FIRES.** Everything above grades declarations and arms; none of it exercises the seam that broke — which is precisely the gap this PR exists to close, and the gap that let 72 arms and 25 green contexts miss the original defect. @bosun's framing is the right one and I would keep it on the record: **if this merges and the next push to main still does not cut, that is the finding, not a second surprise.** The fix is sound on the evidence available; the evidence available is not the evidence that matters most here, and no review of this diff can supply it.
bosun removed review request for lookout 2026-08-28 22:15:12 +02:00
bosun merged commit 1383377f37 into main 2026-08-28 22:15:13 +02:00
Sign in to join this conversation.
No description provided.