Branch protection is only as good as the checks it requires. This part builds those checks: a GitHub Actions workflow that runs the same lint, typecheck, test, and build commands a developer runs locally, on a clean runner, on every pull request. The design goal is not sophistication. It is trustworthiness—a green check should mean something specific, and a red one should be fast enough to fix before context is lost.

What "required checks" actually requires

A required check is only useful if it tests something the team actually cares about, and if it fails for reasons developers can reproduce. A CI job that fails intermittently because of a flaky test or a shared external dependency erodes trust faster than having no CI at all—once a red check is assumed to be noise, it stops being examined.

For a Next.js, TypeScript, and Prisma application, four checks form a reasonable minimum:

  • Lint — static rules the codebase already agrees on (ESLint).
  • Typechecktsc --noEmit, catching type errors independent of test coverage.
  • Test — unit and integration tests, run against a real ephemeral PostgreSQL instance rather than mocks, where the code under test touches the database.
  • Buildnext build, which exercises the production compilation path, not just the dev server.

A practical workflow: triggers and jobs

# .github/workflows/ci.yml
name: CI

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

concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run lint

  typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run typecheck

  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: app
          POSTGRES_PASSWORD: app
          POSTGRES_DB: app_test
        ports: ["5432:5432"]
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    env:
      DATABASE_URL: postgresql://app:app@localhost:5432/app_test
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npx prisma migrate deploy
      - run: npm test -- --ci

  build:
    runs-on: ubuntu-latest
    needs: [lint, typecheck]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run build

A few structural choices are worth calling out. The four jobs run in parallel except for build, which depends on lint and typecheck passing first—there is little value compiling a production bundle from code that already fails static checks. The test job uses a services container to run real PostgreSQL for the duration of the job, then applies migrations with prisma migrate deploy before the suite runs, which validates the migrations themselves as a side effect. The concurrency block cancels a stale run automatically when a new commit lands on the same pull request, so the queue does not fill with runs nobody will read.

Caching dependencies without caching mistakes

actions/setup-node's built-in cache: npm option covers the common case—it keys the npm cache off package-lock.json and restores it automatically. For most small projects, that single line is sufficient and preferable to a hand-rolled actions/cache step, which is easy to get subtly wrong (a cache key that never changes, or one so specific it never hits).

ApproachGood forRisk
setup-node built-in cache: npmMost projects, minimal configLimited to the dependency cache itself
Manual actions/cache on node_modulesVery large dependency treesStale node_modules masking a lockfile change if the key is wrong
No cachingSmall, fast installs, simplicitySlower runs, more npm registry load
Build output caching (e.g. Next.js .next/cache)Large repeated buildsCan hide a build that would fail cold; verify occasionally without cache

The safest default is the built-in dependency cache plus no build-output cache to start. Add build caching only once the workflow's runtime becomes a real bottleneck, and periodically run a cold build to confirm the cache is not hiding a problem.

Matrix strategy: when it helps and when it's overkill

A build matrix—running the same job across multiple Node versions or operating systems—is valuable when the application genuinely needs to support more than one runtime target, such as a published library. For an application deployed to a single container runtime in production, a matrix mostly multiplies CI minutes without multiplying confidence. Keep the workflow single-version until there is a concrete reason to widen it, such as planning a Node upgrade and wanting to validate both versions during the transition.

Turning failures into fast feedback

A red check is only useful if the failure is easy to diagnose from the log alone. A few habits keep failures readable:

  • Run linters and type checkers with their normal, human-readable output rather than a compact CI-only format.
  • Fail fast within a job (test -- --ci rather than a mode that swallows failures and continues).
  • Upload test artifacts—coverage reports, failure screenshots—only when they add real diagnostic value; an artifact nobody opens is wasted minutes.
  • Keep job names in the workflow file matched to the required check names in branch protection, so a failing check in the pull request UI points at an unambiguous job.

Keeping CI honest with the local scripts

The workflow above only stays trustworthy if npm run lint, npm run typecheck, npm test, and npm run build are the same commands developers run locally inside the dev container from part three. If CI silently diverges—extra flags, a different Node version, a skipped step—then a green check stops predicting local success, and developers learn to distrust it. Treat package.json's scripts as the single source of truth, and have the workflow call them rather than reimplementing the same logic inline.

Common mistakes

  • Running tests against mocked database calls only, then discovering a real schema mismatch in staging.
  • Letting the build job run before lint and typecheck, wasting minutes compiling code that was already known to be broken.
  • Adding a matrix "for safety" on a single-target application, tripling CI time for no corresponding gain in confidence.
  • Caching node_modules directly with a cache key that does not include the lockfile hash, leading to stale dependencies.
  • Letting flaky tests stay in the suite "for now," which trains the team to re-run first and investigate never.

Practical checklist

  • CI runs on every pull request and on pushes to main.
  • Lint, typecheck, test, and build each run as separate, clearly named jobs.
  • The test job runs against a real ephemeral PostgreSQL instance and applies migrations first.
  • Dependency installation is cached with a key tied to the lockfile.
  • concurrency cancels stale runs on the same pull request.
  • Job names in the workflow match the required check names in branch protection.
  • CI calls the same package.json scripts developers run locally—no parallel logic.

Previous: Designing a GitHub Workflow That a Small Team Will Actually Use

Next: Pull Request Preview Environments: Review the Actual Change. Passing checks confirm the code is correct in isolation; the next part gives reviewers a running instance of the actual change.