The four-job merge gate
Build the complete GitHub Actions CI workflow: four parallel jobs that type-check, lint, test, and build every pull request before it can merge.
In the previous lesson you built the spine of ci.yml: the frozen header and one job that runs pnpm typecheck. This lesson uses that single job as a template for the rest.
You have a gap to close. The ruleset you wrote in the previous chapter blocks every merge until four checks pass: typecheck, lint, test, and build. Only typecheck exists. When the ruleset asks GitHub for the lint check, nothing reports under that name, so the protection meant to cover linting, tests, and the build covers nothing. Three-quarters of your gate is hollow.
This lesson makes all four checks real. You’ll finish with a complete ci.yml, and you’ll be able to explain to a teammate why these four and not fewer: it is the difference between a main branch you deploy on faith and one where a green pull request can still take down production.
Why exactly these four checks gate a merge
Section titled “Why exactly these four checks gate a merge”Four is the smallest set where each check catches a class of mistake none of the others can. Drop any one and you open a blind spot the gate exists to cover. Here they are, cheapest to most expensive.
Type-check (pnpm typecheck, which runs tsc --noEmit) catches everything the type system can prove wrong: a renamed field still read somewhere, a function called with the wrong shape of argument, a possibly-null value sent where null can’t go.
It catches one thing pnpm dev misses.
The dev server checks types lazily, only for the files you open, so a type error in a module nobody touched this week sails through.
tsc --noEmit checks the whole project in one pass and finds it.
Lint (pnpm lint, which runs Biome ) catches correctness problems the type system doesn’t model: a console.log left in, == where you meant ===, and the dangerous one, a promise you forgot to await, so an error inside it vanishes with no trace.
You configured Biome units ago; in the gate it is just the command that catches what types can’t see.
Test (pnpm test, which runs Vitest ) catches behavioral regressions: code that type-checks and lints clean yet still does the wrong thing, like a refund calculated wrong or a filter that drops the last row.
Types and lint can’t see meaning; only a test asserting that one input produces a given output can.
This is the safety net that makes refactoring safe, telling you the moment behavior shifts under your edit.
Build (pnpm build, which runs next build) catches integration problems that surface only when the bundler resolves the entire module graph at once: an import that type-checks in isolation but can’t be resolved, a missing environment variable the build needs, a server-only module pulled into a Client Component.
The build also produces the artifact the deploy job ships in the next chapter, so building in CI proves the thing you’re about to deploy can be built at all.
Everything else a pipeline can run is supplementary: a pnpm audit for vulnerable dependencies, a link-checker on your docs, a linter for the workflow files, Dependabot opening upgrade pull requests.
All useful, but none catches a class of regression that should block a merge, so it counts as signal rather than gate.
That distinction is why the gate stops at four: these four block, everything else only informs.
Prove to yourself that you can tell the four apart. Each defect below slips past three of the four and is caught by exactly one.
Each defect slips past every check but one. Which job catches it? Drag each item into the bucket it belongs to, then press Check.
Four parallel jobs versus one fat job
Section titled “Four parallel jobs versus one fat job”With the four checks settled, the next decision is structural: how do you arrange them in one file? There are two shapes, and the better default is not the obvious one.
The first shape is four separate jobs under jobs:, with no needs: between them, so they all run in parallel. Each job has the shape from the previous lesson, ending in the single command that is the job. Every job installs dependencies on its own clean runner, but with the pnpm store cached that install costs about thirty seconds. Because the jobs run at once, the total wall-clock time is bounded by the slowest job, usually build, not the sum of all four: roughly three to five minutes for the whole gate on a warm cache.
The second shape is one fat job that runs all four commands in sequence. It installs once, saving the three duplicated installs. If CI minutes were all that mattered, it would win. But it costs you two things. The wall-clock is now the sum of the four commands instead of the max, and, decisively, you lose failure granularity.
Compare what each shape tells you when something is wrong. With four parallel jobs, a failing typecheck shows up beside the lint, test, and build results: you see every failure at one glance at the pull request, fix them all, and push once. With the fat job, the first failing command aborts the run, so when typecheck fails, test and build never start. You fix the type error, push, wait three minutes, and only then discover the lint error. Fix that, push, wait again, find the failing test. One round of feedback has become three.
That is the reflex worth internalizing: optimize for how fast the developer learns everything that’s wrong, not for raw CI minutes. So parallel is the default. Reach for the fat job only when CI minutes are genuinely constrained, on a cheap plan with few concurrent jobs, or when install cost dominates and caching can’t help.
jobs: typecheck: runs-on: ubuntu-latest steps: # ...same setup steps... - run: pnpm typecheck lint: runs-on: ubuntu-latest steps: # ...same setup steps... - run: pnpm lint test: runs-on: ubuntu-latest steps: # ...same setup steps... - run: pnpm test build: runs-on: ubuntu-latest steps: # ...same setup steps... - run: pnpm buildIndependent jobs, parallel by default, so you see all four results at once. Wall-clock is bounded by the slowest job, not the sum, and a red typecheck never hides a red lint. The repeated setup steps are trimmed here; the full file follows in the next section.
jobs: ci: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm typecheck && pnpm lint && pnpm test && pnpm buildInstalls once, but the wall-clock is the sum and the first failure hides the rest. && short-circuits: a failed typecheck aborts before test or build run, so you learn one problem per push.
The four jobs of ci.yml
Section titled “The four jobs of ci.yml”The complete ci.yml produces the four status checks your ruleset requires. The walkthrough rests on one fact: the four jobs are nearly identical.
name: CIon: pull_request: push: branches: [main]permissions: contents: readconcurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: truejobs: typecheck: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm typecheck lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm lint test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm test build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm buildThe unchanged header from the previous lesson: name: CI, the trigger pair that runs on every pull request and re-runs on merge to main, the read-only permissions floor, and the concurrency cancel. You add jobs underneath it.
name: CIon: pull_request: push: branches: [main]permissions: contents: readconcurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: truejobs: typecheck: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm typecheck lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm lint test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm test build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm buildThe typecheck job sets the shape the next three repeat: checkout, pnpm, Node plus cache, frozen install, then one command.
name: CIon: pull_request: push: branches: [main]permissions: contents: readconcurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: truejobs: typecheck: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm typecheck lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm lint test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm test build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm buildlint repeats that shape; only the last line, pnpm lint, differs.
name: CIon: pull_request: push: branches: [main]permissions: contents: readconcurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: truejobs: typecheck: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm typecheck lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm lint test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm test build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm buildtest, same shape, pnpm test.
name: CIon: pull_request: push: branches: [main]permissions: contents: readconcurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: truejobs: typecheck: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm typecheck lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm lint test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm test build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm buildbuild, same shape, pnpm build. It is typically the slowest of the four, so it sets the wall-clock for the whole parallel run.
name: CIon: pull_request: push: branches: [main]permissions: contents: readconcurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: truejobs: typecheck: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm typecheck lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm lint test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm test build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm buildThese four job ids, typecheck, lint, test, and build, are the exact strings the ruleset matches by name. The next section turns on that string equality.
Every job repeats the same five setup steps, four times over. That repetition is the price of isolation and parallelism: each job runs on its own fresh machine, so each must declare everything it needs from scratch. A composite action could bundle the setup into one uses: line, but it earns its weight only across several repositories that must stay in sync, not in a single repo where the inline form is the one you can read.
The exercise below checks that you can write a job from memory rather than copy it; the blanks fall on the three decisions that matter most.
Fill the three load-bearing tokens to complete one job. Pick the right option from each dropdown, then press Check.
test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: ___ - uses: actions/setup-node@v6 with: node-version: 24 cache: ___ - run: pnpm install --___ - run: pnpm testJob names are the contract with the ruleset
Section titled “Job names are the contract with the ruleset”The four job ids you just chose are not just labels. Each one surfaces on the pull request as a status check of exactly that name, and the ruleset’s “required status checks” lists those same four strings: typecheck, lint, test, and build. The two are joined by string equality and nothing else. No compiler, no test, no type system covers this seam. It is two pieces of text in two separate systems, a YAML workflow and a GitHub ruleset, that happen to match.
Watch what happens when they stop matching. A developer renames the test job to tests, a reasonable-looking tidy-up. The workflow still runs, the tests check goes green, and the pull request looks healthy. But the ruleset still requires a check named test, which no longer exists. Depending on the ruleset’s configuration, GitHub either waits forever for a check that will never report, or, far worse, treats the missing required check as not-applicable and lets the pull request merge without the test suite ever gating it. No error, no red X: the gate did not break, it silently stopped protecting that one case, and the only sign is the absence of a signal.
So the rule is this: any rename of a CI job is a two-file pull request, the workflow and the ruleset changed together. Treat a job id like a published API that something else depends on by name, because that is exactly what it is.
- typecheck
- lint
- test
- build
- typecheck
- lint
- test
- build
- typecheck
- lint
- tests
- build
- typecheck
- lint
- test
- build
test — no check reports it
You rename the build job to build-app in ci.yml and forget to update the ruleset, which still lists build as required. What happens to the gate?
build-app.build-app isn’t one of the checks the ruleset lists.build ever reports, so that requirement goes unmet by silence — and depending on config the pull request can become merge-eligible with the build no longer gating it.build reports, so the gate stops enforcing the build. The job itself runs fine and goes green under build-app — that green is the trap. The ruleset blocks only on missing required checks, never on extra ones, so the new build-app check showing up does nothing to help, and a missing required check commonly resolves as merge-eligible rather than a hard block. There is no link that syncs a rename back to the ruleset. The danger isn’t an error; it’s the silence.When the build job needs environment variables
Section titled “When the build job needs environment variables”The build job has one wrinkle the other three don’t. pnpm build passes on your laptop every time, then fails in CI with an error about a missing environment variable. Same code, same lockfile, red in CI and green at home.
The reason is that next build reads some environment variables at build time, not at request time: NEXT_PUBLIC_* values, which get inlined into the client bundle, and any value the app touches while pre-rendering a page, such as a DATABASE_URL read while rendering a public page’s HTML ahead of time. Your laptop has a .env file, so the build finds what it needs; the CI runner has none, so the same build fails.
There are two responses, and the order matters.
Reach for the first one first, because it’s architectural: prefer dynamic rendering for anything backed by the database. A page that reads the database should render per request, not at build time. Render it dynamically and next build never touches the database, never needs DATABASE_URL, and the error disappears. An env-at-build-time error is often the sign that a page is being pre-rendered when it shouldn’t be; fix the rendering and you fix the build.
When the build legitimately needs a value, provide it scoped to the minimum: a job-level env: map sourced from secrets, granting only what this build needs, the same least-privilege instinct you applied to permissions last lesson.
- run: pnpm build env: DATABASE_URL: ${{ secrets.DATABASE_URL }} BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }}One escape hatch to know. The project validates its environment with a Zod-based validator, the build-time env validation you set up in the Postgres-and-Drizzle unit, which fails the build when a required variable is missing. That’s the behavior you want, except when the build shouldn’t be touching the database at all. For that narrow case, set SKIP_ENV_VALIDATION=true, only at build time, never at runtime and never in production.
How environment values are scoped across development, preview, and production belongs to the deployment chapter next. Here, build is simply the one job that may carry an env: block.
The five-minute speed budget
Section titled “The five-minute speed budget”This last property is about the whole pipeline, not any single job: on a warm cache, the baseline should finish in under five minutes wall-clock. A cold-cache run lands around six to eight minutes, a warm one three to five. Five minutes is the line.
The number matters because of what happens to a slow gate: people stop waiting for it. They merge before it finishes, reach for the branch-protection exception meant for emergencies, and treat the gate as the thing between them and shipping. A gate nobody waits for protects nothing. Fast feedback is what keeps the gate respected, and a respected gate is the only kind that works.
So when the budget breaks, treat it as a diagnosis, not a reason to upgrade the runner. If the build creeps past three minutes, ask why first: maybe a route started fetching everything at build time, the env-at-build-time problem from the last section in a new guise, or the test suite outgrew a single job. Two levers are worth knowing, each with the threshold that earns it.
The first is the Next.js build cache. next build keeps an incremental cache in .next/cache, separate from the pnpm store you already cache. Cache that directory across runs and each build reuses the unchanged work from the last one, which can cut build time substantially.
- uses: actions/cache@v4 with: path: .next/cache key: next-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('**/*.{ts,tsx}') }} restore-keys: | next-${{ hashFiles('pnpm-lock.yaml') }}-Reach for the build cache once build time climbs past about two minutes; below that, an unused cache step is just noise. The watch-out is why you key it so carefully: a stale build cache can pass CI while shipping broken code, a green check on a bad artifact. Key it on something that changes when your code changes, and accept the occasional cache miss as the price of never trusting a wrong key.
The second lever is test sharding: splitting a large suite across parallel jobs with vitest run --shard=1/4 and a matrix. It earns its weight once the suite passes roughly five hundred tests or two minutes, past the surface area of a typical web app, so the baseline leaves it out. Know it exists and reach for it when the suite is genuinely big.
One anti-reflex is worth naming. When a test in the gate is flaky , the wrong fix is to auto-retry it until it goes green. That hides the flake, and a hidden flake is a real intermittent bug waiting to ship behind a green gate. Fix the test. A gate that reruns until it’s happy isn’t a gate.
External resources
Section titled “External resources”The official references behind everything you wired in this lesson. The first grounds the jobs: map and parallelism, the second the build and its caching, and the third the test job.