Skip to content
Chapter 97Lesson 2

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.

typecheck
catches what the type system can prove wrong
only this catches a renamed field still read elsewhere
lint
catches the correctness band types don't model
only this catches a forgotten await
test
catches behavioural regressions
only this catches a refund total off by one
build
catches whole-graph integration problems
only this catches a server-only import in a Client Component

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.

typecheck tsc --noEmit
lint biome
test vitest run
build next build
A function is called with an argument of the wrong shape.
A property was renamed in one file but still read in another.
A promise is never awaited, so an error vanishes silently.
An imported helper is never used anywhere.
A discount is applied twice, so the total is wrong — yet every type is correct.
A server-only module is imported into a Client Component.
A page reads an environment variable that exists locally but isn’t set in the build.

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.

.github/workflows/ci.yml
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 build

Independent 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.

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: CI
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
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 build

The 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: CI
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
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 build

The typecheck job sets the shape the next three repeat: checkout, pnpm, Node plus cache, frozen install, then one command.

name: CI
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
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 build

lint repeats that shape; only the last line, pnpm lint, differs.

name: CI
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
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 build

test, same shape, pnpm test.

name: CI
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
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 build

build, same shape, pnpm build. It is typically the slowest of the four, so it sets the wall-clock for the whole parallel run.

name: CI
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
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 build

These 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.

1 / 1

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 test

Job 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.

ci.yml
jobs
  • typecheck
  • lint
  • test
  • build
ruleset
required checks
  • typecheck
  • lint
  • test
  • build
The job name is the only thing wiring the workflow to the ruleset.

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?

GitHub notices the rename and quietly points the ruleset’s required check at build-app.
The pull request is blocked, because build-app isn’t one of the checks the ruleset lists.
Nothing named 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.
The workflow refuses to run until the ruleset and the workflow agree on the name.

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.

.github/workflows/ci.yml
- 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.

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.

.github/workflows/ci.yml
- 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.

typecheck
1m
lint
1.5m
test
2.5m
build
3.5m
one fat job
= the sum, 8.5m
5m budget
0 2 4 6 8 min
Run in parallel, the jobs finish with the slowest, not the sum, which keeps the gate inside the five-minute budget.

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.