feat(workflows): one reusable-pr-gates.yml for every PR gate, combined or split #1547

Merged
alex merged 5 commits from claude/1541-reusable-pr-gates into main 2026-10-05 19:58:25 +02:00
Member

Summary

What an adopter writes. One pr.yml replaces up to four gate wrappers:

name: pr
on:
  pull_request:
    branches: [main]
    types: [opened, synchronize, reopened, edited]
jobs:
  gates:
    uses: frankenbit/release-toolkit/.forgejo/workflows/reusable-pr-gates.yml@vX.Y.Z
    with:
      runs_on: go
      layout: combined
      manifest: 'on'
      fragments: 'on'

The repository then requires one status on main: pr / gates (pull_request).

What toolkit-self now runs. A new local .forgejo/workflows/pr.yml calls the reusable with all six gates on and layout: 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 posted success on every head of this PR (evidence below).

A finding on the way. Inside a called reusable, this forge reports github.event_name as workflow_call. So the six old reusables never ran their #1195 landing-tree replay, and reusable-register-check never ran its commit-subject scan.

  • The new reusable keys its replay on the pull_request payload instead (commit 2).
  • The operator decided to leave the commit-subject scan off: commit 4 retires it, pending the policy question in #1548.

Refs #1541
Refs #1530
Refs #1548

