Sequence CI for branch-based PR stacks (#99086)
### What?
Replace the Graphite-specific CI optimizer with a **read-only TypeScript
gate** for PR stacks formed by ordinary GitHub base/head branch links.
This PR includes the work previously reviewed in #99116 and #99122.
### Why?
Rebasing a large stack starts expensive `build_and_test` jobs on every
PR at once. Let the first three PRs and the top PR run immediately, but
defer middle PRs until nearby CI provides a useful signal. A deferred PR
must **not** appear mergeable just because its jobs have not started.
### How?
- **Find the stack from branch relationships**, not GitHub stack
metadata. Middle PRs poll their three nearest predecessors every **five
minutes**. Any predecessor with a successful, current-head/current-base
`thank you, next` check releases full CI; three unsuccessful results
**fail the gate and required aggregate**. While waiting, the gate stays
in progress. After five hours without a decision, it releases full CI
rather than stranding the PR.
- **Revalidate before deciding.** Refresh PR and check data before
release/failure so rebases, retargeting and reruns cannot make a stale
check decisive. Transient GitHub 5xx/429 errors retry; permanent errors
or ambiguous topology start full CI rather than claiming CI passed.
- **Keep polling cheap and read-only.** Reuse PR head/base data from
`pulls.list` (11 → **8 REST reads** per ordinary waiting poll), while
still checking *all three* predecessors. The bundled action is checked
out at the current test-merge `${{ github.sha }}` without persisted
credentials; forks bypass the checkout/gate and run full CI. The
six-hour gate stays on `ubuntu-latest` because `ubuntu-slim` has a
15-minute limit.
<details>
<summary>Implementation and review tradeoffs</summary>
- The local action bundles a pinned Octokit. Isolated Jest tests use
fake timers and a real check-run response fixture. CI runs the existing
Next.js lint/examples/externals/browser checks before action-specific
install, typecheck, build and Jest; a generated-diff guard catches stale
bundles.
- `uses: ./...` requires action files already in the job workspace; it
does not fetch them like `owner/repo@ref`. The sparse checkout fetches
only `action.yml` and `dist/index.js`. Caller and reusable workflow
grant read-only `checks`, `contents` and `pull-requests` permissions;
the gate inherits no secrets.
- Cheap-check graph splitting was deliberately deferred. Job-level
concurrency does not enforce predecessor *success*; stopping at the
first pending predecessor misses an older success. GraphQL batching
needs a verified check-association projection and measured cost before
replacing the current REST lookup.
- When the child commits became reachable from this branch, GitHub
automatically marked #99116 and #99122 merged; no PR merge operation was
used. [Action
README](https://github.com/vercel/next.js/blob/ci/branch-stack-polling-gate/.github/actions/pr-stack-ci-gate/README.md)
documents the contract.
</details>
### Verification
- **Focused checks:** TypeScript, Prettier, deterministic bundle/diff
checks and **24/24 Jest** passed. The final comment-only change in
`src/gate.ts` produced a byte-identical bundle. The [current root
run](https://github.com/vercel/next.js/actions/runs/35967795126) passed
its gate, lint and required `thank you, next` on **attempt 2** (same
SHA); attempt 1 failed an intermittent redbox test.
- **Live six-PR test on the compiled action:** the first three and top
passed the gate immediately. Middle #99097 and #99098 **waited**. On
initial attempts, both **failed closed** when their three predecessors
were unsuccessful, with no expensive jobs started. On rerun, #99097
**released on #99086's success** despite #99096 failing and #99095 still
pending; full CI ran but failed unrelated tests. #99098 waited for *its
own* three predecessors, then failed closed again. Gate release never
marked full CI successful.
- **Known limitation:** product-test failures—not the gate—left four
immediate/released PRs with failed full CI on various attempts. All five
test drafts remain open for reuse. Workflow reruns in this experiment
were initiated externally; this agent's GitHub integration received 403
(`actions=write` required) when attempting a rerun.
<details>
<summary>Current compiled-action six-PR results, with workflow and gate
logs (2026-09-24)</summary>
Each draft is one Jest assertion commit over its signed parent; the
cumulative focused suite passes **24/24**. Times below are UTC.
“Required” refers to `thank you, next`, not the gate check.
| Position | PR / signed head / workflow | Gate outcome | Required check
| Expensive work |
| :-- | :-- | :-- | :-- | :-- |
| 1 |
[#99086](https://github.com/vercel/next.js/actions/runs/35967795126)
`85444c9e` | immediate pass | failed attempt 1; **passed attempt 2** |
ran twice |
| 2 |
[#99095](https://github.com/vercel/next.js/actions/runs/35969902709)
`bc19787a` | immediate pass | failed attempts 1 and 2 | ran twice |
| 3 |
[#99096](https://github.com/vercel/next.js/actions/runs/35970016367)
`98bfab58` | immediate pass | failed | ran |
| 4 |
[#99097](https://github.com/vercel/next.js/actions/runs/35970109573)
`e260bf96` | [attempt
1](https://github.com/vercel/next.js/actions/runs/35970109573/job/107537584401):
waited → failed 08:02; [attempt
2](https://github.com/vercel/next.js/actions/runs/35970109573/job/107609256427):
**released 11:19** | failed both attempts (attempt 2: product tests) |
none on attempt 1; full CI ran on attempt 2 |
| 5 |
[#99098](https://github.com/vercel/next.js/actions/runs/35970206069)
`01222d4a` | [attempt
1](https://github.com/vercel/next.js/actions/runs/35970206069/job/107537887530):
waited → failed 08:03; [attempt
2](https://github.com/vercel/next.js/actions/runs/35970206069/job/107609367420):
waited → failed 11:50 | failed both attempts | **never started** |
| 6 (top) |
[#99099](https://github.com/vercel/next.js/actions/runs/35970312588)
`c6f3cf4a` | immediate pass | failed attempts 1 and 2 | ran twice |
**Decision evidence:** On attempt 1, #99097 saw #99096/#99095/#99086 all
unsuccessful, and #99098 saw #99097/#99096/#99095 all unsuccessful;
their required checks failed and downstream jobs were skipped *because
their gates failed*. After #99086's successful rerun, #99097 attempt 2
logged `#99096=unsuccessful, #99095=waiting, #99086=success`; its gate
passed and [build-next actually ran and
passed](https://github.com/vercel/next.js/actions/runs/35970109573/job/107609336752).
#99098 could not use #99086 (outside its nearest-three window); it
failed at 11:50 when #99097/#99096/#99095 had all finished without
success. Completed failed gates do not restart automatically when a
predecessor reruns.
</details>
<details>
<summary>Unrelated CI failures and recovered flaky tests</summary>
- #99086's attempt 1 failed `lazy-dynamic-imports › does not parse a
dynamic import target before activation`: redbox source was `null` after
all in-job retries
([job](https://github.com/vercel/next.js/actions/runs/35967795126/job/107530613870)).
**The same-head workflow passed on attempt 2.**
- #99095, #99096, #99097 attempt 2 and #99099 failed
`instant-insights-tab-overlay › should wrap the mobile overlay header
only when it does not fit`: observed header top difference **24**,
expected **< 4**, after in-job retries ([example
job](https://github.com/vercel/next.js/actions/runs/35969902709/job/107537341995)).
#99096 also failed `turbopack-loader-file-dependencies › should update
when a build dependency changes` (`build-one` instead of `build-two`,
[job](https://github.com/vercel/next.js/actions/runs/35970016367/job/107537932737)).
#99097 attempt 2 additionally failed the `lazy-dynamic-imports` redbox
assertion in an experimental cache-components shard
([job](https://github.com/vercel/next.js/actions/runs/35970109573/job/107609732882)).
These failures are not gate-test failures.
- **Recovered in-job flakes:** #99096
`instant-validation/head-and-reporting › invalid - runtime prefetch -
dynamic viewport blocks navigation` (redbox did not open; passed retry
1/2); #99095 `use-cache-without-experimental-flag › should recover from
the build error if useCache flag is set` (Playwright execution context
destroyed on navigation; passed retry 1/2); #99097 attempt 2
`lazy-dynamic-imports` in a different Turbopack-dev shard (passed retry
2/2), `instant-validation/suspense-boundaries` (redbox did not open;
passed retry 2/2), and
`instant-validation/head-and-reporting.partial-prefetching` (redbox did
not open; passed retry 1/2).
</details>
<details>
<summary>Earlier implementation smoke tests and CJS-gate stack
runs</summary>
- The [folded-code root head `89f0ca34` full
run](https://github.com/vercel/next.js/actions/runs/35965443069) passed
gate, build, lint (including **24/24 Jest**) and required `thank you,
next`. The earlier [compiled-action child head `25647f00`
run](https://github.com/vercel/next.js/actions/runs/35922700289) also
passed full CI, proving same-SHA sparse checkout before the lint-order
follow-up was folded into root.
- **CJS-gate fail-closed run (base `ec0abf41`, 2026-09-23):**
[#99086](https://github.com/vercel/next.js/actions/runs/35875541530),
[#99095](https://github.com/vercel/next.js/actions/runs/35875598833),
[#99096](https://github.com/vercel/next.js/actions/runs/35875651890) and
top [#99099](https://github.com/vercel/next.js/actions/runs/35875952426)
ran full CI and failed unrelated tests. Middle
[#99097](https://github.com/vercel/next.js/actions/runs/35875701404/job/107231099534)
failed its gate at 15:20 and
[#99098](https://github.com/vercel/next.js/actions/runs/35875873698/job/107231827956)
at 15:21; required checks failed and expensive jobs never started. A
Turbopack source-map snapshot failure was subsequently fixed in
[#99106](https://github.com/vercel/next.js/pull/99106).
- **CJS-gate release run (base `8ff7f2bd`, 2026-09-23):**
[#99086](https://github.com/vercel/next.js/actions/runs/35887913444) and
[#99095](https://github.com/vercel/next.js/actions/runs/35887957948)
passed full CI.
[#99097](https://github.com/vercel/next.js/actions/runs/35888074168/job/107273254039)
and
[#99098](https://github.com/vercel/next.js/actions/runs/35888146874/job/107273514756)
waited, then released at the next poll on #99095's green check while a
nearer predecessor was unresolved. Their later full CI failed; top
[#99099](https://github.com/vercel/next.js/actions/runs/35888202575)
started immediately (later canceled). These precede the TypeScript
action and are not claimed as evidence for its current head.
</details>
<!-- NEXT_JS_LLM -->
<!-- fleet d2ae64f1-7106-4ba4-b88a-7785c19904c6 -->
---------
Co-authored-by: vercel-fleet-prod[bot] <318278635+vercel-fleet-prod[bot]@users.noreply.github.com>
Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>