chore(#3833): gate every PR compute lane behind a mergeability preflight (#3843)

* test(#3833): failing-first suite for the PR mergeability preflight

* chore(#3833): gate every PR compute lane behind a mergeability preflight

* fix(#3833): assert the preflight gate on parsed yaml and guard status-function if

* fix(#3833): run stub-backed cli tests in-process to avoid a spawnsync deadlock

* fix(#3833): fail the preflight open when its script is absent at the base sha

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-24 21:13:55 -04:00
committed by GitHub
parent 63abcface9
commit 6905726d9c
15 changed files with 1655 additions and 26 deletions

View File

@@ -350,6 +350,99 @@ Reported paths are labelled `CREATED`, `MODIFIED`, `DELETED`, or `UNVERIFIED`.
`UNVERIFIED` means a scan bound was hit and the path could not be attested
either way — it is never the same as clean.
## The mergeability preflight
Before any of the matrix below is provisioned, every `pull_request` compute lane
waits on one shared gate: **`PR mergeability`**, the reusable workflow
`.github/workflows/pr-mergeable-preflight.yml`. **A pull request with a merge
conflict runs no CI at all until the conflict is resolved** (#3833).
### Reference
| Verdict | When | Job result | Effect on the pipeline |
|---|---|---|---|
| `MERGEABLE` | GitHub reported `mergeable: true` | success | everything runs as normal |
| `CONFLICTED` | GitHub reported `mergeable: false` | **failure** | every gated job is skipped; `Required tests` and `Stryker mutation score` report **red** |
| `INDETERMINATE` | mergeability still unknown after the retry budget, or the API read failed | success **(fails open)** | everything runs as normal; a `::warning::` is emitted |
| `SKIPPED_NOT_A_PR` | the event is not `pull_request` (push, `workflow_dispatch`, a `release.yml` call into `install-smoke.yml`) | success | everything runs as normal; **zero** API calls |
| `INDETERMINATE` (bootstrap) | `scripts/ci-pr-mergeability.cjs` is absent at the base sha | success **(fails open)** | everything runs as normal; a `::warning::` names the cause |
The bootstrap row is a consequence of the checkout being pinned to the base sha:
the preflight runs the script **as it exists on the base branch**, so the script
is absent on the pull request that introduces it, and on any branch whose base
predates it. Absent is not "conflicted" — it is one more thing the gate cannot
determine, so it takes the same fail-open path. The arm is self-healing and
never fires again once the script is on the base branch; a test pins it in place
so a future reader does not mistake it for dead code.
The verdict comes from `scripts/ci-pr-mergeability.cjs`, which polls
`GET /repos/{owner}/{repo}/pulls/{number}` until `mergeable` is non-null.
GitHub computes mergeability in an **asynchronous background job** and returns
`null` while it is in progress, so a single cold read is never authoritative —
the poll is required, not defensive.
Two properties are load-bearing and easy to break:
- **Only `mergeable === true` / `=== false` decides the verdict.** `null` is
falsy, so a truthiness test (`if (!mergeable)`) would classify every cold read
as a conflict and red every PR in the repo.
- **`mergeable_state` never decides the verdict.** Its `blocked` value means
"required checks have not passed", which is true *while this very job is
running* — gating on it self-deadlocks the pipeline. It is read for the
failure message only.
Gated: `test.yml` (`lint-tests`, `test`, `test-inert`, `test-full`,
`coverage-gate`, `qa-loop-walk`, `required-tests`), `install-smoke.yml`,
`mutation.yml`, `security-scan.yml`, `docs-required.yml`,
`changeset-required.yml`, `default-flip-documentation.yml`, `branch-naming.yml`.
**Deliberately not gated:** the `pull_request_target` policy and security lanes —
auto-close-unsolicited-PRs, close-draft-PRs, the target/title/template
validators, issue-link, and unauthorized-approval dismissal. Gating those would
let a conflicted drive-by PR evade auto-close, so they keep running on a
conflicted PR and a test asserts they are never wired to the preflight.
### What it deliberately does not do
**It applies no label.** The gate does not add or remove `needs-review: merge-conflict`.
Eight caller workflows each invoke the preflight, so eight jobs would race to
add-or-remove one label on every PR event, and it would force
`pull-requests: write` into eight lanes that today hold `contents: read`.
`needs-review: merge-conflict` stays a maintainer triage label applied during PR
sweeps; the red check and its annotation are the machine signal.
**It changes no branch protection.** `.github/rulesets/main-protection.json` is
untouched and needs no new required context — GitHub natively refuses to merge a
pull request with conflicts, so the ruleset already blocks it. (Adding the check
as *required* before the workflow exists on the base branch would deadlock every
open PR until it landed.)
### What this does not replace
`scripts/ci-rebase-check.cjs` is unchanged and still runs inside each matrix job,
including its #2472 base-sha pin. The preflight is an early-exit optimization on
GitHub's asynchronously-computed view; the in-job merge remains authoritative and
covers the case where the base advances mid-run. **The gate is never a safety
property** — every path except an explicit `mergeable: false` fails open, so an
API outage simply restores the pre-#3833 behavior.
### If your PR shows a red `PR mergeability` check
The annotation names the base branch and the remedy. There is one step:
```bash
git fetch origin && git rebase origin/next && git push --force-with-lease
```
Resolve the conflicts the rebase reports, then push. The next `synchronize`
event re-runs the preflight and the full pipeline comes back. (Because the
sequence is a single command, this has no separate how-to page — see
[CONTRIBUTING.md → Where Do I Open My PR?](../CONTRIBUTING.md#where-do-i-open-my-pr-branching-model)
for the branching model that makes the rebase necessary in the first place.)
Note the interaction with the sha-bound pass marker: a rebase changes your HEAD
sha, which invalidates any prior remote-runner verification. Rebase *last*.
## CI matrix
The `Tests` workflow runs every PR through a scoped gate generated by