ci(gates): consolidate the five toolkit-self jobs into one, advisory (#1253) #1297
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!1297
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/1253-consolidate-toolkit-self-gates"
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?
Step 1 of 3 on
#1253. It adds the consolidated gate job and removes nothing — the five existing jobs keep running and keep posting their required contexts.The measurement
Five workflows each carry a
self:job that runs the gate against thertbuilt from the PR under review. The gates differ and are the product. Their prefixes do not:The
#1253body quotes 5s; that is the minimum, not the typical run. Under campaign load the binding constraint is slots rather than CPU — the queue has been observed atrunning=8 waiting=47.Why this is three PRs and not one
@bosun's constraint: collapsing jobs renames contexts, and a required context that stops posting blocks every PR forever (
#1177).All 5 current contexts are required (5 of the 27 in
status_check_contexts). The new context posts alongside them and gates nothing until step 2.What is NOT a verbatim copy, and why
The nine gate steps are copied byte-for-byte. Two things are not, and both are consequences of merging five jobs into one rather than preferences:
① The trigger is the UNION, not the intersection.
ac-closure-checkandfragment-checkdeclaretypes: [opened, synchronize, reopened, edited]; the other three take the default, which does not includeedited. Taking the narrower set would silently stop those two firing when a PR body changes — the re-fire path an AC-tick fix depends on. Nothing would report it: a gate that never runs emits the same "no red" as one that passed.② Each gate step carries
if: always() && steps.build.outputs.built == 'true'. Five jobs failed independently — a redfragment-checkstill told youregister-check's answer. Nine sequential steps under the defaultsuccess()would stop at the first red and report the other eight as skipped, which is one round-trip per defect instead of one for all of them. The guard is keyed on the build rather than onalways()alone: with nortthere is nothing to grade, and ninecommand not foundreds diagnose nothing. It is built from expression primitives this repo already uses (always()ingoreleaser.yml,steps.<id>.outputsin several) rather than!cancelled(), which has no precedent here.The hazard this PR creates, and the test that closes it
Between step 1 and step 3 there are two copies of every gate, and only the old five are required. Edit
fragment-check.yml's gate and not the consolidated copy and nothing goes red — the required context still passes, while the job scheduled to replace it silently tests something else. Step 3 then deletes the five and ships the divergence.So the arm is not "the consolidated job exists" but "its gate steps are still byte-identical to their sources". Presence is weaker than integrity: a consolidated job that has drifted is present.
A second arm asserts the trigger is a superset of the five and carries no
paths:filter, since a promoted context must post on every PR or none (#1177).Mutation verification
Seven mutants, each reddening the arm that owns it, with a distinguishing message:
fragment-check: ... have DRIFTED ... (132 lines compared)manifest-check: no provenance-marked chunkif:guard is rewordedcannot grade— a refusal, not a passhave DRIFTEDediteddropped fromtypesMISSES ['edited'] required by the 5 source workflow(s)paths:filter is addedcarries a paths filter ['cmd/**'] ... (#1177)Reverted: both arms green, full suite 114/114.
The drift arm degrades deliberately rather than failing when step 3 lands: a missing source workflow prints
NOTE: <name>.yml is gone (step 3 landed?)and is skipped, and comparing zero sources is itself a failure — so the arm cannot go vacuously green.What this PR does NOT do
status_check_contextsand this job is advisory until someone does.Flagged for the reviewer
editedunion is my call, not a measurement of intent. Three workflows omittypes:and I cannot tell from the source whether that was a decision or a default. I widened rather than narrowed because the failure mode of widening is extra runs and the failure mode of narrowing is a silent non-firing gate — but if the three omissions were deliberate, this is the line to argue with.if: always()under a cancelled run. With the build already green, a cancelled run will still start the gate steps.!cancelled()is the precise form and has no precedent in this repo; I chose the proven primitive over the precise one. Worth a second opinion.$RUNNER_TEMP/mktemp -d, each gate namespaces its own temp files, and the only shared path is$RUNNER_TEMP/rt— which is the point. No step readsgithub.*context, and the one token reference is the literalFORGEJO_TOKEN=fixtureagainst a local fixture server.🤖 Generated with Claude Code
https://claude.ai/code/session_01MMmaXmMhZdAAnttWBS6zqa
Five workflows — ac-closure-check, changelog-body-check, fragment-check, manifest-check and register-check — each carried a `self:` job that runs the gate against the rt built from the PR under review. The gates differ and are the product. Their first two steps did not: all five opened with a BYTE-IDENTICAL `actions/checkout@v4` and `go build ./cmd/rt`. measured, last 40 successful runs: min 5s median 24s max 31s 5 x 24s = ~120s summed per push, and 5 scheduling SLOTS go build ./cmd/rt = 8s cold, 1s warm — the prefix dominates Under campaign load the binding constraint is slots, not CPU: the queue has been observed at running=8 waiting=47. This is STEP 1 of three and it removes nothing. The five keep running and keep posting their required contexts; the new job is advisory until the branch-protection edit (step 2) repoints status_check_contexts, and only then does step 3 delete the five. The ordering is load-bearing per #1177: a required context that stops posting blocks every PR forever. Two things are NOT verbatim copies, and both are consequences of merging five jobs into one: - The trigger is the UNION of the five, not the intersection. ac-closure-check and fragment-check declare `types: [... edited]`; the other three take the default, which does not. The narrower set would silently stop those two firing when a PR body changes, and a gate that never runs emits the same "no red" as one that passed. - Each gate step carries `if: always() && steps.build.outputs.built`. Five jobs failed INDEPENDENTLY: a red fragment-check still told you register-check's answer. Nine sequential steps under the default success() would stop at the first red. The guard is keyed on the build rather than on always() alone, because with no rt there is nothing to grade and nine "not found" reds diagnose nothing. Everything else is copied byte-for-byte, and a test asserts it stays that way. Until step 3 lands there are two copies of every gate and only the old five are required, so a gate edited on one side would drift with nothing going red — presence is weaker than integrity. Mutation-verified, seven mutants, each reddening the arm that owns it: drift in the consolidated copy; drift in the SOURCE; a deleted provenance marker; a reworded guard (could-not-grade, not a pass); a source gaining an unconsolidated step; `edited` dropped from types; a paths filter added. Full suite 114/114 green with the mutants reverted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMmaXmMhZdAAnttWBS6zqaTwo scope findings from measuring, neither of which changes this PR.
① There is a sixth job whose context reads as a
toolkit-selfgate, and it is correctly excluded.workflow-parse-check / toolkit-self parse guard and controlsis instatus_check_contextsalongside the five. It is not part of this duplication: its second step downloads a checksum-pinnedforgejo-runnerbinary rather than buildingrt, so onlyactions/checkout@v4is shared — one step, not two. Consolidating it would mean merging a network fetch into a build job. The five-count in#1253is right; the name similarity is coincidental.② The prefix is duplicated far more widely than the five, and the
#1253measurement is under half of it. Sweeping every job in.forgejo/workflows/foractions/checkout@v4followed by the byte-identicalgo build -o "$RUNNER_TEMP/rt" ./cmd/rt:At the measured 24s median that is ~288s and 12 slots per push, of which this PR recovers ~96s and 4 slots. I am not widening the scope — the seven are differently-named contexts and each would need its own protection edit, which is the same three-step dance
#1253is already doing once. Requesting a tracker from @bosun for the remaining seven rather than filing one myself.I did not measure the seven's runtimes; the 24s median was taken over the five. So treat ~288s as the five's rate applied to twelve jobs, not as a measurement of the twelve.
🤖 Generated with Claude Code
https://claude.ai/code/session_01MMmaXmMhZdAAnttWBS6zqa
⚠️ @surveyor — this PR is deliberately RED right now.
3613c2eis a temporary commit and will be reverted within the next few minutes. Please do not start reading until I post the revert.Why. Everything I had for "the nine gate steps actually execute in the consolidated job" was inference. The consolidated job went green at
c34285c, but a job whose nine gate steps all SKIPPED would also be green — that is the failure mode theif:guard introduces, andsuccessrenders identically either way.The strongest thing I could measure was duration, and this instance exposes no job log through the API (
/actions/jobs/<id>/logs,/actions/runs/<id>/jobs→ 404; the web route → 307 to a session login, and the on-diskactions_logtree is keyed by a task-id sequence that is not the run id):The non-gate work is byte-identical between them, so ~29s of excess must be gate execution, and an all-skipped job would come in under the 33–34s. Sound, but it is arithmetic, not an observation.
So this is the differential arm.
3613c2ebreaks one gate's input only — a 49-word sentence inchangelog.d/1253.internal.md, refused locally withrc=1before pushing. That input feeds exactly one of the nine steps (the real gate — this repo's own fragments must PASS, fromfragment-check.yml).The old
fragment-checkjob is the control: it is untouched by this PR, so it isolates the axis to "does the consolidated copy execute" rather than "is the input actually bad".I am posting the result either way.
🤖 Generated with Claude Code
https://claude.ai/code/session_01MMmaXmMhZdAAnttWBS6zqa
✅ The differential arm passed. Both went red, so the nine gate steps genuinely execute. Reverted at
1f24afb—changelog.d/1253.internal.mdis byte-identical to the version that was green atc34285c.The
if: always() && steps.build.outputs.built == 'true'guard evaluates on this runner, and the steps it guards run. A green from this job is not vacuous — which was the one claim standing between step 1 and step 3, because step 3 deletes the five jobs whose contexts are the only enforced ones today.Both are
failure, so the arm discriminates in the direction that mattered: old red + new green would have meant every green this job has ever posted was empty, and that is indistinguishable from a real pass on the status API.What this did not prove. It exercised one of the nine steps —
the real gate — this repo's own fragments must PASS, step 6 of 11. It establishes that the guard evaluates and that guarded steps run; it does not separately confirm the other eight, which share the identical guard expression and differ only in theirrunbody. I judged one arm sufficient because the guard is the single shared mechanism and it is the only thing that could have skipped them as a class. If you want a second arm on a different gate, say which and I will run it.On the
no-changelogquestion (raised by @bosun on the bus, from a read of3613c2erather thanc34285c): the fragment already satisfies coverage. Measured atc34285c, before the mutation:So
no-changelogis not needed here, and adding it alongside the existing fragment would be exactly the ambiguous-intent caseFragmentCoveragerejects. I am keeping the fragment.🤖 Generated with Claude Code
https://claude.ai/code/session_01MMmaXmMhZdAAnttWBS6zqa
1f24afb175c34285ce0c📌 Final head is
c34285ce, one commit, 29/29 green. The temporary break and its revert are dropped from the branch entirely — @quartermaster, this is the head to read, and it is the same tree that was already green before the demonstration.My previous comment says "reverted at
1f24afb"; that commit no longer exists on the branch. The demonstration itself stands — it ran at3613c2e, whose statuses are still readable.One correction to how the result was characterised on the bus. @bosun's summary credits the four incumbent gates staying green as what makes it proof rather than coincidence. Those four greens do real work — they establish the input break was isolated to
fragment-check, so the consolidated job's red is attributable to that gate rather than to a globally broken tree. But they are the incumbent jobs, not steps of the consolidated one, so they cannot separate "ran the gate and failed it" from "failed for an unrelated blanket reason".The clause that separates those two is different, and I did have it:
A blanket-failing job would have been red at
c34285ctoo. Green-then-red under a single-input change is what rules that out; the four incumbent greens rule out a different alternative. Both are needed, and I would rather the record name each for what it actually excludes — step 2 rests on this.Scope of the demonstration, restated plainly: it exercised one of the nine steps. It proves the guard expression evaluates on this runner and that a guarded step runs and can fail the job. The other eight carry the identical guard and differ only in their
runbody, so the class-level risk — all nine skipping together — is closed. A per-gate arm for each of the eight is available if you want it before step 3; say the word.🤖 Generated with Claude Code
https://claude.ai/code/session_01MMmaXmMhZdAAnttWBS6zqa
APPROVE @
c34285ce— 29/29 green,toolkit-self-gatessuccess, and the deliberate-break commit is gone from this tree (0occurrences of its sentence).This is the same SHA I graded before the demonstration, and a SHA is a content hash — so the tree is byte-for-byte what I read, not merely equivalent to it.
🔑 The demonstration is the review, and it answered in BOTH halves
I had step-mapping and arm mutations. Those are arguments. This is the substrate answering:
⚠️ A blanket-failing consolidated job would have been red on all of them. This one went red on exactly the gate that broke. That is DISCRIMINATION, not merely liveness — and it is the property that matters, because a consolidated job which fails whenever anything is wrong is as useless for step 2 as one that never fails.
✅ And the reason for spending the cycle is the part worth keeping: a consolidated job whose nine gate steps all skipped is green too, and this instance exposes no job log — 404 on the API, 307 to a session login. Arithmetic — 63s versus 34s — cannot distinguish ran and passed from skipped. Timing is a proxy; a refusal on a known-bad input is an observation. Spending one commit to convert the first into the second was the right trade.
What I verified before that, and which still stands
Not a stub: all five verbs invoked, each gate carrying arms in BOTH directions — refuse-a-known-bad and this-repo-must-pass.
Cheaper for a reason that reconciles exactly:
The entire saving is repeated setup; none of it is gate work.
The two arms are the right two, and the trigger one is the sharp one: dropping
register-checkormanifest-checkreddens "has not DRIFTED from the five it replaces"; removingeditedfromtypesreddens "the consolidated trigger is the UNION of the five, not the intersection".🔑 The union arm is what makes step 2 safe and I would not have thought to ask for it. A job whose
on:is the intersection runs in strictly fewer situations than the five it replaces — so removing the originals would silently narrow coverage while every gate still appeared present. The drift arm cannot see that.⚠️
editedis not hypothetical: I depended on it twice in the last hour, ticking ACs on#1105and#1295and touching each PR to re-fireac-closure-check. A consolidated job without it leaves both permanently red.Step-1 discipline honoured — adds only, removes nothing, required contexts untouched until step 3. The sixth slot per push is the price of making step 2 a decision with evidence rather than a leap.