← Back to home

CI gating — what runs where, and how main stays green

Why this exists: agents landed several breakages on main that no PR check could have caught, because the gates that would have caught them don't gate PRs. This documents the tiers and the merge queue that closes it without making every push slower.

Status (2026-09-07): the merge queue is LIVE. The repo is public and organization-owned (Loom-Harness/Loc), which is what GitHub gates merge queues on — public org repos qualify on every plan, Free included — and the queue is switched on for main. merge_group runs now fire the heavy tier against the rebased candidate. What changed on this date was the repo SETTING, not the ownership: the oldest merge_group runs the Actions API will page to are all one burst created 2026-09-07T11:59:41Z (#2786's first group), with nothing older — measure it again with list_workflow_runs --event merge_group and walk to the last page. When the repo became org-owned is NOT established here. A transfer demonstrably happened at some point — git push still prints remote: This repository moved and redirects lemmit/Loc → Loom-Harness/Loc — but that redirect is permanent and carries no date, and created_at survives a transfer, so neither "it moved on 2026-09-07" nor "it was org-owned all along" is supportable from here. An earlier draft of this doc asserted the former; it is withdrawn rather than restated in the opposite direction. The sections below that were written while the queue was off are kept as the design record and marked [historical].

The failure mode

This section is the original problem statement — the shape of the hole the tiers, pr-gate and the merge queue were built to close. It is kept because the reasoning still explains why each mechanism exists; see the per-point notes for where it stands today.

Branch protection required only tests-passed (the fast vitest rollup). Every heavy gate — the runtime/boot e2e suites, the deploy build — was a non-required check. Three consequences:

  1. Some heavy gates did not run on a PR by default. tenancy-e2e, channels-e2e, api-call-e2e, migration-evolution-e2e, phoenix-ui-e2e, and the two compose OIDC legs (elixir-oidc-e2e, auth-oidc-compose-e2e) triggered on push: [main] only. Whatever they caught, they caught after merge — on main, where it sat red. (Each also accepted a per-PR label trigger as a manual escape hatch, but that is opt-in, so the default was still post-merge.)

    As of 2026-10-05 every one of them gates before the merge — per-PR, in the merge queue, or both; see "The never-gating legs, re-measured (2026-10-05)" below. Since 2026-10-20 nothing re-tests on push: main at all — see "Post-merge net" below.

    This bucket used to be much larger. Promoted out of it, each now carrying a pull_request: trigger with a narrow paths: block, the literal draft guard and PR-aware concurrency: the cross-backend behavioral-e2e-* legs and the pages build (earlier), then wave G1 of docs/new-plan/verification-waves-2026-09.md — the five *-obs-e2e, the four native *-oidc-e2e (hono/python/java/dotnet), the four generated-{react,vue,svelte,angular}-e2e SPA smokes (their first pull_request: trigger ever), and elixir-vanilla-vo-e2e. A promotion is nothing more than that trigger: scripts/pr-gate.mjs fails pr-gate on any non-passing check run on the head SHA, so a gate becomes binding the moment it can produce one. Read the trigger block in the workflow file for the exact paths — this doc does not restate them, and it does not restate job counts.

    Closed by the queue, for this bucket: every one of them also fires on merge_group, so they gate the rebased candidate before it lands rather than reporting on a main that is already red. What remains is that they stay invisible on the PR itself — you find out at the queue, not at the push.

  2. A red heavy gate doesn't block anything. A gate can be broken (even unparseable) and still merge green. behavioral-e2e-dapper.yml had an unquoted colon in its name:, was a permanent startup_failure, and stayed red across 100% of recent main pushes — unnoticed, because it was never green, so there was no red-transition to alert on.

  3. A gate's paths: filter is a third, quieter switch. Every heavy gate narrows itself to the files it thinks can break it. That list is written once, against whatever the test imported that day, and the test's dependencies then grow without it. The gate stays green and stays listed — and for a change confined to an unwatched dir it never runs at all, which is worse than a vacuous assertion because there is no assertion to inspect.

    Measured in #2397: all 27 path-filtered gates omitted src/macros/** and src/util/**; 26 omitted src/language/**, 23 src/system/**, 19 src/ir/**. So a change to src/macros/prelude.ts — where the auditable, tenantOwned, versioned and tenantRegistry capabilities are defined — triggered none of them, as did a change to src/util/naming.ts or src/util/code-builder.ts, both imported by every emitter on every backend.

    The rule now: a workflow that runs a test which generates a project must watch every phase on the generation path — src/language/** (parse), src/macros/** (expand), src/ir/** (lower/enrich/validate), src/system/** (compose) and src/util/** (the naming / code-builder / platform-axes leaves every emitter imports). Those five run for every backend, so no per-backend argument excuses one. test/system/workflow-path-coverage.test.ts derives "runs a test that generates a project" from the test's own transitive import closure (a workflow cannot fall out of scope by rearranging imports) and fails the fast suite on drift. The generator's shared seams (_walker, _expr, _frontend) are deliberately not required — they are genuinely per-target, and requiring them would produce the false positives that get a gate like this weakened into theatre.

cancel-in-progress: true on the push:main gates made it worse: a rapid follow-up merge cancels the previous commit's heavy jobs, so a real failure gets attributed to a later, innocent commit.

Every gate in every tier runs locally. The workflow → local-command reverse index is docs/testing.md → "Running any CI gate locally"; pushing a commit just to see a check's verdict burns the shared runner pool and is never necessary.

The tiers

Lane What Rule
Per-PR, every push (required) test.yml (fast vitest ×4 shards + the corpus-census job; coverage is nightly-only) + lint + web-tsc → tests-passed (unfiltered on PRs); langium-generated; workflow-lint; the typecheck/compile gates (hono/dotnet/java/python-build, generated-*-build, corpus-build); behavioral-e2e (Hono on PGlite, daemonless) as the runtime canary; pr-gate (the aggregate verdict over everything that triggered) Cheap, parallel, no docker/db. Catches most regressions with fast feedback.
Per-PR, path-scoped (binding via pr-gate) The cross-backend runtime legs behavioral-e2e-{dotnet,java,python,elixir,dapper,mikroorm} + behavioral-ui-e2e + behavioral-ui-vue-e2e + behavioral-heex-ui-e2e (each fires when the PR touches its backend's emitters, the shared IR, or the harness); the five {hono,python,java,dotnet,elixir-vanilla}-obs-e2e legs; the four native {hono,python,java,dotnet}-oidc-e2e legs; the four generated-{react,vue,svelte,angular}-e2e SPA smokes; elixir-vanilla-vo-e2e; phoenix-ui-e2e; migration-evolution-e2e (also in the queue); tenancy-e2e's five flat cells; pairwise's generation sweep; the pages build (docs/web/src) Docker/boot cost paid only by the PRs that can break them; when they fire, pr-gate makes them blocking. Each file's paths: block is the authority on when — for the six behavioral-e2e-* legs deliberately narrower than the generation path, so a typical PR fires one or two siblings, not the whole family; their nightly run covers the rest (see "Post-merge net").
Merge queue (merge_group, runs once on the final candidate — LIVE since 2026-09-07) The 24 wired gates (branch protection itself requires only tests passed + pr-gate): the cheap broad set over the combined tree (tests passed, langium drift, the five build-generated-*, both corpus-*, the six frontend builds, parity, headless behavioral), migration-evolution-e2e (its full-tree run; its per-PR block is path-scoped), plus the five gates the queue is the ONLY pre-merge run for unless a run-* label asks earlier (tenancy-e2e beyond its flat cells, channels-e2e, api-call-e2e, elixir-oidc-compose-e2e, auth-oidc-compose-e2e) Catches what per-PR CI structurally cannot: two PRs green apart, red together. Everything already binding per-PR is deliberately NOT required here — see "What the queue requires, and what it does not".
Nightly / label (unchanged) conformance-full, generated-a11y, frontend-fullstack-e2e, k8s-e2e Broad, slow, low churn — post-hoc is fine.

Note: generated-react-build, generated-vue-build and generated-angular-build run a slice on PRs and in the merge queue, and the full example × pack Cartesian nightly on main (and on workflow_dispatch). The queue exists to catch two PRs green apart and red together; per-example or per-pack drift from an unrelated PR is rare and cheap to fix the next morning, and the full React sweep alone was ~50 runner-minutes of every queue entry.

The playground suite is split the same way. playground-e2e (the whole Playwright suite, including the network-gated bundle/boot specs) stays nightly / run-e2e-label; playground-e2e-no-network runs the network-free subset (workspace, history, builder, requirements, editor) on every PR touching web/** or src/**, so file-management and builder regressions are caught before merge.

Post-merge net

Nothing re-tests on push: main. A merge-queue entry tests exactly the tree that lands on main (squash merges, each group built on the ones ahead of it), so a push: main run of a queue leg repeats a verdict already in hand. At ~300 jobs a merge that repeat was the largest single consumer of the runner pool (2026-10-20: 49 workflow runs for one merged commit).

The cost is detection latency for one class of break: two PRs that are green apart and red together, in a leg the queue does not run, now surfaces at the next nightly instead of about an hour after the second merge. Either way it surfaces on main; ci-red-alarm opens the issue, and flake-budget reads both the nightly runs and each queue leg's merge_group runs.

merge-queue-readiness.test.ts → "post-merge net" pins it: push: main only on the deploy allowlist, and every per-PR leg outside the queue carries a schedule: (or a reasoned NO_NIGHTLY entry).

The never-gating legs, re-measured (2026-10-05)

Wave C3 packet 3e's row: every runtime leg runs before the merge — per-PR, in the merge queue, or both — or carries a dated reason for staying out. Every number below came from the Actions REST API on 2026-10-05; the method is stated so the next re-measure can repeat it rather than trust it.

Method. Flake budget: scripts/flake-budget.mjs (first-attempt pass rate over the last 20 completed main runs, budget 80 %, retry-masked at 2 re-run greens). Runner cost: for each workflow, its last 10 completed non-skipped runs (push on main, or merge_group), every non-skipped job's completed_at − started_at, summed per run (median reported). Queue entry cost: every workflow run on each of the last 10 merge_group head SHAs, jobs and runner-minutes summed, wall = first run created → last job completed.

leg flake budget (1st try, last 20 main) cost a firing (median) decision
phoenix-ui-e2e 30/30 first-attempt (#2718 closed 2026-09-09) 2 jobs, 4.4 runner-min, ~2.2 min each per-PR, path-scoped — same blast radius as behavioral-heex-ui-e2e (elixir tree, shared seams, the two HEEx packs); not in the queue (a per-PR-binding leg's queue run is a re-run)
channels-e2e 20/20 18 jobs, 34.4 runner-min, 1.3–3.3 min each merge queue (merge_group: + channels-e2e-passed). A per-PR block carrying the generation path would fire all 18 cells on every src/ir/** push; in the queue it adds ~7 % to an entry's cost and nothing to its wall time
api-call-e2e 20/20 5 jobs, 9.0 runner-min, 1.4–2.4 min each merge queue (merge_group: + api-call-e2e-passed), same argument at a quarter of the size
tenancy-e2e flat cells 20/20 (workflow) 5 jobs, ~7 runner-min, 1.0–1.9 min each per-PR, path-scoped (job tenancy-e2e-flat, draft guard); the other cells stay label + queue (job tenancy-e2e), one rollup over both
migration-evolution-e2e 20/20 5 cells + rollup, 9.8 runner-min, 1.2–3.0 min each per-PR, path-scoped AND still in the queue — the 35-minute figure the deferral cited is the job's cap, not its cost; the queue run stays as the full-tree net behind a path-scoped PR block
playground-e2e 20/20 — stays out (dated waiver, 2026-10-05): its bundle/boot specs ride esm.sh / jsdelivr / npm availability, so a CDN outage would eject queue entries or red unrelated PRs; the network-free half (playground-e2e-no-network) gates every PR

A merge-queue entry today: ~309 jobs, ~514 runner-minutes, wall median 22.6 min (p90 26.4), its critical path the corpus compile gates (~22 min). Adding channels-e2e and api-call-e2e puts 23 jobs / ~43 runner-minutes (+8 %) on an entry; every added job is shorter than that critical path.

The exit metric is enforced, not asserted. No runtime leg is post-merge-only: every one runs per-PR, in the queue, or both, and merge-queue-readiness.test.ts → "post-merge net" pins the shape that replaced the old push: main re-runs (see "Post-merge net").

Kept out, with dated reasons (2026-10-05):

B7 — javac now runs -Xlint:all -Werror in both java compile legs (java-build.yml and corpus-build.yml's corpus × java), through test/e2e/support/java-werror.init.gradle (an init script, so the GATE is strict while a user's generated build.gradle.kts is not forced onto -Werror). Measured first on all 37 java-build fixtures: four lint classes, each fixed at its emitter — serial (the six emitted exception classes — both DomainException variants — and the api-client's RemoteCallException get a serialVersionUID), try (the unreferenced execution-frame and OTel-scope resources became Java 22 unnamed _ resources), deprecation (Jackson 3 asText()/isTextual() → asString()/isString(), which the 3.1.4 jar compiles the old names down to) and rawtypes (swagger-core's raw Schema.getProperties(), suppressed on the one method that must spell it). After: 37/37 fixtures and 95/95 corpus features clean under -Werror (JDK 25, Gradle 9.7.1, run locally before the wiring). test/system/java-compile-strictness.test.ts pins the install step in both workflows and the script's two flags.

pr-gate cycle time. Before (this head's base, last 22 merged PRs whose final pr-gate was success): cycle = first check-run start on the PR's final head SHA → that SHA's last pr-gate check-run completion — median 24.5 min, p90 45.3 min; tail = last non-pr-gate check completion → the pr-gate verdict — median 0.4 min, p90 1.3 min. The slowest single check on a broad PR is corpus × tsc (Hono/node) at 17–23 min, and every leg this packet added per-PR is under 3.3 min a job. After is measured the same way over the first 20 PRs merged once this lands, plus the queue-entry figures above re-taken over the first 10 merge groups that carry channels-e2e; the result goes in the wave log, and a cycle p90 above ~45 min under load is the revert signal for migration-evolution-e2e's per-PR block.

The pr-gate check — still the per-PR aggregate

Superseded premise, 2026-09-07. This section used to open with "GitHub offers merge queues only on organization-owned repositories, so while this repo lives under a personal account the queue cannot be switched on." The repo was transferred to the Loom-Harness organization that day and the merge queue is now enforced (a direct merge is refused with "405 — Changes must be made through the merge queue"). pr-gate is not obsolete — it is the thing that makes each PR's own head green, and that is what the trim below actually rests on (NOT on the queue's "Require all queue entries to pass required checks" setting, which is currently off; see the runbook).

Plain required-status-checks still can't substitute for pr-gate per-PR, because every PR workflow here is path-filtered: a required check that gets path-skipped never reports, and the PR blocks on "Expected — waiting for status" forever. A docs-only PR would strand on all of them.

pr-gate.yml was the answer to that, and it still earns its place now that the queue is on: it is the per-PR verdict, computed in seconds, over whatever actually triggered on your head SHA. The queue gates the rebased combination; pr-gate gates your branch before you get there, so you learn about a red check without spending a queue slot on it. One aggregate check that branch protection can require safely. It is event-driven (v2): the v1 design was a single long-polling job, and under real load it fed on itself — each open PR's gate parked a runner slot while polling (six parked gates ≈ a third of the ~20-slot pool), starving the very jobs it waited for until its timeout fired and needed a manual label re-arm. v2 waits in exactly one place, and only when waiting is cheap:

Why the gate parked, measured once (2026-09-10)

Seven PRs in ten days offered contradictory explanations for one symptom — a fully green PR whose merge is refused with "Required status check pr-gate is expected", which is what GitHub says when the required check sits at in_progress. The measurement below replaced all of them; do not add a new theory to this section without re-measuring the same way.

The method: list this workflow's runs unfiltered (/actions/workflows/pr-gate.yml/runs?created=<window>) and match on time. Do not filter by branch — a workflow_run-triggered run is attributed to the repository's default branch, so a listing filtered to a PR branch returns only the one pull_request-event run and structurally cannot see an evaluation (F65 in docs/audits/2026-09-09-verification-fleet-plan.md).

Which earlier work that does and does not indict. F65 is a true statement about branch-filtered listings in general; it is not true of #2835, whose #2819 diagnosis listed event=workflow_run runs unfiltered and read them correctly. Its table — last evaluation created 05:48:26, last check completed 05:49:10, "evaluations created after: none" — is the same observation this census reproduces at scale, and its conclusion ("this was delivery, not a missing name") was right. #2835 was right on the mechanism and incomplete on coverage: the sweep it built maps /pulls?state=open to head SHAs, so a gh-readonly-queue/** head is structurally invisible to it, and its own motivating case — the pr-2738 group, 42 minutes all-green, merged 30 seconds after one manual re-run — is exactly the case that sweep cannot reach. The tail watch is what closes that half.

what was measured result
eligible completions vs evaluations, 2026-09-10T10:00–16:00Z 178 completions of listed workflows on non-main branches → 172 PR gate runs; 13 completions produced no run at all — not cancelled, not skipped: never created
the shape of the drops multi-minute windows, not isolated events (14:09–14:18, 15:22–15:31, 15:56–15:58). 9 of the 13 were on gh-readonly-queue/** refs, against 71 of the 178 completions (40%) — over-represented, though n=13 is too small to call it more than that
parks, last 30 merged PRs of 22 whose gate never went red first, 10 parked ≥5 min fully green; #2846 14 min, #2845 43 min, #2819 46 min, #2674 58 min
each park's cause exactly one missing dispatch. #2819: last check completed 05:49:11Z, last evaluation created 05:48:26Z, zero PR gate runs repo-wide until 06:35:29Z — one eligible completion in that window, no run for it
tail size at the last delivered evaluation outstanding checks: median 1, max 7. Minutes from it to the last completion: median 1.2, 9 of 10 within 5, max 16.9
the cancellation theory (#2822) not the cause. 479 of 759 workflow_run evaluations in 21 hours are still cancelled after cancel-in-progress: false — GitHub evicts a superseded pending run regardless of the flag — but a sample of 15 had zero jobs: they cost no runner, and the newest arrival, which is the tail one, is never the evicted one
the read-after-write race theory not the cause. An evaluation dispatched by a completion reads the check-runs API strictly after it
the cron six consecutive schedule runs gapped 2.0, 4.5, 4.6, 4.5 and 3.6 hours against a */15 request

So there is one mechanism: workflow_run delivery drops ~7% of dispatches, and a drop on a SHA's last completion leaves nobody to re-evaluate it. The tail watch is the remedy, and it is mutation-proved in test/system/pr-gate.test.ts — the CONTROL arm replays #2819's timeline through the pre-fix code path and shows the only verdict ever published is in_progress.

Two claims this measurement deleted rather than qualified:

The tail watch is also the in-queue backstop, which nothing else is. A merge-queue head receives evaluations on the same two paths a PR head does — the merge_group: checks_requested arm when the group forms, and workflow_run completions from inside the group (branches-ignore lists only main, deliberately) — and the single-SHA path in scripts/pr-gate.mjs is shared, so a queue head arms and runs the watch identically, bounded to one watcher per SHA by the same SHA-keyed concurrency group. The sweep cannot do this: it enumerates open PRs, and a queue ref is not one.

One residual, stated rather than fixed: the formation evaluation cannot arm the watch. At checks_requested no check has reported yet, and shouldWatchTail requires pending < total — the conjunct that stops the watch parking a runner from PR-open, which it would otherwise do on every PR. So an in-queue head needs at least one workflow_run dispatch to land while ≤8 checks are outstanding. If every one of them is dropped, the group parks and its only bound is the queue's 180-minute checks timeout, which ejects rather than heals. With 24 gates wired into the queue there are ~24 chances for one to land, but that is a probability, not a guarantee — if in-queue parks survive this change, that is the gap to close, and the fix is a formation-time arm with its own budget, not another sweep.

The recovery levers, unchanged: re-run the pull_request-event PR gate run for that head (cheapest — it re-evaluates the same SHA and costs no other CI), or push a new SHA (restarts ~50 workflows).

Branch protection requires exactly two checks: tests passed and pr-gate. Everything else stays non-required by name but becomes binding through pr-gate the moment it triggers.

Now that the queue is on, these two are the entry bar — what a PR must clear to be enqueued — and the queue's own required set (the manifest below) is the landing bar, evaluated on the rebased candidate. Keeping pr-gate required is deliberate: dropping it would let a PR that is red on its own branch consume a queue slot and a full heavy run before being ejected.

Unverified from here: the contents of the branch-protection required list are repo settings, not something any file in this repo can assert. If you are auditing, read them in Settings → Rules and reconcile against test/system/merge-queue-required-checks.ts.

Draft PRs and the runner queue

The account's GitHub-hosted runner pool allows ~20 concurrent jobs (Free plan), and a substantive PR push fires 30–50 jobs across the fan-out. With this repo's claim-first culture — every PR starts as a draft and pushes repeatedly while in progress — draft pushes were the bulk of the queue load, and the queue was the bulk of CI latency (jobs have sat queued for an hour before starting).

So the fan-out is draft-gated: on a draft PR only the seconds-cheap lane runs — langium-generated, workflow-lint, and pr-gate (which waits on whatever ran). Drafts are train members (docs/agent-runbook.md → "Trains"): their authors run the suite locally and the train PR runs it once, so test.yml is draft-gated too — a draft cannot merge, so its skipped required check strands nothing. Every other per-PR workflow carries

on:
  pull_request:
    types: [opened, synchronize, reopened, ready_for_review]
jobs:
  <entry-job>:
    if: github.event_name != 'pull_request' || github.event.pull_request.draft == false

Marking the PR ready for review fires the full fan-out (that's what the ready_for_review type is for), and every push after that runs it too. The if sits only on entry jobs — needs:-chained jobs cascade-skip, and the <stem>-passed rollups treat skipped needs as OK, exactly as in the merge queue. pr-gate also triggers on ready_for_review, so its verdict always covers the full set. Drafts can't merge anyway, so nothing is lost — "ready" means "now spend the fleet on me."

The label-gated heavy workflows (run-tenancy, run-channels, …) are deliberately NOT draft-gated: applying the label to a draft is an explicit request and still works. The converse is why a promotion must drop labeled from types: rather than keep it alongside the new pull_request types — test/system/draft-gate.test.ts classifies a workflow carrying labeled: as label-opt-in and stops requiring the guard, so a half-promoted file rejoins the draft fan-out unchecked.

One deliberate side effect of the slot economy: test.yml's web-tsc job was folded into its lint job (lint + web-tsc) — each half was ~1 minute of mostly-install, and a runner slot is the scarce resource here, not wall-clock. The playground typecheck + DDL guard thereby joined the tests passed rollup, which only makes the floor stricter.

Enabling the merge queue (the structural fix)

A merge queue runs the required checks on the rebased merge candidate before it lands, so the exact combination that will be on main is what gets gated — this is what closes the "never ran on the PR" hole for the push-only gates without charging every push.

What the queue requires, and what it does not

Branch protection requires exactly two names — tests passed and pr-gate (verified against the repo's settings, 2026-09-08). There is no 22-name required list configured, and nothing here should be read as saying there is: merge-queue-required-checks.ts describes which gates are WIRED into the queue, not which names branch protection waits on.

That distinction is easy to lose because it does not change what is binding. pr-gate fails on any non-passing check run present on the head SHA, so every gate that RUNS in a merge group gates it, required by name or not. Which is exactly why the lever is the trigger and not the manifest: 24 gates now carry merge_group: and the other 18 do not, so those 18 neither run nor cost a runner slot in the queue. Marking them "not required" would have changed nothing on its own.

The split is read off the workflows rather than judged, and merge-queue-readiness.test.ts ratchets it BOTH ways: a queueRequired: true row must carry the trigger, a queueRequired: false row must not.

The queue exists to catch one thing per-PR CI structurally cannot: two PRs each green against their own base and red combined. The live instance is #2739 adding ?? to the grammar while #2761 pinned ?? as a parse error — each green alone, main red on the merge. tests passed caught it. No per-backend docker boot could have.

Every gate here carries pull_request:, so the trigger says nothing. The if: guard on the required job decides it, and there are exactly two idioms:

guard meaning in the required set?
github.event_name != 'pull_request' || draft == false runs on every non-draft PR and again in the queue no — the queue run is a re-run; the entry's own head already ran it, and pr-gate (a required check) is green only if it passed
github.event_name != 'pull_request' || <run-* label> needs a label on a PR; merge_group is not pull_request, so the queue is its only run yes — dropping one deletes the coverage rather than saving cost

So the 18 excluded are every docker-booting per-backend leg (8 behavioral-*, 5 *-obs-e2e, 4 native *-oidc-e2e) plus the pages build. The five kept on the second row are tenancy-e2e (its non-flat cells), channels-e2e, api-call-e2e, elixir-oidc-compose-e2e and auth-oidc-compose-e2e — the post-merge blind spot named at the top of this file. Trimming for cost must never reach them, and merge-queue-readiness.test.ts enforces that: it reads each job's guard (following needs for rollups, whose own if: is !cancelled()) and fails if a runs-on-every-pr waiver is really label-guarded, or a queueIsOnlyRun row is really draft-guarded. migration-evolution-e2e is the one gate on neither row: draft-guarded and path-scoped per-PR, and still required in the queue as the full-tree run.

The cost being accepted: a PR-interaction bug that manifests only in a booted per-backend stack can now reach main. Judged unlikely — an emitter collision bad enough to break a booted backend would almost certainly break corpus-build-passed (corpus × 5 backends) or headless behavioral first — but not zero. If it happens, the fix is to promote that one gate back, not to restore all 18.

Status: LIVE since 2026-09-07.

Every workflow in the required set (a) carries a merge_group: trigger, (b) exposes exactly one stable check name suitable for branch-protection "required status checks", and (c) behaves correctly on a merge_group event. The queue is switched on and enforced; the runbook below is kept as the record of how it was configured and how to change it.

The set is written down once, in test/system/merge-queue-required-checks.ts, which is the source of truth — the table below is a rendering of it. test/system/merge-queue-readiness.test.ts (fast suite) asserts against the real workflow files that every entry exists, has merge_group:, resolves to a real job, and — for rollups — is always() over a non-empty needs. Drift is a red per-PR test today rather than a stalled queue on flip day.

Two shapes of check name:

The required-checks list (40)

Per-PR lane — cheap, already runs on every push:

Workflow Required check name
test.yml tests passed
langium-generated.yml check
workflow-lint.yml workflow-lint
hono-build.yml build-generated-ts
dotnet-build.yml build-generated-dotnet
java-build.yml build-generated-java
python-build.yml build-generated-python
elixir-vanilla-build.yml elixir-vanilla-build-passed
corpus-build.yml corpus-build-passed
corpus-elixir-build.yml corpus-elixir-build-passed
generated-react-build.yml generated-react-build-passed
generated-vue-build.yml generated-vue-build-passed
generated-svelte-build.yml generated-svelte-build-passed
generated-angular-build.yml generated-angular-build-passed
generated-feliz-build.yml feliz-build
generated-flutter-build.yml flutter-build
conformance-parity.yml parity
behavioral-e2e.yml behavioral

Queue-heavy lane — the docker/boot gates. Several of these are no longer label-only (the behavioral-e2e-* legs, the five *-obs-e2e, the four native *-oidc-e2e and pages fire per-PR path-scoped); they are listed here because the queue must run them unconditionally on the rebased candidate, not only when a path matches:

Workflow Required check name
behavioral-e2e-dotnet.yml behavioral-dotnet
behavioral-e2e-java.yml behavioral-java
behavioral-e2e-python.yml behavioral-python
behavioral-e2e-elixir.yml behavioral-elixir
behavioral-e2e-dapper.yml behavioral-dapper
behavioral-e2e-mikroorm.yml behavioral-mikroorm
behavioral-ui-e2e.yml behavioral-ui
behavioral-heex-ui-e2e.yml behavioral-heex-ui
tenancy-e2e.yml tenancy-e2e-passed
channels-e2e.yml channels-e2e-passed
api-call-e2e.yml api-call-e2e-passed
hono-obs-e2e.yml hono-obs-e2e
dotnet-obs-e2e.yml dotnet-obs-e2e
java-obs-e2e.yml java-obs-e2e
python-obs-e2e.yml python-obs-e2e
elixir-vanilla-obs-e2e.yml vanilla-obs-e2e
hono-oidc-e2e.yml hono-oidc-e2e
dotnet-oidc-e2e.yml dotnet-oidc-e2e
java-oidc-e2e.yml java-oidc-e2e
python-oidc-e2e.yml python-oidc-e2e
elixir-oidc-e2e.yml elixir-oidc-compose-e2e
auth-oidc-compose-e2e.yml auth-oidc-compose-e2e
migration-evolution-e2e.yml migration-evolution-e2e-passed
pages.yml pages-passed

tests passed, not tests-passed. tests-passed is the job id in test.yml; the check-run name GitHub reports is the job's name:, which is tests passed. Branch protection matches the check-run name. Paste the name from this table, and do not rename that job — it is the only required check main has today.

Everything not in these two tables stays out of the queue — conformance-full, differential-report, the k8s-* gates, the generated-*-e2e SPA smokes, playground-*, frontend-fullstack-e2e, generated-a11y, phoenix-ui-e2e, elixir-vanilla-vo-e2e, ci-red-alarm, cleanup-artifacts, email-e2e, context-integration-e2e. They must not be added to required checks (a required check with no merge_group trigger stalls the queue forever). Note that "out of the queue" is not "out of the PR": the four generated-*-e2e smokes, elixir-vanilla-vo-e2e and phoenix-ui-e2e run per-PR path-scoped and are binding through pr-gate — they simply have no merge_group: trigger, so they cannot be required names. Pulling one in is the recipe below.

The activation runbook (repo settings — the only remaining step)

Nothing below is code; it is an admin action on github.com/Loom-Harness/Loc.

  1. Do not remove tests passed at any point. It stays required from the first click to the last, so there is never an unprotected window.
  2. Settings → Rules → Rulesets (or Settings → Branches → branch protection rule for main, if the repo is still on classic protection). Target branch: main.
  3. Enable Require merge queue. Configuration in force, and why:
    • merge method: Squash (matches how main lands today);

    • build concurrency: 1 (2026-09-08, after 5 → 3 → 1 in one day). Speculative groups are only worth their cost when the pool has slack, and this pool does not. Each extra concurrent group runs the WHOLE merge_group gate set, so at 3 a three-PR batch costs ~120 jobs on top of every open PR's own checks — and one open PR here routinely spawns 200+.

      Measured, not inferred: at concurrency 3 on 2026-09-08 the queue built three speculative prefixes ([A], [A,B], [A,B,C]) for 93 minutes and merged nothing; 300 workflow runs were queued against 13 running, and 23 of the batch's own jobs had not STARTED 93 minutes after creation. Every group that succeeded earlier the same day, at lower load, finished in 35-45 minutes. Starvation, not slow tests — and a starved group eventually ejects on the queue's timeout having merged nothing, which is how #2786 and #2804 were lost that afternoon.

      Raise it again once the queue set is 22 rather than 40 (the trim in this PR): three prefixes then cost ~66 jobs instead of ~120, and the arithmetic changes. Re-measure the queued-vs-running ratio before and after rather than assuming.

    • minimum group size 3, maximum 3, wait 10 min. Batching is not just throughput here: a group RE-FORMS whenever the PRs ahead of it change, restarting its whole gate set, and this repo lands PRs from parallel agents continuously. On day one a single PR went through four successive entries in three hours, two of which had already passed tests passed when they were discarded. Grouping PRs into one entry is what stops that churn.

    • "Require all queue entries to pass required checks": off, and leave it off for now. It is NOT what makes the trim below sound — that was this doc's claim and it was wrong. What makes the trim sound is pr-gate: a PR reaches the queue through auto-merge, auto-merge waits on the required checks, pr-gate is one of them, and pr-gate is green only when every check that ran on the PR's head passed — the 18 trimmed gates included. So the entry's own head is verified either way.

      What enabling it would buy is FAILURE ISOLATION: with it off, only the group's head commit must pass, so one bad entry fails the whole batch and GitHub bisects to find it. What it COSTS is the part that decides the question, and it is easy to get wrong because pr-gate looks cheap: each evaluation takes seconds, but as a REQUIRED CHECK it does not go green until everything else on that SHA has finished — it reports in_progress until then, so its wall-clock is the slowest gate in the set. Requiring it per entry therefore means running the WHOLE merge_group gate set on every entry's intermediate commit — up to 5× per group at maximum size, not one extra fast-suite run — which cancels most of the reason to batch.

      The cheap version of the same protection is a smaller maximum group size, and that is what is configured: max 3, lowered from 5 on 2026-09-08. Still one gate-set run per batch, but a failure implicates three PRs rather than five — which matters here, because the common batch failure in this repo is not a bad PR but two PRs that conflict with each OTHER, and today produced four such pairs in a day. Revisit enabling the setting once the queue set is 22 rather than 40 and the docker behavioural legs — the long pole — are out of it; per-entry validation is affordable then.

  4. Enable Require status checks to pass. The repo requires two names today (tests passed, pr-gate), which is sufficient because pr-gate aggregates everything that ran. Requiring the 24 by name instead is the stricter alternative — it stops a dropped/renamed workflow from going unnoticed — and if you take it, add exactly the 24 names marked queueRequired: true in the manifest. Add them by pasting the name — the search box only offers checks GitHub has seen recently.
  5. Save. From then on, PRs merge via the queue: GitHub builds a rebased candidate, runs the 24 required checks on it, and lands it only if they are green.
  6. Watch the first day. A check that never reports leaves candidates pending — if that happens, the cause is a missing merge_group: trigger or a mistyped check name. npx vitest run test/system/merge-queue-readiness.test.ts re-verifies the workflow half in ~1s; the mistyped-name half is settings-only.

To pull a further gate into the queue later: add merge_group: to its on: block, give it one stable check name (a <stem>-passed rollup if it is a matrix), add the row to test/system/merge-queue-required-checks.ts — the readiness test will then fail until the workflow matches — and add the name to the required-checks list in settings.

Scriptable alternative. The same configuration can be applied as a repo ruleset via gh api --method POST /repos/lemmit/Loc/rulesets with a merge_queue rule plus a required_status_checks rule whose required_status_checks[] are the 24 required names above. It is the reproducible path and worth capturing once the settings are stable, but the UI path is primary: ruleset JSON silently accepts check names that do not exist, which is the one mistake that stalls the queue.

The wait window has to cover the SLOWEST check, not the average

Measured 2026-09-08. With the window at GitHub's 60-minute default, main sat at one commit for roughly four hours: entries for #2786, #2804, #2792 and #2766 each formed, ran the ~40-check heavy set, and were removed without merging — then the next entry re-formed on the same base and re-rolled the same dice. Two independent causes, and it takes both to explain the rotation:

  1. A required check went red. java-obs-e2e failed on two candidates — a docker probe that read its own timeout as "docker is missing" (fixed in #2816). scripts/pr-gate.mjs also fail-closes a cancelled run into red, which took a third candidate whose every step had concluded success.
  2. A fully green group ran out of window. On a saturated pool the queue times are not the job times: measured on one group, generated-angular-build was queued 86 min and elixir-vanilla-build 51 min, both against a 60-minute limit. Nothing was failing; the candidate simply could not finish being checked in time.

Raising the window to 120 minutes cleared it — the very next entry merged. So size the window against the slowest check's queued + run time under load, not its runtime on an idle pool, and re-measure it whenever the required set grows. Trimming that set is the other half of the same lever.

Symptom to recognise: the gh-readonly-queue/main/pr-* ref keeps changing PR number while main's SHA does not move. Enumerate the merge_group runs on each departed ref — if their conclusions are all success, it was the window, not a gate.

Three merge-group behaviours worth knowing

Reading a check that did not run

Tested vs shipped: where a gate does not run the shipped artifact

A gate only proves the artifact it builds. The known places where the tested artifact is not the shipped one:

When a bug class keeps reaching main, check this list before adding a test: the usual cause is that the gate built something other than what ships.

Force a post-merge gate with a label

The push-only gates are invisible on a PR — the queue catches them on the candidate, but only after you enqueue, so a surprise there costs a full heavy run and an ejection. To see one before that, use a label trigger: each of those gates carries a pull_request: types: [labeled] trigger plus a job-level if that runs the job only when a specific label is present. Add the label to a PR and the otherwise-post-merge gate runs against that branch before merge.

The label names a feature / blast-radius, and one label fires every backend of that feature at once — agents reason about what they touched ("I changed the OIDC emitter"), not about the workflow-file inventory. So there is no per-workflow label (run-hono-oidc), and no single mega run-e2e grab-bag — the size that matches the blast radius is one label per feature family:

Label Fires Notes
run-obs — No longer bound to anything. The five *-obs-e2e legs are per-PR path-scoped since wave G1; the label drives no workflow and applying it does nothing.
run-oidc elixir-oidc-e2e + auth-oidc-compose-e2e The two compose OIDC legs only. The four native legs (hono/dotnet/java/python-oidc-e2e) are per-PR path-scoped since wave G1 and no longer answer to the label.
run-tenancy tenancy-e2e's non-flat cells (hierarchy, the mikroorm/dapper adapter cells, org-context, subtree EXPLAIN) The five flat cells run per-PR path-scoped. The label is read on the NEXT push (contains(labels) on synchronize) and only on a PR the workflow's paths: match; the merge queue runs every cell regardless
run-migration-e2e — No longer bound to anything (2026-10-05). migration-evolution-e2e runs per-PR path-scoped and in the queue.
run-conformance conformance-full cross-backend runtime conformance
run-channels channels-e2e cross-deployable eventing — an earlier signal only: the merge queue runs every cell since 2026-10-05
run-api-call api-call-e2e typed in-system call between deployables — likewise run by the merge queue since 2026-10-05
run-differential differential-report the nightly all-pairs DISCOVERY sweep over the wider compose stack. The enforcement half is no longer here: since M-T9.11 slice (c) each backend diffs its recorded responses against test/behavioral/wire-golden/ inside its own behavioral-e2e*.yml leg, so runtime-value parity is a per-PR blocking gate needing no label
run-e2e playground-e2e elixir-vanilla-vo-e2e left this cluster in wave G1 and phoenix-ui-e2e on 2026-10-05 (both per-PR path-scoped); the label now drives the playground suite alone.
frontend-fullstack frontend-fullstack-e2e non-React fullstack round-trip
a11y generated-a11y axe-core WCAG-AA scan
e2e-k8s k8s-e2e kind-cluster smoke

The job if is uniform: github.event_name != 'pull_request' || github.event.label.name == '<label>' — so push, merge_group, and workflow_dispatch always run; a PR runs the gate only when tagged with that exact label. (tenancy-e2e spells the same rule as contains(github.event.pull_request.labels.*.name, 'run-tenancy') on its non-flat job, because that file is per-PR for its flat cells and so fires on synchronize, not on labeled.) Concurrency on these files is keyed so that a labeled PR run never collides with or cancels a main run — some use github.ref, some github.event.pull_request.number || github.ref; read the file rather than assuming one shape. A promoted gate is different: it must use the PR-aware key with cancel-in-progress: ${{ github.event_name == 'pull_request' }}, so a superseded PR push cancels its own in-flight run instead of leaking a runner slot, while scheduled runs on main always finish.

This is a manual pre-merge check, not a replacement for the merge queue above — the queue is the structural fix; labels are the interim "80/20." When you add a new post-merge gate, wire it to the matching run-<feature> label (or mint a new one) and add a row here + in CLAUDE.md.

Sizing a job's timeout-minutes

A timeout is not a flake. When a job is cancelled at its cap, nothing misbehaved — the budget was set below the work. Re-running it is not a diagnosis, and on a saturated pool it costs another full slot to re-roll the same dice. So a budget is a measurement, not a round number.

The rule

timeout-minutes = max(10, ceil_to_5min( p95(job execution time) × 1.5 ))

over at least 10 recent successful runs of that job. 50 % margin over the p95 covers a leg's own run-to-run variance with room left, and the round-up to 5 minutes keeps the value readable without inviting a fresh guess.

Three things the definition has to pin down, because each of them was got wrong here at least once:

Re-deriving it

GITHUB_TOKEN=<token with actions:read> node test/behavioral/ci-budget-report.mjs

prints, per behavioral leg: n, median, p95, max, the declared budget, the headroom at max, the budget the rule asks for, and a loud CAP KILLS warning when the sample is censored. Re-run it when a leg starts getting killed, when the corpus case list grows materially, or when a backend's build changes.

Raise, or split?

Both cost something, and the trade is not symmetric:

Prefer raising unless the leg is slow enough that its own variance no longer fits any sane budget. A leg that is slow is a different problem from a leg that is tight-budgeted, and it wants a different fix — the budget stops the bleeding, cutting the runtime is the repair.

The 2026-10-05 re-measure (current)

Re-run after the behavioral corpus grew by the wave C3 drains, when behavioral-dotnet was killed at its 15m cap on a PR adding six fixtures — on main alone it had reached a 13m51s max against that cap. Job-execution seconds from the Actions API, n=20 successful runs per leg:

leg job median job p95 job max was now
behavioral-e2e (node) 3m49s 3m57s 4m05s 10m 10m
behavioral-e2e-python 5m41s 6m25s 7m29s 10m 10m
behavioral-e2e-java — — — 30m 30m ‡
behavioral-e2e-dotnet 10m19s 13m48s 13m51s 15m 25m
behavioral-e2e-dapper 9m18s 13m02s 13m06s 15m 20m
behavioral-e2e-mikroorm 13m18s 15m05s 15m09s 20m 25m
behavioral-e2e-elixir 10m18s 12m10s 13m03s 20m 20m

‡ not re-sampled — the report returned no java sample on this run; the 2026-09-10 figure below still stands, and java's recent runs (~15m) sit well inside it.

The 2026-09-10 baseline (superseded — java still rests on it)

All seven behavioral legs, measured at step level from the Actions API (behavioral-java at n=40, the rest at n=15):

leg test-step median job p95 job max was now
behavioral-e2e (node) 2m47s 3m24s 3m25s 15m 10m
behavioral-e2e-python 3m41s 4m37s 4m37s 15m 10m
behavioral-e2e-java 17m56s 19m46s † 19m50s 20m 30m
behavioral-e2e-dotnet 7m08s 8m19s 8m19s 20m 15m
behavioral-e2e-dapper 6m00s 7m14s 7m18s 20m 15m
behavioral-e2e-mikroorm 9m22s 10m35s 10m35s 25m 20m
behavioral-e2e-elixir 10m14s 11m27s 11m34s 30m 20m

† censored — see the third bullet above.

Six of the seven were over-provisioned by 57–77 %, which is its own cost: an over-wide cap is how long a hung job squats a slot. Fixing the class rather than the one leg that was visibly bleeding therefore pays for itself — worst-case slot exposure across the tier drops from 145 to 120 minutes even though Java goes up by 10.

behavioral-java remains the outlier that a budget cannot fix: 17m56s median in the test step against 6m–10m for every other backend leg, because run-java.mjs pays a gradle --no-daemon bootJar — cold JVM, full Spring compile — per corpus case, sequentially. Its run-to-run spread is 13m02s–18m49s; that 5m47s swing alone was 35× the 10s of headroom the old cap left, which is why the kills read as random. Cutting that runtime is a separate mission.

Guardrails added alongside

[historical] If the merge queue is too big a lift right now — the 80/20, and where it got to

Superseded — the queue is on. Kept because the reasoning still applies to any gate you are deciding whether to promote into the per-PR lane.

The 80/20 without a queue is to give a post-merge gate a pull_request: trigger scoped to its real blast radius (not the full matrix), so the common breakers are caught pre-merge. It costs per-push CI time — the queue is still the better answer — but it closes the holes, and it needs no repo-settings change: binding is emergent, since pr-gate fails on any non-passing check run on the head SHA and a path-skipped workflow produces none.

Done. The behavioral cross-backend legs and pages went first; wave G1 of docs/new-plan/verification-waves-2026-09.md then promoted the five *-obs-e2e, the four native *-oidc-e2e, the four generated-{react,vue,svelte,angular}-e2e SPA smokes and elixir-vanilla-vo-e2e — each with a narrow paths: block, the literal draft guard, and PR-aware concurrency. §3 of that plan is the checklist a further promotion should follow (no YAML anchors in paths:; drop labeled from types:; the guard string is matched verbatim by test/system/draft-gate.test.ts; no pr-gate.yml edit).

Remaining, as of 2026-10-05 — wave C3 packet 3e re-measured each row (see "The never-gating legs, re-measured (2026-10-05)"); what moved:

Was post-merge / label Now
tenancy-e2e the five flat cells per-PR path-scoped (a split job, not an event-conditional matrix: a job if: cannot read matrix); the rest stay label + queue
migration-evolution-e2e per-PR path-scoped and still in the queue; measured at ~10 runner-minutes a firing, not the 35-minute cap
channels-e2e, api-call-e2e in the merge queue (merge_group: + a -passed rollup); the labels remain an earlier signal
phoenix-ui-e2e per-PR path-scoped — #2718 closed 2026-09-09 and 30/30 first-attempt passes on main
elixir-oidc-e2e, auth-oidc-compose-e2e unchanged: in the merge queue (label-guarded per-PR). Not per-PR because both build images inside the runner (Phoenix release / generated compose stack), which is the cost the narrow-paths answer does not fix