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).
- Typecheck —
tsc --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.
- Build —
next 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).
| Approach | Good for | Risk |
|---|---|---|
setup-node built-in cache: npm | Most projects, minimal config | Limited to the dependency cache itself |
Manual actions/cache on node_modules | Very large dependency trees | Stale node_modules masking a lockfile change if the key is wrong |
| No caching | Small, fast installs, simplicity | Slower runs, more npm registry load |
Build output caching (e.g. Next.js .next/cache) | Large repeated builds | Can 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 -- --cirather 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
buildjob run beforelintandtypecheck, 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_modulesdirectly 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.
-
concurrencycancels 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.jsonscripts 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.

