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 (name, the trigger pair, read-only permissions, and the concurrency cancel) and exactly one job underneath it that runs pnpm typecheck. You learned the anatomy of a single job so that this lesson would feel like assembly rather than magic. Now you get to use that.
This leaves you with a problem. The ruleset you wrote in the previous chapter refuses to merge a pull request until four checks pass: typecheck, lint, test, and build. But only one of those four exists. The other three are missing entirely. The ruleset asks GitHub for a check called lint, GitHub finds nothing reporting under that name, and the protection that was supposed to cover linting, tests, and the build quietly covers nothing. Three-quarters of your gate is hollow.
This lesson makes all four real. By the end you’ll have written the complete ci.yml, and, more importantly, you’ll be able to explain to a teammate who thinks two checks would do why exactly these four. That is the difference between a main branch you can deploy on faith and one where a green pull request can still take down production.
Why these four checks, and no others
Section titled “Why these four checks, and no others”Before you write a line of YAML, you need to be convinced the set is right. Four is not a round number someone picked: it’s the smallest set where each check catches a class of mistake that none of the others can. Drop any one of them and you open a blind spot the gate was built to cover. Here are the four blind spots, cheapest to most expensive.
Type-check (pnpm typecheck, which runs tsc --noEmit) catches everything the type system can prove wrong: a field you renamed but still read somewhere, a function called with the wrong shape of argument, a value that might be null going somewhere that can’t handle null. This is the floor the rest of the codebase stands on. It catches one thing pnpm dev does not: the dev server checks types lazily, only for the files you actually open and edit. A type error in a module nobody touched this week, or a broken import buried two layers down, sails straight through dev unnoticed. tsc --noEmit checks the whole project in one pass, so it finds the error in the file you forgot existed.
Lint (pnpm lint, which runs Biome ) catches a band of problems the type system simply doesn’t model: an import you stopped using, a console.log you left in, == where you meant ===, and the dangerous one, a promise you forgot to await, so an error inside it vanishes with no trace. Biome is formatter and linter folded into one tool, replacing the old ESLint-plus-Prettier pairing and running roughly ten times faster. You configured it units ago, so here you can treat it as a black box. Its only purpose in the gate is to catch the correctness problems that types can’t see.
Test (pnpm test, which runs Vitest ) catches behavioral regressions: code that type-checks clean, lints clean, and still does the wrong thing. A refund that’s calculated wrong. A filter that drops the last row. Types and lint can’t see meaning; only a test that asserts “this input produces that output” can. This is the safety net that makes refactoring safe, because it tells you the moment behavior shifts under your edit.
Build (pnpm build, which runs next build) catches the integration problems the first three miss: the failures that only surface when the bundler resolves the entire module graph at once. A bad import that type-checks in isolation but can’t actually be resolved. An environment variable the build needs that isn’t set. A server-only module pulled into a Client Component, crossing a boundary that’s only enforced at build time. The build also does double duty: it produces the artifact the deploy job will ship in the next chapter, so building in CI proves that the thing you’re about to deploy can be built at all.
That leaves the line that defines the edge of the gate. Everything else a CI pipeline can run is supplementary: a pnpm audit for vulnerable dependencies, a link-checker on your docs, a linter for the workflow files themselves, Dependabot opening upgrade pull requests. All of it is useful, but none of it catches a class of regression that should block a merge, so it counts as signal rather than gate. That distinction is the whole reason the gate stops at four, and it’s what the next lesson is built around. For now, hold the line: four checks gate the merge, and everything else informs you without blocking you.
Before moving on, prove to yourself that you can tell the four apart. The following exercise gives you a defect and asks which job catches it. Each defect 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 four jobs in one file? There are two shapes, and the better default is not the obvious one.
The default is four separate jobs in the jobs: map, with no needs: between them, so they all run in parallel. Each job is the exact shape you learned in the previous lesson: check out the repo, set up pnpm, set up Node with the cache, pnpm install --frozen-lockfile, then the single command that is the job. Every job installs dependencies again on its own clean runner. With the pnpm store cached that install costs about thirty seconds, and the payoff makes it worth paying four times: because the jobs run at once, the total wall-clock time is bounded by the slowest job, usually build, not by the sum of all four. For a typical 2026 SaaS app on a warm cache, that’s roughly three to five minutes for the whole gate.
The alternative is one fat job that runs all four commands in sequence. It installs once, so it genuinely saves the duplicated install cost: three installs you don’t pay for. That sounds like a clear win, and if CI minutes were the only thing that mattered, it would be. But it costs you two things. First, the wall-clock is now the sum of all four commands instead of the max, because they run one after another. Second, and this is the decisive one, it costs you failure granularity.
Consider what each shape tells you when something is wrong. With four parallel jobs, a failing typecheck shows up next to the lint, test, and build results: you see every failure in the run at once, in one glance at the pull request. You fix everything 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 that was there the whole time. Fix that, push, wait again, discover the failing test. You’ve turned one round of feedback into three.
That is the senior reflex worth internalizing: optimize for how fast the developer learns everything that’s wrong, not for raw CI minutes. Minutes are cheap; a developer’s attention and momentum are not. So parallel is the default. You’d only reach for the fat job when CI minutes are genuinely constrained, on a cheap plan with very few concurrent jobs, or when install cost dominates and caching can’t help. Outside those cases, parallel wins, and it wins on feedback quality.
The following two variants show the same four checks arranged both ways.
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. Each job runs on its own clean runner, so the 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 to keep the focus on structure; 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. Cheaper on install minutes, but && short-circuits: a failed typecheck aborts before test or build run at all, so you learn one problem per push.
Your CI runs the four checks as four parallel jobs. A teammate proposes collapsing them into one job to “save time on installs.” What’s the strongest argument for keeping them parallel?
test and build never run and you learn one problem per push. Caching the pnpm store works fine in a single job. And the ruleset matches check names, not job count: a fat job could publish one combined check, which is exactly the trap — it would gate on “all passed or something failed” with no breakdown.Building the workflow: four jobs
Section titled “Building the workflow: four jobs”Now you write the whole thing: the complete ci.yml that produces the four status checks your ruleset requires. It is the central artifact of the lesson and of the chapter. Step through it part by part. The walkthrough rests on the fact that 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 buildThis is byte-for-byte the spine 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’re not touching it, only adding 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, already familiar. It’s the shape the next three jobs repeat: checkout, pnpm, Node plus cache, frozen install, then the one command. Five setup steps, 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 is identical to typecheck except for the last line, pnpm lint. That’s the whole point: the same five setup steps, one distinguishing 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 buildtest is the same shape again. The only line that differs is 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’s typically the slowest of the four, so it’s the job that 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 buildHere is the contract. These four job ids, typecheck, lint, test, and build, are the exact strings the ruleset matches by name. That string equality is what the next section is about.
You’ve probably noticed the obvious objection: every job repeats the same five setup steps, four times over. That repetition is the price of isolation and parallelism, the same clean-runner argument from the previous lesson. Each job runs on its own fresh machine, so each must declare everything it needs from scratch. There is a tidy fix, a composite action that bundles the setup into one uses: line, but it’s premature here. It earns its weight across five or more repositories that must stay in sync, not in a single repo where the inline form is the thing you can actually read.
The following exercise 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 testThe job names are the contract with the ruleset
Section titled “The job names are the contract with the ruleset”This short section ties the file you just wrote back to the ruleset and forward to deployment. You met the idea last lesson: a job’s name becomes the check name. Now it applies to all four, and it brings with it one of the nastiest failure modes in CI configuration.
The mechanism is plain. Each job id, typecheck, lint, test, and build, surfaces on the pull request as a status check of exactly that name. The ruleset’s “required status checks” lists those same four strings. The two are joined by string equality and nothing else. No type system covers this seam, no compiler, no test. It’s two pieces of text in two different systems, a YAML workflow and a GitHub ruleset, that happen to match.
Now watch what happens when they stop matching. A developer renames the test job to tests, plural: a perfectly reasonable-looking tidy-up in a pull request. The workflow still runs. The tests check goes green. The pull request looks healthy. But the ruleset still requires a check named test, and test no longer exists. Depending on how the ruleset is configured, GitHub either waits forever for a check that will never report, or, more commonly and far worse, treats the missing required check as not-applicable and lets the pull request merge without the test suite ever having gated it. Nothing errored. No red X. The gate didn’t break; it silently stopped protecting that one case, and the only sign that it happened is the absence of a signal.
So here is the rule: 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’s exactly what it is. This was flagged as a watch-out twice already; it lands in real code here, so this is where it gets its full weight.
- 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 needs the environment
Section titled “When the build needs the environment”One job doesn’t quite fit the clean “run one command” mold, and it’s the one that trips people up most often in the real world: build. The symptom is oddly specific. 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. Before reaching for any config, understand why.
next build reads certain environment variables at build time, not at request time. There are mainly two kinds: NEXT_PUBLIC_* values, which get inlined into the client bundle as the build runs, and any value the app touches while it’s pre-rendering a page, for instance a DATABASE_URL read while rendering a public page’s HTML ahead of time. Your laptop has a .env file sitting right there, so the build finds everything it needs. The CI runner is a clean room with no .env, so the build that worked locally fails because the room is empty.
There are two senior responses, and the order matters.
The first is architectural, and it’s the one to reach for first: prefer dynamic rendering for anything backed by the database. A page that reads the database should render per request, not be pre-rendered at build time. If it renders dynamically, next build never touches the database, so it never needs DATABASE_URL at all and the problem disappears. Reserve build-time data reads for pages that are genuinely static. There’s a deeper lesson here too: an env-at-build-time error is often a sign that something is being statically rendered that shouldn’t be. Fix the rendering, and you fix the build.
The second response is for when the build legitimately needs a value, and then you provide it, scoped to the minimum. You expose it as a job-level env: map sourced from secrets, granting only what this build actually needs and nothing more, 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 more tool to name, because you’ll meet it: the project validates its environment with a Zod-based validator, the build-time env validation you set up in the Postgres-and-Drizzle unit. That validator fails the build if a required variable is missing, which is the behavior you want, except when the build genuinely shouldn’t be touching the database at all. For that narrow case there’s an escape hatch, SKIP_ENV_VALIDATION=true, used only at build time when a build-time database read would otherwise block you, never at runtime and never in production.
That is deliberately just enough to get the build job green. The full story, how environment values are scoped across development, preview, and production, and how OIDC replaces long-lived cloud secrets entirely, belongs to the deployment chapter that comes next. Here, all you need to know is that build is the one job that may carry an env: block, and why.
The five-minute speed budget
Section titled “The five-minute speed budget”The last property of the gate isn’t about any single job. It’s about the whole pipeline, and it’s the senior reflex this lesson most wants you to keep. State it as a number: the baseline should finish in under five minutes wall-clock on a warm cache. A cold cache run lands around six to eight minutes; a warm one, three to five. Five minutes is the line.
Why does a number deserve this much attention? Because of what happens to a slow gate: it gets bypassed, not maliciously, but through ordinary human friction. People start merging without waiting, telling themselves they’ll fix it after. They reach for the branch-protection exception that was meant for emergencies. They come to resent the gate as the thing standing between them and shipping. A gate nobody waits for protects nothing, and all that structural enforcement you built becomes theater. This is the same logic that justified caching last lesson, now raised to an explicit budget for the entire pipeline: 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 alone is creeping past three minutes, ask why before you throw hardware at it. Maybe a route started fetching everything at build time, which is the env-at-build-time problem from the last section in a different guise, or the test suite quietly 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’re already caching. Cache that directory across runs and your builds reuse the unchanged work from last time, 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 that build cache when build time climbs past about two minutes; skip it when the build is already fast, since an unused cache step is just noise. There’s a sharp watch-out here, and it’s the reason you key the cache so carefully: a stale build cache can produce a passing CI run that ships broken code, which is the worst failure mode there is, a green check on a bad artifact. So key it on something that actually changes when your code changes, and accept the occasional cache miss as the cheap price of never trusting a wrong key.
The second lever is test sharding: splitting a large test suite across several parallel jobs with vitest run --shard=1/4 and a matrix. It earns its weight when the suite passes roughly five hundred tests or two minutes, which is past the surface area of a typical SaaS startup, so the baseline doesn’t include it. Know it exists, and reach for it when the suite is genuinely big.
There’s also one anti-reflex worth naming, because it’s tempting and it’s wrong. When a test in the gate is flaky , passing and failing on identical code, the wrong fix is to auto-retry it until it goes green. Retry-on-failure doesn’t fix anything; it hides the flake, and a hidden flake is a real intermittent bug waiting to ship while the gate stays green. The only durable answer is to fix the test. A gate that reruns until it’s happy isn’t a gate.
The following diagram shows why the parallel arrangement and the budget fit together: the gate’s wall-clock is the slowest job, not the sum, and the build is the one that sets it.
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.