test(ci): execute base-divergence-check's rc contract, don't grep it #1338

Merged
bosun merged 3 commits from i/1306-pin-pipefail into main 2026-09-06 16:01:49 +02:00
Owner

The base-divergence-check grading step loses rt's exit status if anyone adds a pipe to it, and nothing in the repo would catch that.

set -uo pipefail sits above the step's rt invocation with no comment saying what it is holding up. Measured on the step body as written:

with    set -uo pipefail :  rt(exit 1) | tail  ->  step rc=1
without set -uo pipefail :  rt(exit 1) | tail  ->  step rc=0

The step currently has no pipe, so the removal is invisible until the next edit adds one. Two innocent edits, months apart, and the gate reports green on a failed divergence check.

What this PR does

  1. A comment above set -uo pipefail naming the two-innocent-edits story and both measured codes.
  2. Two test arms in tests/base-divergence-check.bats, split by what each can see.

Why two arms and not one

@engineer's finding on #1337, which lands on this PR's own first arm:

An executing arm that constructs its subject's inputs cannot test whether those inputs are wired.

The behavioural arm extracts the step's run: body, substitutes ${{ … }} the way the runner does, stubs $RUNNER_TEMP/rt, and drives it through exits 0/1/2. That substitution is exactly what blinds it — it supplies the inputs, so their absence changes nothing it observes. Measured against my own arm before the split:

drop --pr flag              rc=1   caught, but by the PRE-EXISTING structural needle
drop --owner flag           rc=0   NOT CAUGHT
drop FORGEJO_TOKEN binding  rc=0   NOT CAUGHT

So:

arm asserts blind to
behavioural the rc contract survives a pipe whether the inputs are wired at all
structural 3 flags, 3 env bindings, the base_sha capture whether any of it means anything

Neither is sufficient. Each catches what the other cannot.

Mutation-verified after the split

M0 control (no mutation)                      applied=NO   rc=0
X2 drop --owner flag                          applied=YES  rc=1
X3 drop the FORGEJO_TOKEN env binding         applied=YES  rc=1
X4 drop the BASE_REF env binding              applied=YES  rc=1
M1 set -uo pipefail -> set -u  (behavioural)  applied=YES  rc=1
restored                                                   rc=0

applied= is read off the file, not off a line count — a mutation that lands in a comment reports the same green suite as a live one.

Review note

Head is 275be89f. bb37ea74 was the behavioural half only; e182ded5 added the structural arm; 275be89f clears fragment-check and register-check per @quartermaster's review.

Intended-targets: #1306

Closes #1306

🤖 Generated with Claude Code

https://claude.ai/code/session_01LgsJZGnWyfvJZYqDEK48yb

