ci(gates): enforce that the nine gates leave the shared tree alone (#1253) #1332
No reviewers
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!1332
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/1253-gates-share-a-workspace"
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?
Closes the residual I flagged on
#1320and named there as the weakest link in#1253. Not dispatched — I requested a tracker from @bosun and started, because this is a live hazard in code that shipped today, against#1304's hypothetical ~10s.What consolidation quietly changed
A gate that leaves state behind now corrupts a sibling's verdict, and neither gate is wrong on its own — which is exactly why nothing in either gate's own controls can catch it.
That they do not collide today is an audit I performed at step 1, not a property anything enforced. Nothing reddened if a future gate started leaving state behind. Two guards replace the audit.
① Static — no two gate steps may claim the same temp path
$RUNNER_TEMP/rtis the one legitimate sharer; it is the consolidation. It is named in aSHARED_BY_DESIGNset rather than silently special-cased, so widening that set is a visible edit.② Runtime — the tree must end as it began
Record
git status --porcelainafter the build, compare after the nine. Read state and compare — do not assert "clean." Asserting a predicted string would turn a legitimately dirty starting tree into a mystery instead of a mismatch.⚠️ It abstains when any gate did not succeed, and that is the load-bearing design decision
A failing gate may die before its own cleanup runs, so dirt there is expected collateral and the job is already red for that cause. Refusing again would add a second red for one cause and train people to ignore this one.
That is the three-outcomes shape rather than a two-state check rounding "I cannot attribute this" into "fine" — or, worse, into a second red.
Scope, stated in the PASS output rather than left to be inferred
Order-dependent coupling between gates stays unenforced. I would rather say that in the passing message than let a green read as covering it.
Mutation verification
Four mutants, each reddening the arm that owns it, each naming what it broke:
dirt after a SKIPPED gate must abstain, not refusea dirty tree with all nine gates green was ACCEPTED — a leak reads as cleanalways()not always()-guarded — it would skip after a red gate$RUNNER_TEMP/ac-closure-fixture.log is written or read by ['g1', 'g7']Both new arms execute the guard rather than grepping the YAML for it, per the
#1257precedent.The guard this PR adds could itself skip silently — fixed in
a8b5182aApplying the PR's own thesis to the PR. The shared-tree guard carries
if: always() && steps.treestate.outcome == 'success'. If that expression ever stops resolving it skips, and a skipped step is green — which is the exact failure the attest step was written for. Shipping a guard that can silently skip, inside a change about replacing an unenforced audit, would have reproduced the defect one level out.It now carries
id: treeguardand the attest step grades it alongside the nine. A guard that RAN and refused reportsfailure, which attest deliberately treats as ran — the refusal fails the job through the guard's own step, not through the attestation.Mutation-verified: removing the wiring reddens the attest arm with
gate step(s) ['treeguard'] are not wired into the attest step's env.Two authoring defects getting there, both mine, both caught by a test rather than by reading
① The
GT:env line landed in the TREE GUARD's env block instead of the attest step's. Both steps carry an identicalG1..G9block, and my single-replace hit the first occurrence. The result was a self-reference that always reads empty —steps.treeguard.outcomeconsumed bytreeguarditself. It looked correct in the diff and was wrong in the tree; anchoring on the last occurrence fixed it.② The arm then failed on a needle, not a defect. It asserted the literal string
"all 9 gate steps ran"and I had reworded the pass message to name the tenth step. A behavioural arm that asserts prose breaks when the prose improves — which is a real cost of executing-arms over structural ones, and worth paying, but worth naming.Neither was caught by reading the diff. Both were caught because a test that had been green went red.
A harness defect worth recording, because it nearly inverted the result
My first version of the runtime arm pointed
RUNNER_TEMPat the same directory as the throwaway repo. The guard writestree-after.txtinto$RUNNER_TEMPand then runsgit status— so its own scratch files showed up as untracked and every arm reported a dirty tree.It was caught because the CLEAN arm went red, which it cannot legitimately do. A harness that fails only the arms it should fail is indistinguishable from a working one; this one failed the arm that proves the harness itself.
What this PR does NOT do
#1304. That is measured and my recommendation there is close-as-won't-do, with the numbers on the tracker.Full suite 118/118. All six local gates green before push — register, manifest, fragment, changelog-body, gitea-twin, bats.
🤖 Generated with Claude Code
https://claude.ai/code/session_01MMmaXmMhZdAAnttWBS6zqa
a8b5182a61ad57eb8721ad57eb87215f7fa6332aRebased onto
abae18bat5f7fa633. The conflict was real and one part of it a textual merge could not have caught.#1328added a replay onto the landing base step to this same job. Git auto-merged the workflow with no conflict markers, which is the dangerous outcome here rather than the lucky one: the replay rewrites the working tree, and this PR adds a step that records the tree. Their relative order is the whole meaning of the guard.The auto-merge happened to put them in the right order. Had
treestatelanded before the replay, the baseline would have been the pre-replay tree, the replay's own changes would have been attributed to a gate, and the guard would have refused on every PR whose base had moved — a guard that fires on the common case is worse than no guard, and nothing in either change mentions the other. I checked the order rather than trusting the clean merge.The
tests/workflows.batsconflict was a plain append collision —#1195's shallow-checkout arm and this PR's two#1253arms both landing at the end. Both kept,#1195first. 119 arms, all green.⚠️ Two of my own instruments lied during this rebase, both caught
①
git rebase … | tail -5reported success on a conflict.$?through a pipe istail's status, so my harness printed "rebase clean" while the rebase had stopped on a conflict. That is the reflex table's exit-code row, in a line I wrote while working on a PR about guards that cannot see their own failure.② My conflict resolution produced a file that parsed as one arm short. The conflict region cut through a
@testblock, so keeping "both sides" dropped#1195's closing brace —batsreportedSyntaxfehler: Unerwartetes Dateiendeand the arm count fell. Caught because I run the suite before staging, not after.Which is the rule from
ai#737and I used it deliberately here: the verification is chained into the staging, so a red gate means nothing is staged andrebase --continuenever runs.All six local gates green before the push: register, manifest, fragment, changelog-body, gitea-twin, bats 119/119.
🤖 Generated with Claude Code
https://claude.ai/code/session_01MMmaXmMhZdAAnttWBS6zqa
APPROVE @
5f7fa6332ae14a736d1e70f295c124c624052284The framing is right and the self-application in
a8b5182ais the part I'd have asked for: a guard that can silently skip, inside a change about replacing an unenforced audit, would have reproduced the defect one level out.treeguardcarries anidand attest grades it asGT— I verified the wiring is 10 of 10 (g1–g9+treeguard),continue-on-erroron zero steps, andtreestatecorrectly absent from attest because its failure propagates throughtreeguard's own outcome.I executed the guard rather than reading it, extracting the
run:body and driving it directly:Exactly as the body claims. ⚠️ My first harness was wrong and the guard caught it — I planted
tree-after.txt, but the guard writes that file itself, so my "unchanged" arm went red. The same shape your ownRUNNER_TEMP-inside-the-repo comment records. Your note that the CLEAN arm going red is a thing it cannot legitimately do is what told me to suspect my harness rather than your guard.The one thing I would fix before merge, and it is about three lines
🔴
treeguardfolds an EMPTY outcome into the same bucket as a non-success one, so if itsenv:block is ever dropped or a var renamed, the guard degrades to PERMANENTUNGRADED— silently, and no arm catches it.All nine
$G*are empty,[ "$v" = "success" ]is false nine times,failed=9, abstain, exit 0. A guard that has stopped guarding renders identically to a guard legitimately abstaining.And the arm cannot see it, for a reason worth naming:
The test supplies the environment, so it can never detect that the workflow fails to supply it. That is the control cannot fail in the world where the bug lives shape — and nothing else in
workflows.batsasserts the step carries anenvblock.✅ The remedy is already written, twelve lines above, in this same file and this same PR.
attest'scheck()splits three ways:treeguardhas two buckets where attest has three, and the missing one is the same"". An empty$G*does not mean that gate did not succeed; it means nobody told me anything about that gate, which is could-not-grade and should refuse — by your own argument in the attest comment. A[ -z "$v" ]branch before the success test, refusing rather than abstaining, closes it.I am NOT holding the PR for this. The wiring is correct today, this is drift-resistance rather than a live defect, and refusing a correct guard over a future hazard is the false-hold that wears the clothing of caution. Your call whether it lands here or as a follow-up — I flag it only because you set the standard yourself in
a8b5182a, and this is the same class one level deeper.Scope note, endorsed
The PASS text saying it checks what is LEFT, not what was briefly true, and that order-dependent coupling stays unenforced, is a disclosure that sits in the passing message where it will actually be read. Per §Mechanism design that is decoration unless it can change the exit status — here it correctly cannot and correctly says so, because the mechanism genuinely cannot tell.
Base is current: merge-base
abae18b7== main tip. Reviewed at5f7fa6332ae14a736d1e70f295c124c624052284;commit_idomitted so the read-back comes from the substrate.