What changed

  • .forgejo/workflows/reusable-pr-gates.yml, new. It holds the six gates, each on/off, and layout: combined | split. Its Gitea twin is generated.
  • scripts/pr-gates.sh, new. This is the one per-gate step both layouts call (run <gate>), plus prepare, tree-guard and attest.
  • .forgejo/workflows/pr.yml, new: toolkit-self's caller.
  • Bake and compose lists:
    • canonicalFiles and bakeRefFiles gain the reusable;
    • DefaultComposeScripts gains the reusable and scripts/pr-gates.sh;
    • NeedsSecrets["pr-gates"] = false;
    • the Go test copies of those lists are updated.
  • tests / checks gains c13: the parse guard's mutation control, moved in from workflow-parse-check.yml's self job. It stays duplicated there until #1542 deletes that wrapper.
  • C3 (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 in tests/workflows.bats were derived where possible (see "Hand-kept lists").
  • Fragment changelog.d/1541-reusable-pr-gates.added.md.
  • .forgejo/workflows/pr-split.yml was temporary. It ran layout: split with 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)

  1. split: static per-gate jobs, not a fromJSON matrix.
    • A matrix is right when one aggregated result is enough. fromJSON does work here, from an input and from a job output (measured, item 3 below).
    • Static jobs are right when the result job must name each gate. The AC asks for needs.<job>.result per on gate, and a matrix gives one result for all legs.
    • Chosen: static jobs, each if: inputs.layout == 'split' && inputs.<gate> == 'on'.
  2. Every gate off refuses, 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 in pr-gates.sh expected.
  3. One shared per-gate step: scripts/pr-gates.sh.
    • A local composite action is right only if toolkit-self can load it from the PR. It cannot: uses: ./.release-toolkit/... is main's checkout, so a PR that changes a gate would be graded by main's copy (#728).
    • An rt verb 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.
    • Chosen: the script. It joins DefaultComposeScripts. A remote pin runs it from .release-toolkit at the resolved ref; only a local-path call (toolkit-self) runs the workspace copy.
  4. One gates job does validation and the D6 resolve in both layouts. In combined it then runs every on gate. In split it is the plan job that the gate jobs need. So each file has one resolve step and one bake marker.
  5. The resolve reads the HEAD's workflows, before the replay. The pin that loaded the reusable is the head's. The old reusables resolved after the replay.
  6. The inner job is named plan, and run every gate when combined, not gates. An inner job named gates would post under the caller's context, pr / gates (pull_request).
  7. Gate input names: manifest, fragments, changelog_body, register, ac_closure and workflow_parse.
    • The per-gate options keep their old names.
    • The five density thresholds are now one input each, shared by fragments and changelog body. Before, each reusable took its own copy, and the two "must match".
  8. fragments is 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.
  9. The script path reaches the gate steps through GITHUB_ENV, written by the setup step. That keeps the six gate steps byte-identical across layouts, and a bats arm compares them.
  10. The commit-subject register scan is not carried (operator decision, 2026-10-05; the policy is in #1548). A hit in a commit subject can only be cleared by rewriting history, which AGENTS.md section 15 forbids.
    • Not carrying the step or its input is right while the policy is open. A consumer cannot switch on something the reusable does not have, and #1548 can add it back as a new optional input.
    • A kept input that is refused when set would also be safe. But it would put an input that does nothing into the interface v1 freezes.
    • Chosen: no step and no input. The register gate is the file scan only, and toolkit-self's pr.yml sets no subject scan.
  11. pre_push.go is not touched. Its locallyUnreproducible table must not pre-declare a context that is not required yet (#1333). #1542 adds pr / gates (pull_request) when it swaps the required set.

Carry-forward from the six single-gate reusables (AGENTS.md section 1)

Precondition in the old reusables Disposition here
permissions: contents: read (all) Preserved on every job.
issues: read, pull-requests: read (ac-closure) Reimplemented. The gates job holds the union, because combined runs ac-closure in it. In split, only the ac_closure job has them.
secrets.GITHUB_TOKEN to the bootstrap, and as FORGEJO_TOKEN to ac-closure Preserved. The token reaches each gate step as PR_GATES_TOKEN, and only ac-closure reads it. Every gate step carries it, because the steps must be identical.
vars.RELEASE_TOOLKIT_MINISIGN_PUBLIC_KEY to the bootstrap (#513) Preserved on all 7 bootstrap calls. A bats arm checks every call.
fetch-depth: 0 for manifest (tags), fragments and register; default depth for body, ac-closure and parse Reimplemented: depth 0 everywhere. Combined shares one checkout, and the replay unshallowed every PR checkout anyway.
Landing-tree replay (#1195), if: github.event_name == 'pull_request' Reimplemented, because the old guard never fired. Inside a called reusable on this forge, github.event_name reads workflow_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 on github.event.pull_request.base.ref, and reads its base from there. A bats arm forbids github.event_name in the reusable.
Register commit-subject scan (check_commit_subjects, rt register-check --git-log), guarded by github.event_name == 'pull_request' Retired pending #1548. There is no input and no step, and toolkit-self's pr.yml no longer opts in (commit 4). The old guard never fired anyway: my first head logged event is 'workflow_call'.
Resolve: a fixed wrapper file name per reusable Retired by D6: a scan of every top-level *.yml and *.yaml.
Resolve arms #456, #336 and #1222, with their SOURCE strings Preserved. They are tested by the D6 harness and by the existing #1222 and #336 harnesses in workflows.bats, which now include this file.
Toolkit-self arm: parse and ac-closure grepped their fixed wrapper for a local call Reimplemented as D6's local-path arm. It still resolves main.
The parse check chose the PR's own script by GITHUB_REPOSITORY == frankenbit/release-toolkit Reimplemented on the D6 local-path identity, which also holds in the Gitea mirror and in forks.
Shared bootstrap composite (#861), with its context: Preserved, as context: pr-gates.
Inputs reach shells through env:, never interpolated (#654) Preserved. Every input goes through job-level env, and a bats arm checks every run block and every input.
Fragments: base...head diff, deleted files, whether CHANGELOG.md changed Preserved verbatim in the script.
Fragments: coverage and density as a two-leg matrix Reimplemented as two modes in one gate step (decision 8).
Parse validator 12.8.2, pinned by sha256 Preserved. It downloads to $RUNNER_TEMP/pr-gates/ instead of /tmp.
timeout-minutes: 10 Preserved for the split jobs. Combined gets 20 and result gets 5.
Wrapper triggers: edited only on fragment-check and ac-closure-check Reimplemented: pr.yml takes the union, including edited, with no paths: filter (the wiring rule).

Hand-kept lists in tests/workflows.bats (AC)

List Disposition
"all expected reusables are present" (9 names) Derived: read from canonicalFiles.
"every reusable with a bake-marker env is registered" (9 names) Derived from the files that carry the release-toolkit-build-ref marker, each checked against canonicalFiles. Mutant: dropping reusable-pr-gates.yml from canonicalFiles is refused (MUTANT killed: canonicalFiles without reusable-pr-gates.yml is refused).
if len(scripts) != 18 (twice), and "OK all 18 discovered scripts" Derived: 2 * len(canonicalFiles).
"rt prep stages all 9" Unpinned. The registration arm owns the population; this arm owns only canonical == staged.
wrapper_names maps (#1222, #336 harnesses) Left fixed. They name fixed wrappers for the old reusables. For reusable-pr-gates.yml the derived name pr-gates.yml works, because D6 scans every file. The dedicated D6 harness is in tests/pr-gates.bats. #1542 retires the old entries.
cases pairs ("six adopter ref controls") Left fixed. They grade the fixed-wrapper probe, which the new reusable does not have by design.
gates=(...) (#861), the minisign reusables list, the #654 names set Left fixed for the old reusables, which #1542 removes. tests/pr-gates.bats grades the same three properties for the new one.

tests/bootstrap-rt.bats also changed. Its resolve -> bootstrap chain test now follows a split job's needs.gates.outputs.ref back 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 .forgejo and the .gitea twin, and every mutant is killed:

MUTANT killed: drop the local+remote refusal -> 'local and remote' changes (rc=0 ref=main ...)
MUTANT killed: drop the two-pins refusal -> 'two pins, one file' changes (rc=0 ref=v0.60.0 ...)
MUTANT killed: drop the no-pin refusal -> 'no pin, baked main' changes (rc=0 ref=main ...)
MUTANT killed: read only a fixed pr.yml -> 'pin in a .yaml of any name' changes (rc=1 ...)
MUTANT killed: drop the .yaml glob -> 'pin in a .yaml of any name' changes (rc=1 ...)
MUTANT killed: drop the reusable-name check -> 'another reusable pinned' changes (rc=0 ref=v0.60.0 ...)
MUTANT killed: toolkit-self arm reachable from a remote pin -> 'tag pin, baked tag' changes (rc=0 ref=main script=scripts/pr-gates.sh ...)
MUTANT killed: a pinned adopter runs its own workspace script -> 'tag pin, baked tag' changes (script=scripts/pr-gates.sh ...)
MUTANT killed: drop the #456 @main arm -> 'main pin, baked tag' changes (rc=0 ref=v0.60.0 ...)
MUTANT killed: drop the #336 SHA arm -> 'SHA pin, baked tag' changes (rc=0 ref=v0.60.0 ...)
MUTANT killed: drop the #1222 fallback -> 'tag pin, baked main' changes (rc=0 ref=main ...)
MUTANT killed: drop the repository casefold -> 'absolute URL, display case' changes (rc=1 ...)
OK every D6 mutant is killed

Every remote-pin case, @main and SHA pins included, asserts script=.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:

Mutant combined split
skipped attest: NOT RUN: steps.fragments.outcome=skipped (rc 1) result: GATE NOT GREEN: fragments is on (rc 1); live in probe c5
deleted attest: COULD-NOT-GRADE: steps.manifest.outcome did not resolve (rc 1) result: COULD-NOT-GRADE: needs.manifest.result (rc 1)
failing the gate's own step fails, and writes no verdict result: GATE NOT GREEN: manifest is on (rc 1); live in probe c4
green step with no verdict NO VERDICT (rc 1) the same attest, per split job
off gate that ran OFF GATE RAN (rc 1) OFF GATE RAN (rc 1); a mutant that accepts off:* is caught
result job without always() n/a the wiring arm refuses it. Live, the caller stays green (probe c6): that is the hole.

Commit-subject scan retired (commit 4). A bats arm runs the register gate with PR_GATES_CHECK_COMMIT_SUBJECTS=true and both PR SHAs set, and requires exactly one rt call, the file scan. It also refuses --git-log or check_commit_subjects anywhere in the script, the reusable, the Gitea twin and pr.yml. Three mutants are caught: --git-log restored in the script, the input passed from pr.yml, and a --git-log step added to the split register job.

Other mutants:

  • an unknown layout that is accepted;
  • an all-off set that is accepted, in the validate step and in pr-gates.sh expected;
  • an edited split copy of a gate step (caught by the byte-identity arm);
  • a result job missing one gate in its 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/workflows or .github/workflows (404 each).

  • After use, main still has none of the three. The probe branch has .forgejo/workflows with 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:

    • a gates job;
    • static a and b jobs that need it;
    • result, with if: ${{ always() && inputs.layout == 'split' }}.

    Every step is echo or exit.

Caller (<wf> / gates) Inner jobs (state from the statuses and the job logs) Caller
c1 combined, pass plan job success; a, b and result skipped ("Has been skipped") success
c2 combined, the one inner job fails plan job failure (log: probe: failing the gates job on purpose); a, b and result skipped failure (item 1)
c3 split, a on, b off a success; b skipped; result success (log: ok a:on:success, ok b:off:skipped) success
c4 split, a fails a failure; b success; result failure (log: REFUSED a:on:failure) failure
c5 split, b on but wrongly skipped b skipped; result failure (log: REFUSED b:on:skipped) failure
c6 the same, with the result job without always() b skipped; result skipped success: the hole always() closes
c7 a matrix from fromJSON(inputs.gates), and one from fromJSON(needs.plan.outputs.gates) direct a, direct b, viaplan x (x) and viaplan y (y) all ran (the logs show each leg's echo) success (item 3)
  • Item 1: a single non-matrix inner job that fails fails the caller (c2).
  • Item 2: skipped inner jobs leave the caller green (c1, c3). So both layouts can live in one file, with the other layout's jobs skipped.
  • Item 3: a fromJSON matrix works, from an input and from a job output. Static jobs were still chosen (decision 1).
  • Item 4, split's time saving: not measured, because the runner has one slot.

Self-bootstrap (AGENTS.md section 2)

No re-pin: toolkit-self floats @main (AGENTS.md section 2.1). rt check-self-bootstrap on this branch:

[check-self-bootstrap] pinned toolkit ref: main
[check-self-bootstrap] floating-pin (release-toolkit#456): main matches main by construction

reusable-pr-gates.yml and scripts/pr-gates.sh join DefaultComposeScripts. Adopters get the reusable from the first cut tag that contains it.

Local verification

  • go test ./... passes.
  • golangci-lint: 0 issues.
  • Full bats tests/: 245 ok, 0 failed (commit 1); tests/pr-gates.bats 24 ok after commit 2. CI tests / bats green on all three heads.
  • shellcheck scripts/pr-gates.sh is clean.
  • rt gitea-twin --check: 12 twins match.
  • forgejo-runner validate 12.8.2 accepts the new workflows.

CI on this PR (job logs read in the browser, not just statuses)

Head What ran Result
96650fd (commit 1) pr (combined, run 19757) and pr-split (split, run 19756) All 41 contexts success. The logs showed the replay skipped and event is 'workflow_call', which commit 2 addresses.
7fcd283 (commit 2) pr (run 19771) and pr-split (run 19770) All 41 contexts success. Replay ran in all 7 jobs.
f26d6562 (commit 3) pr (run 19784) All 32 contexts success.
26d9a32 (commit 4, subject scan retired) pr tests / bats (run 19804) failed in the new #1548 arm. The arm compared the stub's whole log line, and CI sets FORGEJO_TOKEN in the environment. The gate itself behaved.
855b34e7 (commit 5, final) pr (run 19818), tests (run 19821) All 32 contexts success.

pr / gates (combined), run 19818 on the final head 855b34e7, from the job log:

  • local .forgejo/workflows/pr.yml, then resolved toolkit ref: main (source toolkit-self local invocation (ADR-0013 D6)), with the per-gate script scripts/pr-gates.sh.
  • landing-tree replay: PASS - this head already contains origin/main at 44bdba5...
  • bootstrap rt: BUILD from source @ main
  • Each gate:
    • manifest: [manifest-check] all checks passed
    • fragments: coverage PASS and density PASS
    • changelog_body: PASS: check 1 (header) ...
    • register: [register-check] OK: 0 register-drift hits across scanned paths., then register-check: file scan only; the commit-subject scan is not part of this gate (#1548). Nothing ran --git-log.
    • ac_closure: no same-repo Closes-target found on either surface
    • workflow_parse: PARSED=38 TOTAL=38
  • Then pr-gates: tree unchanged across every gate step, and attest: every expected gate ran (manifest fragments changelog_body register ac_closure workflow_parse).

pr-split (split), run 19770, from the job logs:

  • Each of the six gate jobs logged landing-tree replay: PASS and attest: every expected gate ran (<its gate>).
  • split result logged plan: 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:

  • c13 logged mutation.yml workflow schema validation failed, PARSED=39 TOTAL=40 and negative control OK: mutation.yml made the executed guard fail (rc=1).
  • attest logged all 13 gate steps AND the shared-tree guard ran.

Not verified

  • split with the real gates ran only on this PR's first two heads, through the temporary pr-split.yml. Since its removal, only the bats arms exercise split.
  • The old reusables still skip their replay, and their register subject scan (finding above). This PR does not touch them, because #1542 deletes them. Adopters on old tags keep that behaviour.
  • An adopter's tag pin end to end (the FETCH arm, with .release-toolkit at a tag) cannot run before a cut. Only the resolve and bootstrap harnesses cover it.
  • Item 4 (split's wall-clock saving).

Verify at source, please (AGENTS.md section 6)

@alex, please verify at source:

  • the D6 resolve step in reusable-pr-gates.yml, against the table in ADR-0013's details;
  • that a remote pin can never reach script=scripts/pr-gates.sh;
  • the job logs of pr / gates and pr-split / gates on the first head;
  • that pr / gates (pull_request) is the context to require in #1542.

🤖 Generated with Claude Code

## Summary **What an adopter writes.** One `pr.yml` replaces up to four gate wrappers: ```yaml name: pr on: pull_request: branches: [main] types: [opened, synchronize, reopened, edited] jobs: gates: uses: frankenbit/release-toolkit/.forgejo/workflows/reusable-pr-gates.yml@vX.Y.Z with: runs_on: go layout: combined manifest: 'on' fragments: 'on' ``` The repository then requires one status on `main`: `pr / gates (pull_request)`. **What toolkit-self now runs.** A new local `.forgejo/workflows/pr.yml` calls the reusable with all six gates `on` and `layout: 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 posted `success` on every head of this PR (evidence below). **A finding on the way.** Inside a called reusable, this forge reports `github.event_name` as `workflow_call`. So the six old reusables never ran their #1195 landing-tree replay, and reusable-register-check never ran its commit-subject scan. - The new reusable keys its replay on the pull_request payload instead (commit 2). - The operator decided to leave the commit-subject scan **off**: commit 4 retires it, pending the policy question in #1548. Refs #1541 Refs #1530 Refs #1548 ## What changed - `.forgejo/workflows/reusable-pr-gates.yml`, new. It holds the six gates, each `on`/`off`, and `layout: combined | split`. Its Gitea twin is generated. - `scripts/pr-gates.sh`, new. This is the one per-gate step both layouts call (`run <gate>`), plus `prepare`, `tree-guard` and `attest`. - `.forgejo/workflows/pr.yml`, new: toolkit-self's caller. - Bake and compose lists: - `canonicalFiles` and `bakeRefFiles` gain the reusable; - `DefaultComposeScripts` gains the reusable and `scripts/pr-gates.sh`; - `NeedsSecrets["pr-gates"] = false`; - the Go test copies of those lists are updated. - `tests / checks` gains `c13`: the parse guard's mutation control, moved in from `workflow-parse-check.yml`'s `self` job. It stays duplicated there until #1542 deletes that wrapper. - C3 (`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 in `tests/workflows.bats` were derived where possible (see "Hand-kept lists"). - Fragment `changelog.d/1541-reusable-pr-gates.added.md`. - `.forgejo/workflows/pr-split.yml` was **temporary**. It ran `layout: split` with 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) 1. **`split`: static per-gate jobs, not a `fromJSON` matrix.** - A matrix is right when one aggregated result is enough. `fromJSON` does work here, from an input and from a job output (measured, item 3 below). - Static jobs are right when the result job must name each gate. The AC asks for `needs.<job>.result` per on gate, and a matrix gives one result for all legs. - **Chosen: static jobs**, each `if: inputs.layout == 'split' && inputs.<gate> == 'on'`. 2. **Every gate `off` refuses, 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 in `pr-gates.sh expected`. 3. **One shared per-gate step: `scripts/pr-gates.sh`.** - A local composite action is right only if toolkit-self can load it from the PR. It cannot: `uses: ./.release-toolkit/...` is main's checkout, so a PR that changes a gate would be graded by main's copy (#728). - An `rt` verb 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. - **Chosen: the script.** It joins `DefaultComposeScripts`. A remote pin runs it from `.release-toolkit` at the resolved ref; only a local-path call (toolkit-self) runs the workspace copy. 4. **One `gates` job does validation and the D6 resolve in both layouts.** In `combined` it then runs every on gate. In `split` it is the plan job that the gate jobs `need`. So each file has one resolve step and one bake marker. 5. **The resolve reads the HEAD's workflows, before the replay.** The pin that loaded the reusable is the head's. The old reusables resolved after the replay. 6. **The inner job is named `plan, and run every gate when combined`, not `gates`.** An inner job named `gates` would post under the caller's context, `pr / gates (pull_request)`. 7. **Gate input names:** `manifest`, `fragments`, `changelog_body`, `register`, `ac_closure` and `workflow_parse`. - The per-gate options keep their old names. - The five density thresholds are now one input each, shared by fragments and changelog body. Before, each reusable took its own copy, and the two "must match". 8. **`fragments` is 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. 9. **The script path reaches the gate steps through `GITHUB_ENV`**, written by the setup step. That keeps the six gate steps byte-identical across layouts, and a bats arm compares them. 10. **The commit-subject register scan is not carried** (operator decision, 2026-10-05; the policy is in #1548). A hit in a commit subject can only be cleared by rewriting history, which AGENTS.md section 15 forbids. - Not carrying the step or its input is right while the policy is open. A consumer cannot switch on something the reusable does not have, and #1548 can add it back as a new optional input. - A kept input that is refused when set would also be safe. But it would put an input that does nothing into the interface v1 freezes. - **Chosen: no step and no input.** The register gate is the file scan only, and toolkit-self's `pr.yml` sets no subject scan. 11. **`pre_push.go` is not touched.** Its `locallyUnreproducible` table must not pre-declare a context that is not required yet (#1333). #1542 adds `pr / gates (pull_request)` when it swaps the required set. ## Carry-forward from the six single-gate reusables (AGENTS.md section 1) | Precondition in the old reusables | Disposition here | |---|---| | `permissions: contents: read` (all) | Preserved on every job. | | `issues: read`, `pull-requests: read` (ac-closure) | **Reimplemented.** The `gates` job holds the union, because `combined` runs ac-closure in it. In `split`, only the `ac_closure` job has them. | | `secrets.GITHUB_TOKEN` to the bootstrap, and as `FORGEJO_TOKEN` to ac-closure | Preserved. The token reaches each gate step as `PR_GATES_TOKEN`, and only ac-closure reads it. Every gate step carries it, because the steps must be identical. | | `vars.RELEASE_TOOLKIT_MINISIGN_PUBLIC_KEY` to the bootstrap (#513) | Preserved on all 7 bootstrap calls. A bats arm checks every call. | | `fetch-depth: 0` for manifest (tags), fragments and register; default depth for body, ac-closure and parse | **Reimplemented:** depth 0 everywhere. Combined shares one checkout, and the replay unshallowed every PR checkout anyway. | | Landing-tree replay (#1195), `if: github.event_name == 'pull_request'` | **Reimplemented, because the old guard never fired.** Inside a called reusable on this forge, `github.event_name` reads `workflow_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 on `github.event.pull_request.base.ref`, and reads its base from there. A bats arm forbids `github.event_name` in the reusable. | | Register commit-subject scan (`check_commit_subjects`, `rt register-check --git-log`), guarded by `github.event_name == 'pull_request'` | **Retired pending #1548.** There is no input and no step, and toolkit-self's `pr.yml` no longer opts in (commit 4). The old guard never fired anyway: my first head logged `event is 'workflow_call'`. | | Resolve: a fixed wrapper file name per reusable | **Retired** by D6: a scan of every top-level `*.yml` and `*.yaml`. | | Resolve arms #456, #336 and #1222, with their SOURCE strings | Preserved. They are tested by the D6 harness and by the existing #1222 and #336 harnesses in `workflows.bats`, which now include this file. | | Toolkit-self arm: parse and ac-closure grepped their fixed wrapper for a local call | **Reimplemented** as D6's local-path arm. It still resolves `main`. | | The parse check chose the PR's own script by `GITHUB_REPOSITORY == frankenbit/release-toolkit` | **Reimplemented** on the D6 local-path identity, which also holds in the Gitea mirror and in forks. | | Shared bootstrap composite (#861), with its `context:` | Preserved, as `context: pr-gates`. | | Inputs reach shells through `env:`, never interpolated (#654) | Preserved. Every input goes through job-level env, and a bats arm checks every run block and every input. | | Fragments: base...head diff, deleted files, whether CHANGELOG.md changed | Preserved verbatim in the script. | | Fragments: coverage and density as a two-leg matrix | **Reimplemented** as two modes in one gate step (decision 8). | | Parse validator 12.8.2, pinned by sha256 | Preserved. It downloads to `$RUNNER_TEMP/pr-gates/` instead of `/tmp`. | | `timeout-minutes: 10` | Preserved for the split jobs. Combined gets 20 and result gets 5. | | Wrapper triggers: `edited` only on fragment-check and ac-closure-check | **Reimplemented:** `pr.yml` takes the union, including `edited`, with no `paths:` filter (the wiring rule). | ## Hand-kept lists in `tests/workflows.bats` (AC) | List | Disposition | |---|---| | "all expected reusables are present" (9 names) | **Derived:** read from `canonicalFiles`. | | "every reusable with a bake-marker env is registered" (9 names) | **Derived** from the files that carry the `release-toolkit-build-ref` marker, each checked against `canonicalFiles`. **Mutant:** dropping `reusable-pr-gates.yml` from `canonicalFiles` is refused (`MUTANT killed: canonicalFiles without reusable-pr-gates.yml is refused`). | | `if len(scripts) != 18` (twice), and "OK all 18 discovered scripts" | **Derived:** `2 * len(canonicalFiles)`. | | "rt prep stages all 9" | **Unpinned.** The registration arm owns the population; this arm owns only canonical == staged. | | `wrapper_names` maps (#1222, #336 harnesses) | **Left fixed.** They name fixed wrappers for the old reusables. For `reusable-pr-gates.yml` the derived name `pr-gates.yml` works, because D6 scans every file. The dedicated D6 harness is in `tests/pr-gates.bats`. #1542 retires the old entries. | | `cases` pairs ("six adopter ref controls") | **Left fixed.** They grade the fixed-wrapper probe, which the new reusable does not have by design. | | `gates=(...)` (#861), the minisign `reusables` list, the #654 `names` set | **Left fixed** for the old reusables, which #1542 removes. `tests/pr-gates.bats` grades the same three properties for the new one. | `tests/bootstrap-rt.bats` also changed. Its resolve -> bootstrap chain test now follows a split job's `needs.gates.outputs.ref` back 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 `.forgejo` and the `.gitea` twin, and every mutant is killed: ``` MUTANT killed: drop the local+remote refusal -> 'local and remote' changes (rc=0 ref=main ...) MUTANT killed: drop the two-pins refusal -> 'two pins, one file' changes (rc=0 ref=v0.60.0 ...) MUTANT killed: drop the no-pin refusal -> 'no pin, baked main' changes (rc=0 ref=main ...) MUTANT killed: read only a fixed pr.yml -> 'pin in a .yaml of any name' changes (rc=1 ...) MUTANT killed: drop the .yaml glob -> 'pin in a .yaml of any name' changes (rc=1 ...) MUTANT killed: drop the reusable-name check -> 'another reusable pinned' changes (rc=0 ref=v0.60.0 ...) MUTANT killed: toolkit-self arm reachable from a remote pin -> 'tag pin, baked tag' changes (rc=0 ref=main script=scripts/pr-gates.sh ...) MUTANT killed: a pinned adopter runs its own workspace script -> 'tag pin, baked tag' changes (script=scripts/pr-gates.sh ...) MUTANT killed: drop the #456 @main arm -> 'main pin, baked tag' changes (rc=0 ref=v0.60.0 ...) MUTANT killed: drop the #336 SHA arm -> 'SHA pin, baked tag' changes (rc=0 ref=v0.60.0 ...) MUTANT killed: drop the #1222 fallback -> 'tag pin, baked main' changes (rc=0 ref=main ...) MUTANT killed: drop the repository casefold -> 'absolute URL, display case' changes (rc=1 ...) OK every D6 mutant is killed ``` Every remote-pin case, `@main` and SHA pins included, asserts `script=.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:** | Mutant | combined | split | |---|---|---| | skipped | attest: `NOT RUN: steps.fragments.outcome=skipped` (rc 1) | result: `GATE NOT GREEN: fragments is on` (rc 1); live in probe c5 | | deleted | attest: `COULD-NOT-GRADE: steps.manifest.outcome did not resolve` (rc 1) | result: `COULD-NOT-GRADE: needs.manifest.result` (rc 1) | | failing | the gate's own step fails, and writes no verdict | result: `GATE NOT GREEN: manifest is on` (rc 1); live in probe c4 | | green step with no verdict | `NO VERDICT` (rc 1) | the same attest, per split job | | off gate that ran | `OFF GATE RAN` (rc 1) | `OFF GATE RAN` (rc 1); a mutant that accepts `off:*` is caught | | result job without `always()` | n/a | the wiring arm refuses it. Live, the caller stays green (probe c6): that is the hole. | **Commit-subject scan retired (commit 4).** A bats arm runs the register gate with `PR_GATES_CHECK_COMMIT_SUBJECTS=true` and both PR SHAs set, and requires exactly one `rt` call, the file scan. It also refuses `--git-log` or `check_commit_subjects` anywhere in the script, the reusable, the Gitea twin and `pr.yml`. Three mutants are caught: `--git-log` restored in the script, the input passed from `pr.yml`, and a `--git-log` step added to the split register job. **Other mutants:** - an unknown layout that is accepted; - an all-off set that is accepted, in the validate step and in `pr-gates.sh expected`; - an edited split copy of a gate step (caught by the byte-identity arm); - a result job missing one gate in its `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/workflows` or `.github/workflows` (404 each). - **After use**, `main` still has none of the three. The `probe` branch has `.forgejo/workflows` with 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: - a `gates` job; - static `a` and `b` jobs that `need` it; - `result`, with `if: ${{ always() && inputs.layout == 'split' }}`. Every step is `echo` or `exit`. | Caller (`<wf> / gates`) | Inner jobs (state from the statuses and the job logs) | Caller | |---|---|---| | c1 combined, pass | plan job success; a, b and result skipped ("Has been skipped") | **success** | | c2 combined, the one inner job fails | plan job failure (log: `probe: failing the gates job on purpose`); a, b and result skipped | **failure** (item 1) | | c3 split, a on, b off | a success; b skipped; result success (log: `ok a:on:success`, `ok b:off:skipped`) | **success** | | c4 split, a fails | a failure; b success; result failure (log: `REFUSED a:on:failure`) | **failure** | | c5 split, b on but wrongly skipped | b skipped; result failure (log: `REFUSED b:on:skipped`) | **failure** | | c6 the same, with the result job **without** `always()` | b skipped; **result skipped** | **success**: the hole `always()` closes | | c7 a matrix from `fromJSON(inputs.gates)`, and one from `fromJSON(needs.plan.outputs.gates)` | `direct a`, `direct b`, `viaplan x (x)` and `viaplan y (y)` all ran (the logs show each leg's echo) | **success** (item 3) | - **Item 1:** a single non-matrix inner job that fails fails the caller (c2). - **Item 2:** skipped inner jobs leave the caller green (c1, c3). So both layouts can live in one file, with the other layout's jobs skipped. - **Item 3:** a `fromJSON` matrix works, from an input and from a job output. Static jobs were still chosen (decision 1). - **Item 4**, split's time saving: not measured, because the runner has one slot. ## Self-bootstrap (AGENTS.md section 2) No re-pin: toolkit-self floats @main (AGENTS.md section 2.1). `rt check-self-bootstrap` on this branch: ``` [check-self-bootstrap] pinned toolkit ref: main [check-self-bootstrap] floating-pin (release-toolkit#456): main matches main by construction ``` `reusable-pr-gates.yml` and `scripts/pr-gates.sh` join `DefaultComposeScripts`. Adopters get the reusable from the first cut tag that contains it. ## Local verification - `go test ./...` passes. - `golangci-lint`: 0 issues. - Full `bats tests/`: 245 ok, 0 failed (commit 1); `tests/pr-gates.bats` 24 ok after commit 2. CI `tests / bats` green on all three heads. - `shellcheck scripts/pr-gates.sh` is clean. - `rt gitea-twin --check`: 12 twins match. - `forgejo-runner validate` 12.8.2 accepts the new workflows. ## CI on this PR (job logs read in the browser, not just statuses) | Head | What ran | Result | |---|---|---| | `96650fd` (commit 1) | `pr` (combined, run 19757) and `pr-split` (split, run 19756) | All 41 contexts success. The logs showed the replay **skipped** and `event is 'workflow_call'`, which commit 2 addresses. | | `7fcd283` (commit 2) | `pr` (run 19771) and `pr-split` (run 19770) | All 41 contexts success. Replay ran in all 7 jobs. | | `f26d6562` (commit 3) | `pr` (run 19784) | All 32 contexts success. | | `26d9a32` (commit 4, subject scan retired) | `pr` | `tests / bats` (run 19804) **failed** in the new #1548 arm. The arm compared the stub's whole log line, and CI sets `FORGEJO_TOKEN` in the environment. The gate itself behaved. | | `855b34e7` (commit 5, final) | `pr` (run 19818), `tests` (run 19821) | All 32 contexts success. | **`pr / gates` (combined), run 19818 on the final head `855b34e7`, from the job log:** - `local .forgejo/workflows/pr.yml`, then `resolved toolkit ref: main (source toolkit-self local invocation (ADR-0013 D6))`, with the per-gate script `scripts/pr-gates.sh`. - `landing-tree replay: PASS - this head already contains origin/main at 44bdba5...` - `bootstrap rt: BUILD from source @ main` - Each gate: - manifest: `[manifest-check] all checks passed` - fragments: `coverage PASS` and `density PASS` - changelog_body: `PASS: check 1 (header) ...` - register: `[register-check] OK: 0 register-drift hits across scanned paths.`, then `register-check: file scan only; the commit-subject scan is not part of this gate (#1548).` Nothing ran `--git-log`. - ac_closure: `no same-repo Closes-target found on either surface` - workflow_parse: `PARSED=38 TOTAL=38` - Then `pr-gates: tree unchanged across every gate step`, and `attest: every expected gate ran (manifest fragments changelog_body register ac_closure workflow_parse)`. **`pr-split` (split), run 19770, from the job logs:** - Each of the six gate jobs logged `landing-tree replay: PASS` and `attest: every expected gate ran (<its gate>)`. - `split result` logged `plan: 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:** - c13 logged `mutation.yml workflow schema validation failed`, `PARSED=39 TOTAL=40` and `negative control OK: mutation.yml made the executed guard fail (rc=1)`. - attest logged `all 13 gate steps AND the shared-tree guard ran`. ## Not verified - `split` with the real gates ran only on this PR's first two heads, through the temporary `pr-split.yml`. Since its removal, only the bats arms exercise `split`. - The old reusables still skip their replay, and their register subject scan (finding above). This PR does not touch them, because #1542 deletes them. Adopters on old tags keep that behaviour. - An adopter's tag pin end to end (the FETCH arm, with `.release-toolkit` at a tag) cannot run before a cut. Only the resolve and bootstrap harnesses cover it. - Item 4 (split's wall-clock saving). ## Verify at source, please (AGENTS.md section 6) @alex, please verify at source: - the D6 resolve step in `reusable-pr-gates.yml`, against the table in ADR-0013's details; - that a remote pin can never reach `script=scripts/pr-gates.sh`; - the job logs of `pr / gates` and `pr-split / gates` on the first head; - that `pr / gates (pull_request)` is the context to require in #1542. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(workflows): add reusable-pr-gates.yml, one PR-gates reusable with a combined or split layout
All checks were successful
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 7s
manifest-check / check (pull_request) Successful in 0s
pr-split / plan, and run every gate when combined (pull_request) Successful in 4s
pr / plan, and run every gate when combined (pull_request) Successful in 10s
readme-pin-check / mirror (pull_request) Successful in 8s
register-check / register-drift check (pull_request) Successful in 7s
register-check / check (pull_request) Successful in 0s
tests / bats (pull_request) Successful in 42s
tests / checks (pull_request) Successful in 8s
toolkit-self-gates / toolkit-self gates (PR's own rt) (pull_request) Successful in 8s
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 4s
workflow-parse-check / check (pull_request) Successful in 0s
go-ci / page landing-tree failure (pull_request) Has been skipped
pr-split / manifest (pull_request) Successful in 8s
pr-split / fragments (pull_request) Successful in 8s
pr-split / changelog_body (pull_request) Successful in 8s
pr-split / register (pull_request) Successful in 8s
pr-split / ac_closure (pull_request) Successful in 8s
pr-split / workflow_parse (pull_request) Successful in 9s
pr / manifest (pull_request) Has been skipped
pr / fragments (pull_request) Has been skipped
pr / changelog_body (pull_request) Has been skipped
pr / register (pull_request) Has been skipped
pr / ac_closure (pull_request) Has been skipped
pr / workflow_parse (pull_request) Has been skipped
pr-split / split result (pull_request) Successful in 1s
pr-split / gates (pull_request) Successful in 0s
pr / split result (pull_request) Has been skipped
pr / gates (pull_request) Successful in 0s
96650fd6db
ADR-0013 (Refs #1541, #1530). One reusable runs manifest, fragments,
changelog body, register, ac-closure and workflow parse, each 'on' or
'off' (manifest and fragments default on). `layout: combined` runs every
on gate as a step of one job; `layout: split` runs one job per gate plus
a result job with `if: always()` that reads needs.<job>.result for every
gate. Both layouts call the same per-gate step, scripts/pr-gates.sh.

The resolve step implements D6: it scans every top-level workflow for a
call, refuses two pins or a local call mixed with a remote pin, refuses
a baked 'main' with no caller pin, and recognises toolkit-self by its
local-path call. Every gate off is refused (operator, 2026-10-04).

Toolkit-self calls it from a local pr.yml with every gate on and
layout combined. pr-split.yml runs the split layout on this first head
only and is removed in the next commit.

The reusable joins canonicalFiles, bakeRefFiles, DefaultComposeScripts
(with scripts/pr-gates.sh) and NeedsSecrets; the Gitea twin is
generated. tests / checks gains the parse guard's mutation control
(c13). Hand-kept "all baked reusables" lists in tests/workflows.bats are
now read from canonicalFiles or from the marker-carrying files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude requested review from alex 2026-10-05 00:15:44 +02:00
Author
Member

HOLD: pushing a new commit. On the first head (96650fd), the job logs show that github.event_name reads workflow_call inside 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.

HOLD: pushing a new commit. On the first head (96650fd), the job logs show that `github.event_name` reads `workflow_call` inside 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.
fix(workflows): key pr-gates' replay and subject scan on the PR payload, not event_name
All checks were successful
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 7s
manifest-check / check (pull_request) Successful in 0s
pr-split / plan, and run every gate when combined (pull_request) Successful in 4s
pr / plan, and run every gate when combined (pull_request) Successful in 10s
readme-pin-check / mirror (pull_request) Successful in 7s
register-check / register-drift check (pull_request) Successful in 7s
register-check / check (pull_request) Successful in 0s
tests / bats (pull_request) Successful in 42s
tests / checks (pull_request) Successful in 8s
toolkit-self-gates / toolkit-self gates (PR's own rt) (pull_request) Successful in 8s
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 4s
workflow-parse-check / check (pull_request) Successful in 0s
go-ci / page landing-tree failure (pull_request) Has been skipped
pr-split / manifest (pull_request) Successful in 8s
pr-split / fragments (pull_request) Successful in 8s
pr-split / changelog_body (pull_request) Successful in 8s
pr-split / register (pull_request) Successful in 8s
pr-split / ac_closure (pull_request) Successful in 8s
pr-split / workflow_parse (pull_request) Successful in 8s
pr / manifest (pull_request) Has been skipped
pr / fragments (pull_request) Has been skipped
pr / changelog_body (pull_request) Has been skipped
pr / register (pull_request) Has been skipped
pr / ac_closure (pull_request) Has been skipped
pr / workflow_parse (pull_request) Has been skipped
pr-split / split result (pull_request) Successful in 1s
pr-split / gates (pull_request) Successful in 0s
pr / split result (pull_request) Has been skipped
pr / gates (pull_request) Successful in 0s
7fcd2837b3
Inside a called reusable this forge reports github.event_name as
workflow_call. Measured on the first head of #1547 (job logs): the
`if: github.event_name == 'pull_request'` replay step was skipped in
every called reusable, and the register gate logged "event is
'workflow_call'". The replay now keys on
github.event.pull_request.base.ref and reads its base from the payload;
the commit-subject scan keys on the payload's base/head SHAs. A bats arm
forbids github.event_name in the reusable's code.

Refs #1541

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Member

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.

New head: 7fcd2837b3578e0469119acd2074210232dca282 (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.
Author
Member

HOLD: pushing the commit that removes the temporary pr-split.yml. The split layout's logs on 7fcd283 are read and recorded in the PR body.

HOLD: pushing the commit that removes the temporary pr-split.yml. The split layout's logs on 7fcd283 are read and recorded in the PR body.
Author
Member

New head: f26d6562f8 (temporary pr-split.yml removed).

New head: f26d6562f8ae8752faa153bf18fa9ae543ee265d (temporary pr-split.yml removed).
chore(ci): remove the temporary pr-split.yml after its live split run
All checks were successful
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 7s
changelog-body-check / check (pull_request) Successful in 0s
go-ci / lint + build + test (pull_request) Successful in 34s
go-ci / record reviewed vs landed commit (pull_request) Has been skipped
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 7s
manifest-check / check (pull_request) Successful in 0s
readme-pin-check / mirror (pull_request) Successful in 8s
register-check / register-drift check (pull_request) Successful in 7s
register-check / check (pull_request) Successful in 0s
tests / bats (pull_request) Successful in 42s
tests / checks (pull_request) Successful in 8s
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
go-ci / page landing-tree failure (pull_request) Has been skipped
ac-closure-check / ac-closure check (pull_request) Successful in 7s
ac-closure-check / check (pull_request) Successful in 0s
fragment-check / coverage (pull_request) Successful in 7s
fragment-check / density (pull_request) Successful in 7s
fragment-check / check (pull_request) Successful in 0s
pr / plan, and run every gate when combined (pull_request) Successful in 11s
toolkit-self-gates / toolkit-self gates (PR's own rt) (pull_request) Successful in 8s
pr / manifest (pull_request) Has been skipped
pr / fragments (pull_request) Has been skipped
pr / changelog_body (pull_request) Has been skipped
pr / register (pull_request) Has been skipped
pr / ac_closure (pull_request) Has been skipped
pr / workflow_parse (pull_request) Has been skipped
pr / split result (pull_request) Has been skipped
pr / gates (pull_request) Successful in 0s
f26d6562f8
It ran reusable-pr-gates.yml with layout: split and the real gates on
#1547's first two heads (runs 19756 and 19770, all green, logs read).
Toolkit-self keeps one caller, pr.yml, with layout: combined.

Refs #1541

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Member

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.

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.
feat(workflows): retire the commit-subject register scan from reusable-pr-gates.yml
Some checks failed
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 6s
changelog-body-check / check (pull_request) Successful in 0s
go-ci / lint + build + test (pull_request) Successful in 33s
go-ci / record reviewed vs landed commit (pull_request) Has been skipped
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 7s
manifest-check / check (pull_request) Successful in 0s
readme-pin-check / mirror (pull_request) Successful in 7s
register-check / register-drift check (pull_request) Successful in 7s
register-check / check (pull_request) Successful in 0s
tests / bats (pull_request) Failing after 42s
tests / checks (pull_request) Successful in 8s
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 4s
workflow-parse-check / check (pull_request) Successful in 0s
ac-closure-check / ac-closure check (pull_request) Successful in 7s
ac-closure-check / check (pull_request) Successful in 0s
fragment-check / coverage (pull_request) Successful in 7s
fragment-check / density (pull_request) Successful in 7s
fragment-check / check (pull_request) Successful in 0s
pr / plan, and run every gate when combined (pull_request) Successful in 10s
toolkit-self-gates / toolkit-self gates (PR's own rt) (pull_request) Successful in 8s
go-ci / page landing-tree failure (pull_request) Has been skipped
pr / manifest (pull_request) Has been skipped
pr / fragments (pull_request) Has been skipped
pr / changelog_body (pull_request) Has been skipped
pr / register (pull_request) Has been skipped
pr / ac_closure (pull_request) Has been skipped
pr / workflow_parse (pull_request) Has been skipped
pr / gates (pull_request) Successful in 0s
pr / split result (pull_request) Has been skipped
26d9a32a2c
Operator decision (2026-10-05): the register gate in the new reusable is
the file scan only. The check_commit_subjects input, its env plumbing,
the `rt register-check --git-log` call and toolkit-self's opt-in in
pr.yml are removed. A hit in a commit subject can only be cleared by
rewriting history, which AGENTS.md section 15 forbids, so whether to
grade subjects is open policy, tracked in #1548. The landing-tree replay
is unchanged.

A bats arm executes the register gate with every old knob set and
requires exactly one rt call (the file scan), and refuses --git-log or
the input anywhere in the script, the reusable, its twin or pr.yml; three
mutants restoring it are caught.

Refs #1541
Refs #1548

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Member

New head: 26d9a32a2c (commit-subject register scan retired, pending #1548; replay unchanged).

New head: 26d9a32a2c21da7ed4cada4f4d00791235fb105b (commit-subject register scan retired, pending #1548; replay unchanged).
Author
Member

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.

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.
Author
Member

New head: 855b34e76b (the #1548 register arm is now hermetic; select_gates uses printf '%s\n').

New head: 855b34e76b386d6aeb791ebdd1e9176ede36307a (the #1548 register arm is now hermetic; select_gates uses printf '%s\n').
test(workflows): make the #1548 register arm hermetic; fix select_gates printf
All checks were successful
workflow-parse-check / check (pull_request) Successful in 0s
go-ci / page landing-tree failure (pull_request) Has been skipped
ac-closure-check / ac-closure check (pull_request) Successful in 7s
ac-closure-check / check (pull_request) Successful in 0s
fragment-check / coverage (pull_request) Successful in 7s
fragment-check / density (pull_request) Successful in 7s
fragment-check / check (pull_request) Successful in 0s
pr / plan, and run every gate when combined (pull_request) Successful in 10s
toolkit-self-gates / toolkit-self gates (PR's own rt) (pull_request) Successful in 8s
pr / manifest (pull_request) Has been skipped
pr / fragments (pull_request) Has been skipped
pr / changelog_body (pull_request) Has been skipped
pr / register (pull_request) Has been skipped
pr / ac_closure (pull_request) Has been skipped
pr / workflow_parse (pull_request) Has been skipped
pr / split result (pull_request) Has been skipped
pr / gates (pull_request) Successful in 0s
check-self-bootstrap / check (push) Successful in 6s
gitea-twin-check / check (push) Successful in 5s
go-ci / lint + build + test (push) Successful in 34s
go-ci / record reviewed vs landed commit (push) Successful in 3s
prepared-uncut-check / toolkit-self prepared-uncut controls (push) Successful in 5s
prepared-uncut-check / prepared-but-uncut release (push) Successful in 7s
prepared-uncut-check / check (push) Successful in 0s
release / decide + act (push) Successful in 9s
release / release (push) Successful in 0s
tests / bats (push) Successful in 41s
tests / checks (push) Successful in 8s
go-ci / page landing-tree failure (push) Has been skipped
release / fire-cut (push) Has been skipped
855b34e76b
The arm compared the stub rt's whole log line, which records ambient env;
CI sets FORGEJO_TOKEN, so tests / bats run 19804 failed on 26d9a32 while
the gate itself behaved (log: one register-check call, no --git-log). It
now asserts exactly one rt call whose argument field is the bare file
scan, and setup unsets FORGEJO_TOKEN/FORGEJO_BASE_URL.

select_gates printed its gate with a literal newline inside the printf
format; it is now printf '%s\n' (reviewer pre-read nit).

Refs #1541

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude left a comment

Verdict: APPROVE at 855b34e76b

Summary: reusable-pr-gates.yml implements 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 on f26d656 (26d9a32, 855b34e); base 44bdba5 is live main.

The throwaway-repo measurements, checked on the repo itself

frankenbit/pr-gates-probe-1541:

  • State: private; main gives 404 for .forgejo/workflows, .gitea/workflows and .github/workflows; the 10 files are on probe only; 0 tags. That satisfies AGENTS.md section 14.
  • Statuses: at PR #1's head 21feae0 they match c1-c7 row for row:
    • c2: the single inner job fails, so the caller fails;
    • c5: a wrongly skipped b makes the result fail, so the caller fails;
    • c6: without always() the result is skipped and the caller stays success;
    • c7: all four fromJSON legs ran.
  • Controls: reusable-probe.yml and reusable-probe-noalways.yml differ only in the result job's if:. So c6 is a clean control for the hole, and c5's wrongskip input really skips b.

Verified at source

  • The workflow_call finding. #1544's run (19704 job 1, 19706 job 1, head 5cc3b8c) shows replay onto the landing base skipped in the old manifest and register inner jobs. It also shows register's commit-subject scan skipped, with be triggered by event: workflow_call. The new reusable keys the replay on github.event.pull_request.base.ref, and a bats arm forbids github.event_name.
  • The D6 resolve against ADR-0013's table. The scan covers every top-level *.yml and *.yaml. It uses the #1222 parsing: optional host, lowercased prefix, quote stripping, the name must be reusable-pr-gates.yml. It refuses a local call mixed with a remote pin, two remote refs, and no pin with baked main or 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 local uses: line. The Gitea twin differs only in its header and the .gitea paths.
  • A remote pin never reaches the workspace script. SCRIPT is scripts/pr-gates.sh only when SELF=true, which only the local arm sets. Every other path gets .release-toolkit/scripts/pr-gates.sh at the resolved ref.
  • split's result job: if: ${{ always() && inputs.layout == 'split' }}, and needs: all six gates plus gates. It accepts only on:success and off:skipped, treats empty as could-not-grade, and refuses everything else.
  • Combined: attest runs with always() and attests the tree guard. Every gate off refuses in both the validate step and pr-gates.sh expected.
  • The fragments gate is set -e safe. Errexit is off inside the || context, but every branch of the subshell ends in rt fragment-check or an explicit exit 2. The coverage and density env matches the old matrix legs.
  • C3 documents all 21 inputs (the five density thresholds share one row) and pr / gates (pull_request).
  • The subject-scan removal (26d9a32). check_commit_subjects and --git-log appear nowhere in either reusable. pr.yml and pr-gates.sh mention them only in comments. gate_register is now one rt register-check plus the #1548 line. All seven PR_GATES_CHECK_COMMIT_SUBJECTS env 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, commit 855b34e76b): it resolved the toolkit ref to main (source=toolkit-self local invocation, ADR-0013 D6), and the replay passed. The gates logged:
    • manifest: all checks passed;
    • fragments: coverage and density PASS;
    • changelog_body: PASS;
    • register: 0 register-drift hits across scanned paths and file scan only; the commit-subject scan is not part of this gate (#1548);
    • ac_closure: no Closes-target;
    • workflow_parse: PARSED=38 TOTAL=38.
      Then the tree was unchanged, and attest says every expected gate ran.
  • tests / bats (run 19821, job 0): 1..246, no not ok, including ok 74 ... never runs --git-log (#1548).
  • toolkit-self-gates (run 19827): attest 15 of 15.
  • split with the real gates (run 19770, head 7fcd283, before pr-split.yml was removed): the result job logs every gate on, success.

Mutants (Axis B), in an LF clone, tests/pr-gates.bats in rt-bats:local

The baseline is 24 of 24.

  • Ma (f26d656): the result job accepts on:skipped. Arm 23 goes red.
  • Mb (f26d656): a remote pin gets scripts/pr-gates.sh. The D6 arm goes red.
  • Mc (f26d656): the scan's quote stripping removed. The D6 arm goes red ("self local call, quoted").
  • Md (855b34e): rt register-check --git-log base..head put back in gate_register. The #1548 arm goes red.
  • Me (855b34e): the file scan replaced by true, 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)

  • The tag-pinned FETCH arm end to end needs a cut.
  • split with 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.

Verdict: APPROVE at 855b34e76b386d6aeb791ebdd1e9176ede36307a Summary: `reusable-pr-gates.yml` implements 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 on f26d656 (26d9a32, 855b34e); base 44bdba5 is live main. ## The throwaway-repo measurements, checked on the repo itself `frankenbit/pr-gates-probe-1541`: - **State:** private; `main` gives 404 for `.forgejo/workflows`, `.gitea/workflows` and `.github/workflows`; the 10 files are on `probe` only; 0 tags. That satisfies AGENTS.md section 14. - **Statuses:** at PR #1's head `21feae0` they match c1-c7 row for row: - c2: the single inner job fails, so the caller fails; - c5: a wrongly skipped `b` makes the result fail, so the caller fails; - c6: without `always()` the result is skipped and the caller stays **success**; - c7: all four `fromJSON` legs ran. - **Controls:** `reusable-probe.yml` and `reusable-probe-noalways.yml` differ only in the result job's `if:`. So c6 is a clean control for the hole, and c5's `wrongskip` input really skips `b`. ## Verified at source - **The `workflow_call` finding.** #1544's run (19704 job 1, 19706 job 1, head 5cc3b8c) shows `replay onto the landing base` **skipped** in the old manifest and register inner jobs. It also shows register's `commit-subject scan` skipped, with `be triggered by event: workflow_call`. The new reusable keys the replay on `github.event.pull_request.base.ref`, and a bats arm forbids `github.event_name`. - **The D6 resolve against ADR-0013's table.** The scan covers every top-level `*.yml` and `*.yaml`. It uses the #1222 parsing: optional host, lowercased prefix, quote stripping, the name must be `reusable-pr-gates.yml`. It refuses a local call mixed with a remote pin, two remote refs, and no pin with baked `main` or 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 local `uses:` line. The Gitea twin differs only in its header and the `.gitea` paths. - **A remote pin never reaches the workspace script.** `SCRIPT` is `scripts/pr-gates.sh` only when `SELF=true`, which only the local arm sets. Every other path gets `.release-toolkit/scripts/pr-gates.sh` at the resolved ref. - **`split`'s result job:** `if: ${{ always() && inputs.layout == 'split' }}`, and `needs:` all six gates plus `gates`. It accepts only `on:success` and `off:skipped`, treats empty as could-not-grade, and refuses everything else. - **Combined:** attest runs with `always()` and attests the tree guard. Every gate off refuses in both the validate step and `pr-gates.sh expected`. - **The fragments gate is set -e safe.** Errexit is off inside the `||` context, but every branch of the subshell ends in `rt fragment-check` or an explicit `exit 2`. The coverage and density env matches the old matrix legs. - **C3** documents all 21 inputs (the five density thresholds share one row) and `pr / gates (pull_request)`. - **The subject-scan removal (26d9a32).** `check_commit_subjects` and `--git-log` appear nowhere in either reusable. `pr.yml` and `pr-gates.sh` mention them only in comments. `gate_register` is now one `rt register-check` plus the #1548 line. All seven `PR_GATES_CHECK_COMMIT_SUBJECTS` env 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, commit 855b34e76b): it resolved the toolkit ref to `main` (source=toolkit-self local invocation, ADR-0013 D6), and the replay passed. The gates logged: - manifest: `all checks passed`; - fragments: coverage and density PASS; - changelog_body: PASS; - register: `0 register-drift hits across scanned paths` and `file scan only; the commit-subject scan is not part of this gate (#1548)`; - ac_closure: no Closes-target; - workflow_parse: `PARSED=38 TOTAL=38`. Then the tree was unchanged, and attest says every expected gate ran. - **`tests / bats`** (run 19821, job 0): `1..246`, no `not ok`, including `ok 74 ... never runs --git-log (#1548)`. - **`toolkit-self-gates`** (run 19827): attest `15 of 15`. - **`split` with the real gates** (run 19770, head 7fcd283, before `pr-split.yml` was removed): the result job logs every gate `on, success`. ## Mutants (Axis B), in an LF clone, `tests/pr-gates.bats` in `rt-bats:local` The baseline is 24 of 24. - **Ma** (f26d656): the result job accepts `on:skipped`. Arm 23 goes **red**. - **Mb** (f26d656): a remote pin gets `scripts/pr-gates.sh`. The D6 arm goes **red**. - **Mc** (f26d656): the scan's quote stripping removed. The D6 arm goes **red** ("self local call, quoted"). - **Md** (855b34e): `rt register-check --git-log base..head` put back in `gate_register`. The #1548 arm goes **red**. - **Me** (855b34e): the file scan replaced by `true`, 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) - The tag-pinned FETCH arm end to end needs a cut. - `split` with 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.
alex approved these changes 2026-10-05 19:57:59 +02:00
alex merged commit 855b34e76b into main 2026-10-05 19:58:25 +02:00
alex deleted branch claude/1541-reusable-pr-gates 2026-10-05 19:58:25 +02:00

Landing identity record

  • PR: #1547
  • landed commit (server merge_commit_sha): 855b34e76b386d6aeb791ebdd1e9176ede36307a
  • effective official approval(s):
    • @alex, review #7236, stamped commit: 855b34e76b386d6aeb791ebdd1e9176ede36307a
  • replay comparison: no identity change (stamped SHA equals landed SHA)

This 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.

<!-- release-toolkit:landing-review-record-v1 landed=855b34e76b386d6aeb791ebdd1e9176ede36307a --> ## Landing identity record - PR: #1547 - landed commit (server merge_commit_sha): `855b34e76b386d6aeb791ebdd1e9176ede36307a` - effective official approval(s): - @alex, review #7236, stamped commit: `855b34e76b386d6aeb791ebdd1e9176ede36307a` - replay comparison: no identity change (stamped SHA equals landed SHA) This 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.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
frankenbit/release-toolkit!1547
No description provided.