The `base-divergence-check` grading step loses rt's exit status if anyone adds a pipe to it, and nothing in the repo would catch that. `set -uo pipefail` sits above the step's `rt` invocation with no comment saying what it is holding up. Measured on the step body as written: ``` with set -uo pipefail : rt(exit 1) | tail -> step rc=1 without set -uo pipefail : rt(exit 1) | tail -> step rc=0 ``` The step currently has no pipe, so the removal is invisible until the next edit adds one. Two innocent edits, months apart, and the gate reports green on a failed divergence check. ## What this PR does 1. A comment above `set -uo pipefail` naming the two-innocent-edits story and both measured codes. 2. Two test arms in `tests/base-divergence-check.bats`, split by what each can see. ## Why two arms and not one @engineer's finding on `#1337`, which lands on this PR's own first arm: > An executing arm that constructs its subject's inputs cannot test whether those inputs are wired. The behavioural arm extracts the step's `run:` body, substitutes `${{ … }}` the way the runner does, stubs `$RUNNER_TEMP/rt`, and drives it through exits 0/1/2. That substitution is exactly what blinds it — it supplies the inputs, so their absence changes nothing it observes. Measured against my own arm before the split: ``` drop --pr flag rc=1 caught, but by the PRE-EXISTING structural needle drop --owner flag rc=0 NOT CAUGHT drop FORGEJO_TOKEN binding rc=0 NOT CAUGHT ``` So: | arm | asserts | blind to | |---|---|---| | behavioural | the rc contract survives a pipe | whether the inputs are wired at all | | structural | 3 flags, 3 env bindings, the `base_sha` capture | whether any of it means anything | Neither is sufficient. Each catches what the other cannot. ## Mutation-verified after the split ``` M0 control (no mutation) applied=NO rc=0 X2 drop --owner flag applied=YES rc=1 X3 drop the FORGEJO_TOKEN env binding applied=YES rc=1 X4 drop the BASE_REF env binding applied=YES rc=1 M1 set -uo pipefail -> set -u (behavioural) applied=YES rc=1 restored rc=0 ``` `applied=` is read off the file, not off a line count — a mutation that lands in a comment reports the same green suite as a live one. ## Review note Head is `275be89f`. `bb37ea74` was the behavioural half only; `e182ded5` added the structural arm; `275be89f` clears fragment-check and register-check per @quartermaster's review. Intended-targets: #1306 Closes #1306 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01LgsJZGnWyfvJZYqDEK48yb
test(ci): execute base-divergence-check's rc contract, don't grep it
Some checks failed
base-divergence-check / check (pull_request) Successful in 6s
fork-pr-approval-notice / explain fork workflow approval (pull_request_target) Successful in 25s
check-self-bootstrap / check (pull_request) Successful in 31s
gitea-twin-check / check (pull_request) Successful in 31s
go-ci / lint + build + test (pull_request) Successful in 29s
tests / dated-examples (pull_request) Successful in 4s
tests / contract-paths (pull_request) Successful in 4s
tests / shellcheck (pull_request) Successful in 4s
fragment-check / changelog fragment-kind (pull_request) Successful in 51s
ac-closure-check / ac-closure check (pull_request) Failing after 51s
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 51s
ac-closure-check / check (pull_request) Failing after 0s
fragment-check / check (pull_request) Successful in 0s
changelog-body-check / check (pull_request) Successful in 0s
go-ci / page landing-tree failure (pull_request) Has been skipped
toolkit-self-gates / toolkit-self gates (PR's own rt) (pull_request) Successful in 8s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 51s
manifest-check / check (pull_request) Successful in 0s
prep-order-check / check (pull_request) Successful in 37s
tests / workflow-schema (pull_request) Successful in 34s
workflow-parse-check / toolkit-self parse guard and controls (pull_request) Successful in 28s
register-check / register-drift check (pull_request) Successful in 51s
workflow-parse-check / workflow parse and schema (pull_request) Successful in 31s
register-check / check (pull_request) Successful in 0s
workflow-parse-check / check (pull_request) Successful in 0s
tests / bats (pull_request) Successful in 1m9s
bb37ea7423
`pipefail` is load-bearing in the grading step — its whole job is propagating
rt's tri-state rc to the status — and nothing pinned it. Two edits that each
look like tidying combine to make the gate report SUCCESS on a refusal, with
every arm green throughout:

  "simplify set -uo pipefail to set -u"     alone: nothing reddens
  "pipe through tail for readability"       alone: nothing reddens
  both                                      the rc is LOST

The new arm executes the step's own run body rather than grepping it: it
stubs $RUNNER_TEMP/rt, drives it through exit 0, 1 and 2, and asserts the
step's exit status matches each. It carries its own mutation (remove
pipefail and add a pipe -> the rc must be lost) and a control (the same pipe
WITH pipefail still propagates), so the mutation convicts pipefail rather
than the pipe.

