feat(workflows): one reusable-pr-gates.yml for every PR gate, combined or split #1547
No reviewers
Labels
No labels
bump
major
bump
minor
bump
patch
kind
bug
kind
chore
kind
docs
kind
epic
kind
feature
loop/paused
mirror-drift
priority
critical
priority
high
priority
low
priority
medium
size
L
size
M
size
S
size
XL
status
blocked
No milestone
No project
No assignees
3 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
frankenbit/release-toolkit!1547
Loading…
Reference in a new issue
No description provided.
Delete branch "claude/1541-reusable-pr-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?
Summary
What an adopter writes. One
pr.ymlreplaces up to four gate wrappers:The repository then requires one status on
main:pr / gates (pull_request).What toolkit-self now runs. A new local
.forgejo/workflows/pr.ymlcalls the reusable with all six gatesonandlayout: combined, so it is one extra job per PR. The six old gate wrappers keep running beside it until #1542 removes them and swaps the required checks.Context for #1542's swap:
pr / gates (pull_request). It postedsuccesson every head of this PR (evidence below).A finding on the way. Inside a called reusable, this forge reports
github.event_nameasworkflow_call. So the six old reusables never ran their #1195 landing-tree replay, and reusable-register-check never ran its commit-subject scan.Refs #1541
Refs #1530
Refs #1548
What changed
.forgejo/workflows/reusable-pr-gates.yml, new. It holds the six gates, eachon/off, andlayout: combined | split. Its Gitea twin is generated.scripts/pr-gates.sh, new. This is the one per-gate step both layouts call (run <gate>), plusprepare,tree-guardandattest..forgejo/workflows/pr.yml, new: toolkit-self's caller.canonicalFilesandbakeRefFilesgain the reusable;DefaultComposeScriptsgains the reusable andscripts/pr-gates.sh;NeedsSecrets["pr-gates"] = false;tests / checksgainsc13: the parse guard's mutation control, moved in fromworkflow-parse-check.yml'sselfjob. It stays duplicated there until #1542 deletes that wrapper.workflow-api.md) documents the reusable's inputs, the one status to require, its contract behaviour, and the three lists.tests/pr-gates.bats, new. Hand-kept lists intests/workflows.batswere derived where possible (see "Hand-kept lists").changelog.d/1541-reusable-pr-gates.added.md..forgejo/workflows/pr-split.ymlwas temporary. It ranlayout: splitwith the real gates on the first two heads, and the third commit removed it. The final head has one caller,pr.yml.Decision tree (choices ADR-0013 left open)
split: static per-gate jobs, not afromJSONmatrix.fromJSONdoes work here, from an input and from a job output (measured, item 3 below).needs.<job>.resultper on gate, and a matrix gives one result for all legs.if: inputs.layout == 'split' && inputs.<gate> == 'on'.offrefuses, in both layouts. This is the operator's decision of 2026-10-04: a workflow that grades nothing would post a green required status. It is mutation-verified in the validate step and inpr-gates.sh expected.scripts/pr-gates.sh.uses: ./.release-toolkit/...is main's checkout, so a PR that changes a gate would be graded by main's copy (#728).rtverb is right if the gate glue should be Go. It would add the section 13 artifacts and port a lot of shell, for no adopter gain now.DefaultComposeScripts. A remote pin runs it from.release-toolkitat the resolved ref; only a local-path call (toolkit-self) runs the workspace copy.gatesjob does validation and the D6 resolve in both layouts. Incombinedit then runs every on gate. Insplitit is the plan job that the gate jobsneed. So each file has one resolve step and one bake marker.plan, and run every gate when combined, notgates. An inner job namedgateswould post under the caller's context,pr / gates (pull_request).manifest,fragments,changelog_body,register,ac_closureandworkflow_parse.fragmentsis one gate that runs both modes: coverage, then density. Both always run, and a red in either fails the gate. The old reusable posted them as two matrix contexts.GITHUB_ENV, written by the setup step. That keeps the six gate steps byte-identical across layouts, and a bats arm compares them.pr.ymlsets no subject scan.pre_push.gois not touched. ItslocallyUnreproducibletable must not pre-declare a context that is not required yet (#1333). #1542 addspr / gates (pull_request)when it swaps the required set.Carry-forward from the six single-gate reusables (AGENTS.md section 1)
permissions: contents: read(all)issues: read,pull-requests: read(ac-closure)gatesjob holds the union, becausecombinedruns ac-closure in it. Insplit, only theac_closurejob has them.secrets.GITHUB_TOKENto the bootstrap, and asFORGEJO_TOKENto ac-closurePR_GATES_TOKEN, and only ac-closure reads it. Every gate step carries it, because the steps must be identical.vars.RELEASE_TOOLKIT_MINISIGN_PUBLIC_KEYto the bootstrap (#513)fetch-depth: 0for manifest (tags), fragments and register; default depth for body, ac-closure and parseif: github.event_name == 'pull_request'github.event_namereadsworkflow_call. On this PR's first head the replay was skipped in every called reusable: manifest-check, fragment-check, workflow-parse-check, and mine. It ran only in the non-reusable jobs. The body is unchanged in all 7 jobs, but the step now keys ongithub.event.pull_request.base.ref, and reads its base from there. A bats arm forbidsgithub.event_namein the reusable.check_commit_subjects,rt register-check --git-log), guarded bygithub.event_name == 'pull_request'pr.ymlno longer opts in (commit 4). The old guard never fired anyway: my first head loggedevent is 'workflow_call'.*.ymland*.yaml.workflows.bats, which now include this file.main.GITHUB_REPOSITORY == frankenbit/release-toolkitcontext:context: pr-gates.env:, never interpolated (#654)$RUNNER_TEMP/pr-gates/instead of/tmp.timeout-minutes: 10editedonly on fragment-check and ac-closure-checkpr.ymltakes the union, includingedited, with nopaths:filter (the wiring rule).Hand-kept lists in
tests/workflows.bats(AC)canonicalFiles.release-toolkit-build-refmarker, each checked againstcanonicalFiles. Mutant: droppingreusable-pr-gates.ymlfromcanonicalFilesis refused (MUTANT killed: canonicalFiles without reusable-pr-gates.yml is refused).if len(scripts) != 18(twice), and "OK all 18 discovered scripts"2 * len(canonicalFiles).wrapper_namesmaps (#1222, #336 harnesses)reusable-pr-gates.ymlthe derived namepr-gates.ymlworks, because D6 scans every file. The dedicated D6 harness is intests/pr-gates.bats. #1542 retires the old entries.casespairs ("six adopter ref controls")gates=(...)(#861), the minisignreusableslist, the #654namessettests/pr-gates.batsgrades the same three properties for the new one.tests/bootstrap-rt.batsalso changed. Its resolve -> bootstrap chain test now follows a split job'sneeds.gates.outputs.refback to the one resolve step, and it recognises the D6 scan. It grades the new reusable with the same 6 pin shapes as the others.Mutation evidence
Every mutant below is a bats arm, so CI re-runs it. Each one changes the answer of the case it names.
D6 resolve (
tests/pr-gates.bats). All 20 cases pass on both.forgejoand the.giteatwin, and every mutant is killed:Every remote-pin case,
@mainand SHA pins included, assertsscript=.release-toolkit/scripts/pr-gates.sh. Only a local-path call gets the workspace script.A skipped, deleted or failing on gate turns the run red, in both layouts:
NOT RUN: steps.fragments.outcome=skipped(rc 1)GATE NOT GREEN: fragments is on(rc 1); live in probe c5COULD-NOT-GRADE: steps.manifest.outcome did not resolve(rc 1)COULD-NOT-GRADE: needs.manifest.result(rc 1)GATE NOT GREEN: manifest is on(rc 1); live in probe c4NO VERDICT(rc 1)OFF GATE RAN(rc 1)OFF GATE RAN(rc 1); a mutant that acceptsoff:*is caughtalways()Commit-subject scan retired (commit 4). A bats arm runs the register gate with
PR_GATES_CHECK_COMMIT_SUBJECTS=trueand both PR SHAs set, and requires exactly onertcall, the file scan. It also refuses--git-logorcheck_commit_subjectsanywhere in the script, the reusable, the Gitea twin andpr.yml. Three mutants are caught:--git-logrestored in the script, the input passed frompr.yml, and a--git-logstep added to the split register job.Other mutants:
pr-gates.sh expected;needs.Throwaway-repo measurements (#1541 items 1-3)
The repository is
frankenbit/pr-gates-probe-1541(private), created 2026-10-04. Alex deletes it.Before use, the API showed no
.forgejo/workflows,.gitea/workflowsor.github/workflows(404 each).After use,
mainstill has none of the three. Theprobebranch has.forgejo/workflowswith exactly the 10 files I pushed, and neither of the other two. There are no tags.The probe: PR #1 there, head
21feae0. Its synthetic workflows reproduce this reusable's job graph:gatesjob;aandbjobs thatneedit;result, withif: ${{ always() && inputs.layout == 'split' }}.Every step is
echoorexit.<wf> / gates)probe: failing the gates job on purpose); a, b and result skippedok a:on:success,ok b:off:skipped)REFUSED a:on:failure)REFUSED b:on:skipped)always()always()closesfromJSON(inputs.gates), and one fromfromJSON(needs.plan.outputs.gates)direct a,direct b,viaplan x (x)andviaplan y (y)all ran (the logs show each leg's echo)fromJSONmatrix works, from an input and from a job output. Static jobs were still chosen (decision 1).Self-bootstrap (AGENTS.md section 2)
No re-pin: toolkit-self floats @main (AGENTS.md section 2.1).
rt check-self-bootstrapon this branch:reusable-pr-gates.ymlandscripts/pr-gates.shjoinDefaultComposeScripts. Adopters get the reusable from the first cut tag that contains it.Local verification
go test ./...passes.golangci-lint: 0 issues.bats tests/: 245 ok, 0 failed (commit 1);tests/pr-gates.bats24 ok after commit 2. CItests / batsgreen on all three heads.shellcheck scripts/pr-gates.shis clean.rt gitea-twin --check: 12 twins match.forgejo-runner validate12.8.2 accepts the new workflows.CI on this PR (job logs read in the browser, not just statuses)
96650fd(commit 1)pr(combined, run 19757) andpr-split(split, run 19756)event is 'workflow_call', which commit 2 addresses.7fcd283(commit 2)pr(run 19771) andpr-split(run 19770)f26d6562(commit 3)pr(run 19784)26d9a32(commit 4, subject scan retired)prtests / bats(run 19804) failed in the new #1548 arm. The arm compared the stub's whole log line, and CI setsFORGEJO_TOKENin the environment. The gate itself behaved.855b34e7(commit 5, final)pr(run 19818),tests(run 19821)pr / gates(combined), run 19818 on the final head855b34e7, from the job log:local .forgejo/workflows/pr.yml, thenresolved toolkit ref: main (source toolkit-self local invocation (ADR-0013 D6)), with the per-gate scriptscripts/pr-gates.sh.landing-tree replay: PASS - this head already contains origin/main at 44bdba5...bootstrap rt: BUILD from source @ main[manifest-check] all checks passedcoverage PASSanddensity PASSPASS: check 1 (header) ...[register-check] OK: 0 register-drift hits across scanned paths., thenregister-check: file scan only; the commit-subject scan is not part of this gate (#1548).Nothing ran--git-log.no same-repo Closes-target found on either surfacePARSED=38 TOTAL=38pr-gates: tree unchanged across every gate step, andattest: every expected gate ran (manifest fragments changelog_body register ac_closure workflow_parse).pr-split(split), run 19770, from the job logs:landing-tree replay: PASSandattest: every expected gate ran (<its gate>).split resultloggedplan: success | manifest: on, success | fragments: on, success | changelog_body: on, success | register: on, success | ac_closure: on, success | workflow_parse: on, success | split result: every on gate's job succeeded and every off gate's job was skipped.tests / checks, run 19774:mutation.yml workflow schema validation failed,PARSED=39 TOTAL=40andnegative control OK: mutation.yml made the executed guard fail (rc=1).all 13 gate steps AND the shared-tree guard ran.Not verified
splitwith the real gates ran only on this PR's first two heads, through the temporarypr-split.yml. Since its removal, only the bats arms exercisesplit..release-toolkitat a tag) cannot run before a cut. Only the resolve and bootstrap harnesses cover it.Verify at source, please (AGENTS.md section 6)
@alex, please verify at source:
reusable-pr-gates.yml, against the table in ADR-0013's details;script=scripts/pr-gates.sh;pr / gatesandpr-split / gateson the first head;pr / gates (pull_request)is the context to require in #1542.🤖 Generated with Claude Code
HOLD: pushing a new commit. On the first head (
96650fd), the job logs show thatgithub.event_namereadsworkflow_callinside a called reusable. So the landing-tree replay was skipped, and so was the register commit-subject scan. The commit keys both on the pull_request payload instead. I will post the new head SHA once it is pushed.New head:
7fcd2837b3(payload-keyed replay and subject scan). The temporary pr-split.yml is still in place on this head, so both layouts run with the replay. It is removed in the next commit.HOLD: pushing the commit that removes the temporary pr-split.yml. The split layout's logs on
7fcd283are read and recorded in the PR body.New head:
f26d6562f8(temporary pr-split.yml removed).HOLD: pushing a new commit. Per the operator's decision, the register commit-subject scan is retired from reusable-pr-gates.yml and from pr.yml, pending #1548. The landing-tree replay stays as it is. I will post the new head SHA once it is pushed.
New head:
26d9a32a2c(commit-subject register scan retired, pending #1548; replay unchanged).HOLD: on
26d9a32, CI tests / bats (run 19804) failed in the new #1548 arm. Its expected rt log line included ambient env, and the CI runner sets FORGEJO_TOKEN. Pushing a fix, together with the reviewer's printf nit in select_gates.New head:
855b34e76b(the #1548 register arm is now hermetic; select_gates uses printf '%s\n').Verdict: APPROVE at
855b34e76bSummary:
reusable-pr-gates.ymlimplements ADR-0013 as decided, and its measurements hold on the probe repo. The commit-subject scan is gone in the "no step, no input" shape you asked for. I found nothing blocking.Head pinned with
rt review-submit-check(rc=0, READY) immediately before this review. It is a fast-forward onf26d656(26d9a32,855b34e); base44bdba5is live main.The throwaway-repo measurements, checked on the repo itself
frankenbit/pr-gates-probe-1541:maingives 404 for.forgejo/workflows,.gitea/workflowsand.github/workflows; the 10 files are onprobeonly; 0 tags. That satisfies AGENTS.md section 14.21feae0they match c1-c7 row for row:bmakes the result fail, so the caller fails;always()the result is skipped and the caller stays success;fromJSONlegs ran.reusable-probe.ymlandreusable-probe-noalways.ymldiffer only in the result job'sif:. So c6 is a clean control for the hole, and c5'swrongskipinput really skipsb.Verified at source
workflow_callfinding. #1544's run (19704 job 1, 19706 job 1, head5cc3b8c) showsreplay onto the landing baseskipped in the old manifest and register inner jobs. It also shows register'scommit-subject scanskipped, withbe triggered by event: workflow_call. The new reusable keys the replay ongithub.event.pull_request.base.ref, and a bats arm forbidsgithub.event_name.*.ymland*.yaml. It uses the #1222 parsing: optional host, lowercased prefix, quote stripping, the name must bereusable-pr-gates.yml. It refuses a local call mixed with a remote pin, two remote refs, and no pin with bakedmainor empty. The #456, #336 and #1222 arms are kept. The mixed refusal also stops an adopter PR from switching to the workspace script by adding a localuses:line. The Gitea twin differs only in its header and the.giteapaths.SCRIPTisscripts/pr-gates.shonly whenSELF=true, which only the local arm sets. Every other path gets.release-toolkit/scripts/pr-gates.shat the resolved ref.split's result job:if: ${{ always() && inputs.layout == 'split' }}, andneeds:all six gates plusgates. It accepts onlyon:successandoff:skipped, treats empty as could-not-grade, and refuses everything else.always()and attests the tree guard. Every gate off refuses in both the validate step andpr-gates.sh expected.||context, but every branch of the subshell ends inrt fragment-checkor an explicitexit 2. The coverage and density env matches the old matrix legs.pr / gates (pull_request).26d9a32).check_commit_subjectsand--git-logappear nowhere in either reusable.pr.ymlandpr-gates.shmention them only in comments.gate_registeris now onert register-checkplus the #1548 line. All sevenPR_GATES_CHECK_COMMIT_SUBJECTSenv lines are removed. C3 and the fragment say nothing contrary.CI, read in job logs (Axis C)
32 statuses, all success.
pr / plan, and run every gate when combined(run 19826, job 1, commit855b34e76b): it resolved the toolkit ref tomain(source=toolkit-self local invocation, ADR-0013 D6), and the replay passed. The gates logged:all checks passed;0 register-drift hits across scanned pathsandfile scan only; the commit-subject scan is not part of this gate (#1548);PARSED=38 TOTAL=38.Then the tree was unchanged, and attest says every expected gate ran.
tests / bats(run 19821, job 0):1..246, nonot ok, includingok 74 ... never runs --git-log (#1548).toolkit-self-gates(run 19827): attest15 of 15.splitwith the real gates (run 19770, head7fcd283, beforepr-split.ymlwas removed): the result job logs every gateon, success.Mutants (Axis B), in an LF clone,
tests/pr-gates.batsinrt-bats:localThe baseline is 24 of 24.
f26d656): the result job acceptson:skipped. Arm 23 goes red.f26d656): a remote pin getsscripts/pr-gates.sh. The D6 arm goes red.f26d656): the scan's quote stripping removed. The D6 arm goes red ("self local call, quoted").855b34e):rt register-check --git-log base..headput back ingate_register. The #1548 arm goes red.855b34e): the file scan replaced bytrue, so the gate passes without calling rt. The #1548 arm goes red.The delta f26d656..855b34e touches none of the lines Ma-Mc mutate.
Not verified here (as the body says)
splitwith the real gates ran only on the first two heads.split's time saving is unmeasured on a one-slot runner.For #1542:
pr / gates (pull_request)posts on this head, and it is the context to require.Landing identity record
855b34e76b386d6aeb791ebdd1e9176ede36307a855b34e76b386d6aeb791ebdd1e9176ede36307aThis is a post-merge identity record. It does not retroactively review the landed object; it records whether the server landed the object that an official approval named.