On this page

A pull request is where a change becomes reviewable. A gate is where a change becomes provable. Chapter 4 covered the PR as the unit of shipping. This chapter covers the thing that runs inside that unit before anyone, including you, gets to call it done: the CI gate that fires on every push and every pull request, and that fails loudly the moment the code doesn't do what it claims.

I run one on this site. It is not clever. It typechecks, it tests, it builds, and it does all three in about three minutes. That last number matters more than the first three put together.

The gate this site actually runs

Here is the trigger block from this site's .github/workflows/deploy.yml:

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:

Three triggers, one job that cares about two of them. The build job (named "Typecheck & Build" in the workflow) runs on push to main and on every pull_request targeted at main. That is deliberate, and it is the single most important design decision in the file. A gate that only runs on main catches problems after they've already merged; a gate that runs on both catches them before a human ever has to react to a red main branch.

The steps, in order, are: checkout, set up Node 20, npm ci, a name-scrub check, npm run typecheck, npm test, then npm run build. Six steps, one job, one runner. Nothing about that list is exotic. What makes it a gate rather than a checklist is that any one of those steps failing stops the job, and a failed job blocks the PR from looking green. The gate doesn't advise. It refuses.

Anatomy: on, jobs, runs-on

Strip the workflow down to its skeleton and three keys carry the whole thing.

on decides when the workflow wakes up. jobs decides what runs once it does. runs-on decides what kind of machine each job runs on. GitHub's workflow syntax reference documents runs-on as the key specifying the runner environment, listing ubuntu-latest, windows-latest, and macos-latest among the GitHub-hosted values. This site's build job declares runs-on: ubuntu-latest, which is the right default for a Node/Next.js project: it's the cheapest runner class and there's no platform-specific behaviour in a static export build that would need Windows or macOS.

jobs:
  build:
    name: Typecheck & Build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: bash scripts/scrub-check.sh
      - run: npm run typecheck
      - run: npm test
      - run: npm run build

That's the whole anatomy. A job is a named block under jobs. It gets a runner. It gets a list of steps, each either a reusable uses: action or a raw run: command. Everything else in a real workflow (concurrency, needs, conditionals, matrix builds) is a refinement layered on top of this shape, not a replacement for it.

Fast enough to run on everything

Three minutes is not a vanity number. It's the reason this gate can afford to run on every push and every PR without anyone complaining about waiting for it. A gate that takes twenty minutes teaches people to stop running it locally before pushing, batch commits to amortise the wait, and eventually treat a red check as background noise rather than a stop sign. A three-minute gate fits inside the attention span of someone who just pushed and is still watching the terminal, red or green, they get an answer while the change is still fully loaded in their head.

Keeping it fast is a discipline, not an accident. Typecheck and build are things a static-export Next.js project needs to do anyway to prove it's shippable, so running them in CI adds no new work, only the certainty it happened on a clean checkout rather than "on my machine." The moment a gate starts doing something slow, ask whether it belongs here at all, or in a separate, less frequent one (chapter 7 covers workflows that run on a schedule instead of on every push, where slow things belong).

The backstop nobody sets until they need it

timeout-minutes is a job-level key you can add next to runs-on to cap how long that job is allowed to run before the platform kills it for you. This site's build job doesn't currently set it explicitly, and that's worth naming rather than glossing over: a job that normally finishes in three minutes has no protection against becoming a job that hangs for the platform's full default allowance because npm ci stalled on a flaky registry, or a test process forgot to exit.

The fix, when I get to it, is one line: timeout-minutes: 5 on a job that reliably finishes in three. That turns a silent hang into a fast, loud failure instead of an orange "still running" that nobody notices for an hour, cheap insurance if your own gate doesn't set it yet.

Artefact upload: what the gate hands off

A gate that only tells you pass or fail throws away useful evidence. This site's build job ends with:

- name: Upload build artefact
  uses: actions/upload-artifact@v4
  with:
    name: static-out
    path: out/
    retention-days: 1
    include-hidden-files: true

actions/upload-artifact@v4 is the documented action for storing workflow output, with name and path as the two required keys. Here it packages the entire out/ static export as an artefact called static-out, which the separate deploy job then downloads and ships over FTP. The build job produces evidence; the deploy job consumes it, the PR path never needs FTP credentials, and the deploy path never re-does the build.

retention-days: 1 is set deliberately low. GitHub docs note the value "cannot exceed the retention limit set by the repository, organization, or enterprise." This artefact is a same-day relay between two jobs in the same run, not a long-term build cache, so a one-day retention keeps storage lean without any downside.

A gate is where policy stops being a suggestion

