Claude identified these as clear candidates for shrinking machine size.
Here are the runner types we have that are attributed to the OSS cost
center:

Ideally we'd have 1 and 2 core machines as well, but I don't really want
to bother IT right now.
### What?
Add the Next.js-owned Chromium-only Playwright image definition and a
workflow that builds and pushes it to GHCR.
### Why?
This needs to merge before the reusable CI workflow starts using the
image, so the GHCR tag exists when job containers are initialized.
### How?
- Add a Noble-based Chromium image with the CI build dependencies,
Playwright Chromium, and the repository's Node.js LTS version
preinstalled.
- Add a manual/canary-only publisher workflow that derives Playwright
from `package.json`, Node.js from `build_reusable.yml`, and tags images
with a Next.js-owned revision suffix.
- Keep the image revision in the publisher workflow so Dockerfile-only
changes can publish a distinct immutable tag.
### Verification
- `git diff --cached --check`
- `ruby -e 'require "yaml";
YAML.load_file(".github/workflows/playwright_chromium_image.yml")'`
- Not run: `docker buildx build --check --build-arg
PLAYWRIGHT_VERSION=1.58.2 .github/images/playwright-chromium` (Docker
daemon is not running locally)
- Not run: `pnpm prettier --with-node-modules --ignore-path
.prettierignore --write .github/workflows/playwright_chromium_image.yml
.github/images/playwright-chromium/Dockerfile` (`prettier` command is
not available in this checkout)
<!-- NEXT_JS_LLM_PR -->
By default, turbo-tasks will try to use every available CPU core.
While we do want it to use as many resources as possible if the CPU is
otherwise idle, this may hurt us when the CPU is busy, as it will lead
to more locking, more thread contention, more memory usage, etc.
**This environment variable does not strictly limit Turbopack to using 4
cores, it just restricts the number of main tokio worker threads and
changes how we shard dashmaps.**
We currently are using 16 core CI machines, but I want to experiment
with 32 core build machines with a higher `TEST_CONCURRENCY`, and the
per-core scaling of Turbopack was a concern that came up, as wall time
improvements are sub-linear starting somewhere around 16 cores.
## Test Plan
Watch CI, compare build times in this commit and the parent one.
Attempt with a value of 2: https://github.com/vercel/next.js/pull/94001
Attempt with a value of 8: https://github.com/vercel/next.js/pull/94002