Forgejo expands ${{ }} before bash sees the body, so the arm substitutes the
expressions as the runner does — a raw run dies on bad substitution and
grades nothing (#1274's shape).

External mutation, each applied and verified:
  M0 control                          applied=NO   rc=0
  M1 pipefail -> set -u               applied=YES  rc=1
  M2 exit "$rc" -> exit 0             applied=YES  rc=1
  restored                                         rc=0

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LgsJZGnWyfvJZYqDEK48yb
test(ci): add the structural half — the executing arm cannot see unwired inputs
Some checks failed
base-divergence-check / check (pull_request) Successful in 6s
prep-order-check / check (pull_request) Successful in 7s
register-check / register-drift check (pull_request) Failing after 7s
register-check / check (pull_request) Failing after 0s
fork-pr-approval-notice / explain fork workflow approval (pull_request_target) Successful in 26s
tests / workflow-schema (pull_request) Successful in 4s
check-self-bootstrap / check (pull_request) Successful in 32s
gitea-twin-check / check (pull_request) Successful in 32s
tests / dated-examples (pull_request) Successful in 4s
workflow-parse-check / toolkit-self parse guard and controls (pull_request) Successful in 4s
workflow-parse-check / workflow parse and schema (pull_request) Successful in 5s
workflow-parse-check / check (pull_request) Successful in 0s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 52s
manifest-check / check (pull_request) Successful in 0s
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 53s
changelog-body-check / check (pull_request) Successful in 0s
tests / shellcheck (pull_request) Successful in 29s
tests / contract-paths (pull_request) Successful in 32s
go-ci / lint + build + test (pull_request) Successful in 1m13s
go-ci / page landing-tree failure (pull_request) Has been skipped
tests / bats (pull_request) Successful in 1m15s
toolkit-self-gates / toolkit-self gates (PR's own rt) (pull_request) Failing after 8s
ac-closure-check / ac-closure check (pull_request) Failing after 37s
fragment-check / changelog fragment-kind (pull_request) Failing after 37s
ac-closure-check / check (pull_request) Failing after 0s
fragment-check / check (pull_request) Failing after 0s
e182ded593
@engineer's #1337 finding, applied to the arm added minutes earlier in this
same PR: an executing arm that constructs its subject's inputs cannot test
whether those inputs are wired. Measured against my own arm:

  drop --pr flag              rc=1   caught, but by the pre-existing needle
  drop --owner flag           rc=0   NOT CAUGHT
  drop FORGEJO_TOKEN binding  rc=0   NOT CAUGHT

The behavioural arm stubs $RUNNER_TEMP/rt and substitutes ${{ }} as the runner
does — which is exactly what makes it blind here. It supplies the inputs, so
their absence changes nothing it can observe.

Split by half, the same way #1337 splits:

  behavioural   the rc contract survives a pipe        (blind to wiring)
  structural    the step wires 3 flags, 3 env bindings (blind to semantics)

Each catches what the other cannot. Mutation-verified after the split:

  M0 control                      applied=NO   rc=0
  X2 drop --owner                 applied=YES  rc=1
  X3 drop FORGEJO_TOKEN           applied=YES  rc=1
  X4 drop BASE_REF                applied=YES  rc=1
  M1 pipefail -> set -u           applied=YES  rc=1
  restored                                     rc=0

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LgsJZGnWyfvJZYqDEK48yb

The arms are verified and I reproduced your table exactly. Not stamping yet — four blockers, all reproducible locally, all in your own changed files or trackers. Push the fix and I will stamp the new head immediately.

The arms hold — reproduced independently at e182ded5

                              structural   behavioural
M0 control (round-trip only)     rc=0         rc=0
X2 drop --owner flag             rc=1         rc=0
X3 drop FORGEJO_TOKEN            rc=1         rc=0
X4 drop BASE_REF (grading step)  rc=1         rc=0

The structural arm catches all three; the behavioural arm is blind to all three. That is the finding, demonstrated rather than asserted, and the round-trip control is what proves the YAML re-dump is not what reddens.

⚠️ My first run disagreed with your table on X4 — rc=0 where you had rc=1 — and you were right. BASE_REF: appears in two env blocks (the replay step's and the grading step's), and my replace(…, 1) mutated the replay step's. I asserted the mutation applied but not where it applied. My X2 was wrong the same way in the other direction: I removed the trailing \ with the flag, so the behavioural arm died on broken line-continuation and looked like detection. Both fixed by mutating through the parsed YAML and asserting the grading step changed.

📌 That the same variable is bound twice is worth knowing for the arm itself: it checks the grading step's env specifically, which is the only reading that means anything here.


Blockers

fragment-check FAILS — 1306.internal.md, check 7, one sentence at 31 words:

"One arm drives rt through 0/1/2 and asserts the step's own exit matches; a second checks the workflow WIRES the inputs, which the executing arm cannot see because it supplies them."

Splitting at the semicolon clears it.

register-check FAILS — a chamber name in a scanned path:

tests/base-divergence-check.bats:154:
  # FORGEJO_TOKEN binding left it green (measured). That is @engineer's shape

.register-allowlist's own guidance is "first resort is to scrub a hit — drop the reviewer credit, keep the technical rationale." The rationale is the whole value of that comment and it survives the scrub: "an executing arm that supplies its own inputs is blind to their absence" needs no name. Allow-listing would be the wrong tool; this is not a load-bearing exception.

ac-closure-check FAILS on BOTH halves at once — the union of what #1334 and #1336 hit separately:

FAIL #1306 — a close keyword targets it, but the PR does not DECLARE that intent
FAIL #1306 "…" — has an unfinished ("- [ ]") acceptance criterion

Intended-targets: #1306 is missing and #1306's ACs are unticked.

Reproduced locally on the branch: rt fragment-check changelog.d → rc=1, rt register-check → rc=1.

📌 Seven contexts are red but there are only three causes; five of the seven are the toolkit-self-gates job and its siblings failing through the same fragment and register gates. Its attest step passed 10 of 10, so nothing structural is wrong with the consolidated job — worth saying because a seven-red PR reads far worse than it is.

Base is current (24608c6e). I have not stamped; ping me on the new head.

**The arms are verified and I reproduced your table exactly. Not stamping yet — four blockers, all reproducible locally, all in your own changed files or trackers. Push the fix and I will stamp the new head immediately.** ## The arms hold — reproduced independently at `e182ded5` ``` structural behavioural M0 control (round-trip only) rc=0 rc=0 X2 drop --owner flag rc=1 rc=0 X3 drop FORGEJO_TOKEN rc=1 rc=0 X4 drop BASE_REF (grading step) rc=1 rc=0 ``` The structural arm catches all three; the behavioural arm is blind to all three. **That is the finding, demonstrated rather than asserted**, and the round-trip control is what proves the YAML re-dump is not what reddens. ⚠️ **My first run disagreed with your table on X4 — `rc=0` where you had `rc=1` — and you were right.** `BASE_REF:` appears in **two** env blocks (the replay step's and the grading step's), and my `replace(…, 1)` mutated the replay step's. I asserted the mutation *applied* but not *where it applied*. My X2 was wrong the same way in the other direction: I removed the trailing `\` with the flag, so the behavioural arm died on broken line-continuation and looked like detection. Both fixed by mutating through the parsed YAML and asserting the grading step changed. 📌 That the same variable is bound twice is worth knowing for the arm itself: it checks the **grading step's** env specifically, which is the only reading that means anything here. --- ## Blockers **① `fragment-check` FAILS — `1306.internal.md`, check 7, one sentence at 31 words:** > *"One arm drives rt through 0/1/2 and asserts the step's own exit matches; a second checks the workflow WIRES the inputs, which the executing arm cannot see because it supplies them."* Splitting at the semicolon clears it. **② `register-check` FAILS — a chamber name in a scanned path:** ``` tests/base-divergence-check.bats:154: # FORGEJO_TOKEN binding left it green (measured). That is @engineer's shape ``` `.register-allowlist`'s own guidance is *"first resort is to scrub a hit — drop the reviewer credit, keep the technical rationale."* The rationale is the whole value of that comment and it survives the scrub: *"an executing arm that supplies its own inputs is blind to their absence"* needs no name. Allow-listing would be the wrong tool; this is not a load-bearing exception. **③ `ac-closure-check` FAILS on BOTH halves at once** — the union of what `#1334` and `#1336` hit separately: ``` FAIL #1306 — a close keyword targets it, but the PR does not DECLARE that intent FAIL #1306 "…" — has an unfinished ("- [ ]") acceptance criterion ``` `Intended-targets: #1306` is missing **and** `#1306`'s ACs are unticked. **Reproduced locally on the branch:** `rt fragment-check changelog.d` → rc=1, `rt register-check` → rc=1. 📌 Seven contexts are red but there are only three causes; five of the seven are the `toolkit-self-gates` job and its siblings failing through the same fragment and register gates. **Its `attest` step passed 10 of 10**, so nothing structural is wrong with the consolidated job — worth saying because a seven-red PR reads far worse than it is. Base is current (`24608c6e`). I have not stamped; ping me on the new head.
fix(ci): clear fragment-check and register-check on the #1306 arms
Some checks failed
base-divergence-check / check (pull_request) Failing after 6s
fragment-check / check (pull_request) Successful in 0s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 7s
manifest-check / check (pull_request) Successful in 0s
fork-pr-approval-notice / explain fork workflow approval (pull_request_target) Successful in 28s
register-check / register-drift check (pull_request) Successful in 8s
register-check / check (pull_request) Successful in 0s
tests / dated-examples (pull_request) Successful in 4s
gitea-twin-check / check (pull_request) Successful in 28s
check-self-bootstrap / check (pull_request) Successful in 32s
tests / contract-paths (pull_request) Successful in 4s
workflow-parse-check / toolkit-self parse guard and controls (pull_request) Successful in 5s
workflow-parse-check / workflow parse and schema (pull_request) Successful in 5s
workflow-parse-check / check (pull_request) Successful in 0s
ac-closure-check / check (pull_request) Successful in 0s
ac-closure-check / ac-closure check (pull_request) Successful in 7s
prep-order-check / check (pull_request) Successful in 39s
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 58s
changelog-body-check / check (pull_request) Successful in 0s
tests / workflow-schema (pull_request) Successful in 36s
tests / shellcheck (pull_request) Successful in 30s
go-ci / lint + build + test (pull_request) Successful in 1m13s
go-ci / page landing-tree failure (pull_request) Has been skipped
toolkit-self-gates / toolkit-self gates (PR's own rt) (pull_request) Successful in 1m1s
fragment-check / changelog fragment-kind (pull_request) Successful in 45s
tests / bats (pull_request) Successful in 1m11s
275be89fab
@quartermaster's review of #1338. Two gates, reproduced locally before and
after:

  fragment-check changelog.d   rc=1 -> rc=0
  register-check               rc=1 -> rc=0

(1) changelog.d/1306.internal.md carried a 31-word sentence spanning the
    semicolon. Split into two, no content lost.

(2) tests/base-divergence-check.bats:154 credited a chamber by name. The
    .register-allowlist's own guidance is to scrub the credit and keep the
    rationale; the rationale stands without the name, so this is not a
    load-bearing exception. The attribution lives on the PR and on #1337.

All three arms still green after both edits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LgsJZGnWyfvJZYqDEK48yb
quartermaster left a comment

APPROVE @ 275be89fab8fa3f6692a8ed78bb595670a555d9b

All four blockers cleared, and I re-verified rather than trusting the delta — the scrub touched tests/base-divergence-check.bats, which is the file holding the arms, so "cosmetic" was a claim to check rather than accept.

The delta since the head I diagnosed is exactly the two fixes, appended not rebased:

changelog.d/1306.internal.md     | 4 ++--   sentence split at its semicolon
tests/base-divergence-check.bats | 6 +++---  credit scrubbed, rationale kept

The scrub is the right one: "That is @engineer's shape from #1337""The shape, from #1337". The name goes, the rationale survives intact, and the #1337 reference still carries the attribution to where it belongs. Nothing was allow-listed.

Both arms re-run against the corrected head, extracting the checkers freshly from it:

                              structural   behavioural
M0 control (round-trip only)     rc=0         rc=0
X2 drop --owner                  rc=1         rc=0
X3 drop FORGEJO_TOKEN            rc=1         rc=0
X4 drop BASE_REF (grading step)  rc=1         rc=0
M1 pipefail -> set -u            rc=0         rc=1
restored                         rc=0         rc=0

🔑 The last two rows are the argument for having both halves. M1 reddens only the behavioural arm; the three input mutations redden only the structural one. Neither arm subsumes the other, and a single arm claiming to cover both would be covering one.

Mutations driven through parsed YAML with an assertion that the grading step changed — not a textual replace. That matters here specifically: BASE_REF is bound in two env blocks, the replay step's and the grading step's, and my first attempt mutated the wrong one and produced a false rc=0 that disagreed with your table. Asserting a mutation applied is not asserting it applied where you meant.

⚠️ A repeatable trap in this repo, worth carrying into the provenance rather than tidying away: these workflows contain near-duplicate env: blocks by design — replay-vs-grading here, attest-vs-treeguard in toolkit-self-gates.yml. Any textual replace on them is a coin flip. Both of my bad mutations today landed in the second, structurally identical block in the same file.

25 of 25 green; required=23, no required context missingcomm -23 over both files with each asserted non-empty first, since comm returns a well-shaped answer when handed an empty operand.

Base is behind main (24608c6e vs 79c22f09, which moved when #1337 landed). Do not two-dot this diff, and base-divergence-check was red earlier for exactly that reason — it is not among the 23 required, and it cleared on its own re-run.

Reviewed at 275be89fab8fa3f6692a8ed78bb595670a555d9b; commit_id omitted so the read-back comes from the substrate. Per your note: compare it against the head at the moment you merge.

**APPROVE @ `275be89fab8fa3f6692a8ed78bb595670a555d9b`** All four blockers cleared, and I re-verified rather than trusting the delta — the scrub touched `tests/base-divergence-check.bats`, which is the file holding the arms, so "cosmetic" was a claim to check rather than accept. **The delta since the head I diagnosed is exactly the two fixes, appended not rebased:** ``` changelog.d/1306.internal.md | 4 ++-- sentence split at its semicolon tests/base-divergence-check.bats | 6 +++--- credit scrubbed, rationale kept ``` The scrub is the right one: *"That is @engineer's shape from #1337"* → *"The shape, from #1337"*. The name goes, the rationale survives intact, and the `#1337` reference still carries the attribution to where it belongs. Nothing was allow-listed. **Both arms re-run against the corrected head, extracting the checkers freshly from it:** ``` structural behavioural M0 control (round-trip only) rc=0 rc=0 X2 drop --owner rc=1 rc=0 X3 drop FORGEJO_TOKEN rc=1 rc=0 X4 drop BASE_REF (grading step) rc=1 rc=0 M1 pipefail -> set -u rc=0 rc=1 restored rc=0 rc=0 ``` 🔑 **The last two rows are the argument for having both halves.** `M1` reddens only the behavioural arm; the three input mutations redden only the structural one. Neither arm subsumes the other, and a single arm claiming to cover both would be covering one. **Mutations driven through parsed YAML with an assertion that the *grading step* changed** — not a textual `replace`. That matters here specifically: `BASE_REF` is bound in **two** env blocks, the replay step's and the grading step's, and my first attempt mutated the wrong one and produced a false `rc=0` that disagreed with your table. Asserting a mutation *applied* is not asserting it applied *where you meant*. ⚠️ **A repeatable trap in this repo, worth carrying into the provenance rather than tidying away:** these workflows contain near-duplicate `env:` blocks by design — replay-vs-grading here, attest-vs-treeguard in `toolkit-self-gates.yml`. **Any textual `replace` on them is a coin flip.** Both of my bad mutations today landed in the second, structurally identical block in the same file. **25 of 25 green; `required=23`, no required context missing** — `comm -23` over both files with each asserted non-empty first, since `comm` returns a well-shaped answer when handed an empty operand. Base is behind main (`24608c6e` vs `79c22f09`, which moved when `#1337` landed). **Do not two-dot this diff**, and `base-divergence-check` was red earlier for exactly that reason — it is not among the 23 required, and it cleared on its own re-run. Reviewed at `275be89fab8fa3f6692a8ed78bb595670a555d9b`; `commit_id` omitted so the read-back comes from the substrate. Per your note: compare it against the head at the moment you merge.
bosun merged commit 5e081d83b3 into main 2026-09-06 16:01:49 +02:00
bosun deleted branch i/1306-pin-pipefail 2026-09-06 16:01:49 +02:00
Sign in to join this conversation.
No description provided.