Typecheck and build catch code that's broken. They say nothing about code that's correct but violates a rule you decided matters: no personal usernames in published content, no citation to a source outside an approved registry, no chapter missing the pedagogy it's supposed to carry. Those rules live in scripts, and a script that only a human remembers to run is a rule that eventually gets skipped under deadline pressure.

This site's gate runs bash scripts/scrub-check.sh on every push and PR, the same enforcement layer as typecheck and build, just checking policy instead of syntax. A separate script, scripts/validate-learning.mjs, runs during npm run prebuild and fails the build non-zero if any published /learning/ chapter cites a source domain that isn't in the canonical-sources registry, or is missing its required concepts tags or its <Exercise>/<Checkpoint> blocks. Neither check depends on a human remembering to look, both fire on every PR that touches the content they guard, because content rules enforced only by memory drift the moment the person who wrote them stops personally reviewing every change.

What no gate costs

The counter-example is UK Calculators before it had one. For months the only build-time signal was typescript.ignoreBuildErrors: true sitting in next.config.mjs next to a strict: true in tsconfig.json, which is a contradiction dressed up as configuration: strict mode finds the errors, then the build flag tells the deploy step to ship anyway. The only test coverage was a Playwright suite that had been red for weeks, functionally no test suite at all, because a check nobody looks at stops being a check.

Turning that flag off and standing up a real gate (typecheck and build, replacing the dead Playwright suite) surfaced twelve real product-code type errors on its very first run. None of those twelve errors were new. They had been shipping to production, silently, for as long as the flag had been set. The gate didn't create the bugs. It was the first thing that ever looked.

Sabotage-test the gate, or it's decoration

A gate you have never watched fail is a gate you're trusting on faith. When validate-learning.mjs first landed as a CI-enforced check, I proved it worked the only way that counts: deliberately broke a published chapter (dropped its concepts tags), pushed, watched the gate fail for the right reason, restored the chapter, and watched it go green again. That round trip is the whole test. A green check that has never once gone red under a known-bad input hasn't been verified. It's been assumed.

The same logic applies to the name-scrub check and to the typecheck/build gate itself. If you've never seen your own CI gate fail on a commit you know is bad, you don't actually know it's checking anything. You know it's running.

Where this leaves you

A CI gate is small on purpose: an on block that fires on push and PR, a job with runs-on, a short list of steps that would need doing anyway, and an artefact upload that hands proof of the build to whatever runs next. Keep it fast enough that it runs on everything without friction, and it becomes the layer where your own repo's rules, not just the compiler's, get enforced automatically instead of by memory. The next chapter picks up the artefact this one produces and follows it into the deploy job that actually ships it.

// EXERCISE

Stand up a fast typecheck+build gate and prove it fires both ways

On your own repository, add or tighten a GitHub Actions workflow that runs on both push and pull_request against your main branch, keep the job under about five minutes, and sabotage-test it once before trusting it.

Expected behaviour
  • A workflow whose on: block includes both push and pull_request, each filtered to your main branch
  • A single job on runs-on: ubuntu-latest running install, typecheck, and build as separate steps
  • One deliberately broken commit on a throwaway branch that makes the job fail, pushed as a PR to confirm the check goes red for the right reason
  • The same branch fixed and re-pushed, with the check going green, and the whole job completing in roughly three to five minutes

PROVE IT Run `gh run list --branch <your-throwaway-branch> --limit 2` and show one failed run followed by one successful run for the same PR.

// CHECKPOINT — ACTIONS I: CI GATES
multiple choice · auto-checked

Why does this site's build job run on both push and pull_request, rather than push alone?

exact answer · auto-checked

Which job-level YAML key caps how long a job may run before it's killed, protecting against a hung step that never exits?

open · self-checked

UK Calculators had `strict: true` in tsconfig.json and `ignoreBuildErrors: true` in next.config.mjs at the same time, for months, before a real gate replaced that setup. Explain why those two settings together are worse than having no type checking at all.

Show answer

strict: true makes the type checker do real work and correctly identify errors, but ignoreBuildErrors: true tells the build step to ship anyway regardless of what the checker found. The combination produces every outward signal of a type-safe project (a strict config, a checker that runs) while guaranteeing that nothing it finds ever blocks a deploy. That's worse than no type checking, because a project with no checker at least looks unguarded; this one looked guarded and wasn't, which is exactly why twelve real errors, including an as any at a tax-data boundary, shipped silently for months before anyone looked.

Lived experience

Sources

  • Workflow syntax for GitHub Actions
    GitHub Docs
    Canonical reference for the on trigger key (event lists vs filtered branches form) and jobs.<job_id>.runs-on
    docs.github.com
  • Storing workflow data as artifacts
    GitHub Docs
    Confirms actions/upload-artifact@v4 syntax (name, path, retention-days) and the retention-days ceiling behaviour
    docs.github.com
Back to guide overview