I think this data is too noisy to draw any conclusions.
## Summary
Runs Linux `build_reusable.yml` jobs that need Playwright inside
Microsoft's prebuilt
[`mcr.microsoft.com/playwright:v1.58.2-noble`](https://mcr.microsoft.com/en-us/product/playwright/about)
image. The image already contains Chromium, Firefox, WebKit, and their
system dependencies under `/ms-playwright`, so Linux test shards no
longer spend each job downloading browsers and installing apt deps.
- Adds a `playwrightDockerImage` input, defaulting to the Ubuntu 24.04
(`-noble`) Playwright image so glibc matches the `ubuntu-latest` runners
that build `next-swc.linux-x64-gnu.node`.
- Adds a `usePlaywrightContainer` input so jobs can opt out.
`test-next-swc-wasm` opts out because its `afterBuild` step needs
`rustup`, which is not in the Playwright image.
- Skips `pnpm playwright install --with-deps` for Linux jobs running in
the image; Windows jobs and opted-out jobs still install Playwright at
runtime.
- Enables the pnpm store cache inside container jobs because the
container filesystem is ephemeral even when the host runner is
persistent.
### Container condition
The Playwright image is used when all of the following are true:
- `inputs.needsPlaywright != 'no'`
- `inputs.skipInstallBuild != 'yes'`
- `inputs.usePlaywrightContainer != 'no'`
- The runner is not Windows
Otherwise `container.image` evaluates to `''` and the job runs directly
on the runner.
### Compatibility fixes
The Microsoft image is browser-focused, so this PR adds a few CI setup
fixes before the existing test workflow runs:
| Concern | Fix |
| --- | --- |
| Missing `jq` / `unzip` / native build toolchain | Install `jq`,
`unzip`, and `build-essential` inside Linux container jobs. |
| Firefox refuses `$HOME` not owned by the current user | Re-own GitHub
Actions' mounted `/github/home` directory for the root user used by the
job. |
| Git sees the mounted workspace as dubious ownership | Mark
`$GITHUB_WORKSPACE` as a safe directory for Git. |
| Standalone tests bind to Docker's generated `HOSTNAME` | Set container
`HOSTNAME=0.0.0.0`. |
| `getStorageDirectory()` treats Docker storage as ephemeral | Add
`NEXT_IGNORE_IS_DOCKER=1` for CI container jobs and honor it in
`cache-dir.ts` so deterministic-build tests keep using `dist/cache`. |
| `localhost` IPv4/IPv6 mismatch in analyze tests | Let the analyze
server listen on both IPv4 and IPv6 localhost addresses. |
| Nx fixture can race in CI before Next.js starts | Disable Nx isolated
plugins for the `nx-handling` fixture scripts. |
## Timing
Earlier comparison runs showed the Playwright install/setup portion
across 82 matched Linux jobs dropping from about **3 h 03 m total** on
canary to about **39 m 24 s total** with the containerized setup,
roughly **1 m 45 s saved per job** on average. Test execution itself is
excluded from that comparison because shard balancing makes it noisy.
## Test plan
- [x] Latest PR CI run
[`26121297957`](https://github.com/vercel/next.js/actions/runs/26121297957)
completed successfully with 0 failed jobs.
- [x] Linux Playwright matrices are green, including `test dev`, `test
prod`, `test turbopack *`, `test cache components *`, `test node streams
*`, and changed-test lanes.
- [x] Linux non-Playwright jobs remain green on the bare runner,
including `lint`, `types and precompiled`, `rust check`, `test cargo
unit`, and `test unit`.
- [x] `test-next-swc-wasm` is green while opted out of the container via
`usePlaywrightContainer: 'no'`.
- [x] Windows jobs are green and continue to use runtime Playwright
installation.
<!-- NEXT_JS_LLM_PR -->
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
## Summary
Memoize passing tests across attempts of the same workflow run, so a
re-attempt after a timeout or cancellation can skip what already passed.
## What changed
- `run-tests.js` appends each passing test's filename to
`$NEXT_TEST_PASSED_FILE` as it finishes (sync `write` + `fsync`, fd kept
open across calls). On startup it reads the same file and skips any
entries that match.
- `build_reusable.yml` wraps the test step with `actions/cache/restore`
(before) and `actions/cache/save` (after, with `if: always()`). The save
step runs on success, failure, and cancel — which is the whole point: a
cancelled attempt still gets to preserve its progress for retry.
- Cache key is scoped to
`${input_step_key}-${run_id}-attempt${run_attempt}`, with `restore-keys`
falling back to any earlier attempt of the same run.
The previous implementation wrote a single Turbo-remote-cache blob
*after* `Promise.allSettled` resolved — so a cancellation (like [run
26127371192](https://github.com/vercel/next.js/actions/runs/26127371192/job/76845288867))
lost every passed test from that attempt.
This also removes one dependency on the turbo-remote cache (there are
still two more uses of `scripts/turbo-cache.mjs`)
<!-- NEXT_JS_LLM_PR -->
### What?
Converts every test under `test/integration/` to an isolated test
running through `nextTestSetup` (under `test/e2e/`, `test/production/`,
`test/development/`, or `test/unit/`), then deletes `test/integration/`
along with the legacy CI orchestration that was specific to it.
- `test/integration/` removed entirely (~327 test suites)
- New isolated suites added across the existing folders:
- `test/e2e/` — 175
- `test/production/` — 130
- `test/development/` — 43
- `test/unit/` — 1
- `.github/workflows/build_and_test.yml` and `run-tests.js` no longer
have any `integration` branches
- `nextTestSetup` gained a `baseUrl` option on `next.browser()` so a
small number of tests that drive their own proxy/static-export server
can keep using `next.browser(...)` instead of importing `next-webdriver`
directly
### Why?
`test/integration/` predated `nextTestSetup` and ran tests directly
against the source checkout via custom helpers (`launchApp`,
`nextBuild`, `nextStart`, `runNextCommand`, `webdriver`, `fetchViaHTTP`,
…). Each suite hand-rolled its own dev/start/build orchestration,
fixture mutation, and process management.
The isolated test model used by the rest of the repo gives each suite an
isolated working directory containing a packed `next.tgz` install, a
uniform `next.start()` / `next.build()` / `next.fetch()` /
`next.browser()` API, and the same lifecycle for dev, start, and deploy
modes — so a single set of assertions covers all three. Deploy-mode
skips and per-feature gates are expressed declaratively
(`skipDeployment`, `disableAutoSkewProtection`, `if (skipped) return`)
instead of branching on `process.env`.
Removing `test/integration/` lets us:
- Delete the bespoke orchestration code in the CI workflow and
`run-tests.js`
- Run every converted suite consistently in dev, start, and deploy modes
(where applicable)
- Reproduce every test locally with the same `pnpm
test-{dev,start}-{turbo,webpack}` commands; no separate `integration`
path
- Open the door to running `test/production` against deployments in the
future (the converted suites already declare `skipDeployment` so they
can be flipped on)
### How?
Mechanical conversion per suite, with targeted clean-ups:
1. **Per-suite conversion.** Each
`test/integration/<name>/test/index.test.{js,ts}` was rewritten into a
single `<name>.test.ts` under the right folder based on what the
original exercised:
- `launchApp` / dev-only assertions → `test/development/`
- `nextBuild` + `nextStart` / start-only assertions → `test/production/`
- Both → `test/e2e/`
- The one pure jsdom render check (`link-without-router`) → `test/unit/`
2. **API mapping.** Custom helpers were replaced by `nextTestSetup`
equivalents: `launchApp` → `next.start()`, `nextBuild` → `next.build()`,
`runNextCommand` → `next.runCommand`, `fetchViaHTTP` → `next.fetch`,
`webdriver(...)` → `next.browser(...)`. Fixture mutations switched from
raw `fs.writeFile`/`fs.rename` to `next.patchFile` (with the 3-arg
`runWithTempContent` callback when the change has a defined scope) and
`next.deleteFile`.
3. **Deploy-mode handling.** Suites that can't run in deploy mode (use
`patchFile` / `next.build()` / depend on local CLI output) declare
`skipDeployment: true` and early-return on the `skipped` boolean. Suites
where Vercel's edge mutates URLs (`&dpl=`, immutable assets) declare
`disableAutoSkewProtection: true`.
4. **`next.browser({ baseUrl })`.** A handful of tests
(`prerender-export`, `cdn-cache-busting`, `preload-viewport`, both
`react-virtualized` suites) need to drive a separate server (a
static-export server or an `http-proxy` instance) rather than the
Next.js process. Instead of importing `next-webdriver` directly, those
tests now pass `{ baseUrl: <port|url> }` to `next.browser()`. For the
proxy cases, the proxy was moved into `server.js` inside the fixture and
`http-proxy` declared via the `dependencies` option of `nextTestSetup`,
so the test runs with a fully isolated dependency graph.
5. **CI clean-up.** With `test/integration` gone, the `test
integration*` jobs and `integration-tests-manifest`-related logic in
`.github/workflows/build_and_test.yml` were removed, and `run-tests.js`
no longer has the `integration` test-folder branch.
6. **Validation.** The PR was iterated against multiple full CI runs;
the remaining failures on the latest run are pre-existing flakes
(segment-cache 60s `act` timeouts in turbopack-prod) or transient
infrastructure issues unrelated to the conversion.
## What?
- Restore pnpm store for test timing fetch to speed up `pnpm install` in
that step.
- Skip playwright install when it's not needed. Multiple steps don't
need Playwright in order to run. Saves 41 seconds per step that doesn't
need it.
- Removed extra playwright install on the `test-firefox-safari` as
Playwright already gets installed in the reusable workflow.
### Before (canary)
<img width="1546" height="1142" alt="CleanShot 2026-05-01 at 16 17
37@2x"
src="https://github.com/user-attachments/assets/7819915b-9c42-4286-b0b9-c2489b322825"
/>
### After (This PR)
<img width="1588" height="968" alt="CleanShot 2026-05-01 at 16 17 25@2x"
src="https://github.com/user-attachments/assets/fb583831-a78c-42db-a77d-7e27af9c1f35"
/>
---------
Co-authored-by: Benjamin Woodruff <benjamin.woodruff@vercel.com>
We're not planning to re-enable this workflow, and instead are handling
the upgrade PRs from within the applications themselves.
This removes them since they are currently failing.
The intel runners were quite a bit slower than the base arm runners. We
were previously compiling both architectures on self-hosted arm so this
just back the status quo on GH runners to improve speed. This shaves off
~10/20 mins.
Validated
[here](https://github.com/vercel/next.js/actions/runs/25071926790/job/73454315459)
Moves all release workflows off of a GH PAT and uses an app with a
short-lived token instead.
Test Plan:
Dry run
[here](https://github.com/vercel/next.js/actions/runs/24934593874).
However, this workflow is blocked until we figure out commit signing for
the bot app. Some options:
- The bot account generates a signing key and we use it in CI (not
great, bypasses the app)
- The org bypasses signature verification for the bot user (also not
great, requires an exemption rule)
- We need to rework the commit step so Lerna does not do the push, and
instead trigger it via the app + GH API. This seems like the best
option, will be added in a follow-up PR.
Note: `create-release-branch` workflow is broken in its current form, as
we will not be restoring administrator privileges to adjust environment
settings. This will become a manual step in the future.
- Add a workflow_run reporter that posts PR test/stats comments using
`GITHUB_TOKEN`.
- Upload PR metadata artifacts from `build-and-test` and `Generate
Stats` so the reporter can safely resolve the target PR
- Move stats/test comment posting out of the external webhook/PAT path
while preserving the existing comment format
This means the test comment won't incrementally update as tests fail,
it'll just post once at the end, but that's a reasonable tradeoff to not
having to worry about a webhook.
## Summary
Switch CI from self-hosted runners to GitHub-hosted runners and fix the
follow-up issues that showed up once the hosted jobs were exercised.
## Changes
- Move Linux workflows onto `ubuntu-latest-16-core-oss`, Windows
workflows onto `windows-latest-8-core-oss`, and the native mac release
lane onto `macos-15-intel`.
- Update the reusable build workflow to work in hosted environments by
relying on `runner.os` instead of label string matching, and by removing
the hardcoded `/home/runner` `fnm` path.
- Make Turbo/sccache configuration explicit for hosted runners:
- unify on `vtest314-next-adapter-e2e-tests`
- pass `TURBO_TOKEN` via workflow env/secrets instead of assuming
runner-level env
- switch Turbo cache mode to `local:rw,remote:rw` so jobs still have
local cache behavior when remote cache is unavailable
- Update the `sccache` action defaults/docs to use the hosted-runner
secret setup (`TURBO_TOKEN`) instead of the old self-hosted token
wiring.
- Make Datadog reporting fail open so missing `DATA_DOG_API_KEY` does
not block CI.
- Fix the CLI reserved-port test so it asserts Next’s own `-p 1`
validation instead of failing earlier on hosted Linux due to
privileged-port binding.
- Replace the live `musl.cc` dependency in `native-builder.Dockerfile`
with musl sysroots imported from GHCR-hosted `rust-musl-cross` images,
keeping the existing `/opt/*-cross` layout used by the Linux native
build scripts.
- Fix the macOS native build workaround s so it applies to all
*-apple-darwin targets based on the Cargo target OS rather than the host
architecture, which avoids napi-build injecting the unsupported -Wl
linker arg when @next/swc is linked with rust-lld on hosted mac runners.
Test Plan:
- This PR's CI
- This
[build_and_deploy](https://github.com/vercel/next.js/actions/runs/24865715983/job/72801225722)
job running on every arch
Moves the `environment` into `publishRelease` rather than
`trigger_release` since the actual action we want to create environment
rules for is the publish step.
Switches from a long-lived token to trusted publishing OIDC flow. This requires a bump to Node (for feature support). Otherwise just dropping unnecessary envs.
## What
- Use `cargo binstall` instead of `cargo install` for Rust tools in the
Docker image
- Download pre-built vercel/sccache from GitHub releases instead of
compiling from git
## Why
`cargo install` compiles from source which is slow on cache miss. `cargo
binstall` downloads pre-built binaries in seconds. The vercel/sccache
fork now publishes release binaries, eliminating the need for the
`build-sccache` turbo task (~2-3 min compile).
## Changes
**Dockerfile:**
- Install cargo-binstall at pinned version (1.18.1), use it for
cargo-rustflags
- Remove sccache from Docker image (not needed inside container)
**CI sccache:**
- Download pre-built binary from `vercel/sccache` GitHub releases
(~11MB)
- Cache in `~/.cache/sccache-<version>` on self-hosted runners
- Delete `scripts/build-sccache.js`, remove turbo task and package.json
entry
- `scripts/sccache-version` now contains just the release tag
<!-- NEXT_JS_LLM_PR -->
Fixes a few things:
- semver wasn't available because deps weren't being installed
- Fixed it to compare latest release with latest canary -- it was
erroneously marking a backport as not a backport.
Test plan:
Dry run results here:
https://github.com/vercel/next.js/actions/runs/24571828615/job/71846911677
```
should_dispatch=true
reason=Dispatching canary preminor release because stable release 16.2.4 is ahead of 16.2.1-canary.46
released_version=16.2.4
current_canary_version=16.2.1-canary.46
dispatch_input=false
auto_dispatch_enabled=true
```
## What
Use a stamp file to skip `playwright install --with-deps` after the
first successful run on a self-hosted runner.
## Why
`--with-deps` runs `sudo apt-get install` for Playwright's system
dependencies every time, even when they're already installed. On
self-hosted runners this takes ~10s of no-op `apt-get` checking. After
the first install, subsequent runs only need `playwright install` (which
downloads browser binaries if the version changed, and is instant when
cached).
The assumption is that on most of the runners, `playwright` will be the
same version on each, and the deps that are installed are compatible
within each version.
Most of the time this step is ~3s, when everything is healthy and
up-to-date.
Occasionally these steps will hiccup on builds when we're already really
got these deps installed so this chops a little bit off each one. This
also helps when APT mirrors go down.
## How
- First run: `playwright install --with-deps` + write
`~/.cache/playwright-deps-installed`
- Subsequent runs: stamp file exists, run `playwright install` without
`--with-deps`
- The stamp persists on self-hosted runners across jobs
- Fresh/reimaged runners don't have the stamp, so they get the full
install
- The cache is ignored if the most recently installed playwright version
doesn't match
<!-- NEXT_JS_LLM_PR -->
This adds an automated path to keep canary semver-ahead after a
successful stable backport publish.
Today, a backport release can produce a stable version that is newer
than the current canary version, which creates confusing version
ordering for tooling. This change adds a follow-up workflow that
evaluates completed backport publishes and, when appropriate, triggers a
canary preminor release on canary.
The `Sync Canary After Backport Release` workflow evaluates whether a
completed `build-and-deploy` run should trigger a canary sync.
The evaluation only triggers when:
- the upstream `Potentially publish release` job succeeded
- the head commit is a stable release commit
- the published stable version is a backport relative to next@latest
- the current canary version is not already semver-ahead
- there is not already a queued or in-progress Trigger Release run on
canary
When all checks pass, the workflow dispatches trigger_release.yml on
canary with:
- releaseType=canary
- semverType=minor
- force=true
(This is currently gated behind an env flag so we can dry-run it)
## What
Skip the sccache setup step when running on Windows.
## Why
sccache is currently not "winning" with cache hits when building on
Windows which is increasing the size of the turborepo cache. In
addition, sccache servers appear to be lingering on some of the windows
runners.
## What
Use the GHA cache instead of turborepo cache for intemediate build
products.
## Why
turborepo is fast, but GHA is pretty fast too (adds a few seconds), and
this takes load off the cache server. As a bonus, we get intermediate
artifacts available for download and we work regardless of cache server
availability.
## Details
- Add `nativeArtifact`/`uploadNativeArtifact` inputs to
`build_reusable.yml`
- Build jobs upload `.node` files as GitHub artifacts
- Test jobs download pre-built artifacts instead of rebuilding via
turborepo cache
- Rust installation and sccache are skipped when downloading pre-built
artifacts
- Both Linux (`next-swc-linux`) and Windows (`next-swc-windows`) native
builds upload artifacts
## Summary
Set `NEXT_SKIP_BUILD_CACHE=1` as a job-level env var for production,
staging, and force-preview deploy targets. Each caching step checks this
var:
- **Rust cache** (`ijjk/rust-cache`) — skipped
- **Native binary cache** (`native-cache.js` restore/save) — skipped
- **Turbo remote cache** — not passed to Docker builds; set to `local`
for non-Docker builds
- **sccache** — env vars not passed to Docker builds
Automated-preview builds (PRs) continue to use all caches as before.
<!-- NEXT_JS_LLM_PR -->
## What
- Pin all `actions/upload-artifact` references to `v7.0.1`
(`043fb46d1a93c77aae656e7c1c64a875d1fc6a0a`)
- Pin all `actions/download-artifact` references to `v8.0.1`
(`3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c`)
- Full SHA commit hashes
- Consistent across all 7 workflow files
`pnpm dlx` isn't using a lockfile. Using the self-contained binary from
GitHub and validate the checksum lets us effectively pin `datadog-ci`
and all of its possible transitive dependencies.
This is a follow-up for
https://vercel.slack.com/archives/C0APPN2LC83/p1775073127894859