Set up a GitHub Actions workflow that runs lint, typecheck, tests and a build on every pull request.
Why it matters
"It works on my machine" is not a release process. Continuous integration (CI) runs the same checks on a clean machine for every push and pull request, so broken code is caught before it is merged, not after it reaches users. On most front-end teams a pull request cannot be merged until CI is green, and setting up or fixing a pipeline is a normal mid-level task.
What a front-end pipeline checks
A typical pipeline runs these steps, cheapest first, so failures show up quickly:
- Install dependencies exactly as locked:
npm ci(usespackage-lock.json, fails if it is out of date). - Lint:
npm run lint(ESLint, including accessibility and React hooks rules). - Type check:
npm run typecheck, usuallytsc --noEmit. - Unit and integration tests:
npm test(Vitest or Jest) in run-once mode, not watch mode. - Build:
npm run build, which catches import errors and framework build errors. - Optional, often in separate jobs: end-to-end tests (Playwright), a Lighthouse budget check, bundle size limits, and a preview deploy.
Make sure each of these is a script in package.json, so the commands are the same locally and in CI:
{
"scripts": {
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"build": "vite build"
}
}
GitHub Actions basics
GitHub Actions reads YAML workflow files from .github/workflows/. A workflow has triggers (on), one or more jobs, and each job has steps that run on a fresh virtual machine called a runner. Actions is free for public repositories, and private repositories get a monthly allowance of free minutes on GitHub's hosted runners (see GitHub's billing docs, checked on 2026-10-23).
# .github/workflows/ci.yml
name: CI
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm test
- run: npm run build
What each part does:
on: run on every pull request and on pushes tomain.permissions: contents: read: give the job's token the least access it needs.concurrency: cancel an older run on the same branch when you push again, saving minutes.actions/checkout: clone the repository onto the runner.actions/setup-node: install the Node version from.nvmrcand cache npm's download cache between runs.- Each
runstep is a shell command. If any exits with a non-zero code, the job fails and the PR shows a red cross.
Making CI useful
- Required checks. In the repository settings, add a branch protection rule (or ruleset) for
mainthat requires the CI job to pass before merging. - Fast feedback. Cache dependencies, cancel superseded runs, and split slow work (e2e) into a separate job that runs in parallel.
- Same as local. Pin the Node version (
.nvmrcorengines) and usenpm ci, so CI and laptops agree. - Readable failures. One step per check, so the failing step's name tells you what broke.
- Secrets. Never put tokens in the YAML. Store them as repository secrets and reference
${{ secrets.NAME }}. Pull requests from forks do not receive secrets, which protects you from malicious PRs. - Coverage gates (optional): fail the build when test coverage drops below an agreed threshold. Set the bar where it is today and only raise it.
Reading a failed run
Open the PR's Checks tab, select the failed job and expand the red step. Read from the first error, not the last line. Common causes:
npm cifails:package-lock.jsonis out of sync. Runnpm installlocally and commit the lock file.- Tests pass locally but fail in CI: they depend on your time zone, locale, an environment variable or test order.
- Build fails on case: macOS file systems are usually case-insensitive and Linux runners are not, so
import Button from './button'breaks if the file isButton.tsx.
Common mistakes
- Running
npm installinstead ofnpm ci, so CI quietly uses different versions. - Running tests in watch mode, so the job hangs until it times out.
- Only running CI on
main, after the bad code is merged. - Letting a flaky test stay red, so people learn to ignore CI.
- Giving workflows broad token permissions or printing secrets in logs.
Quick recap
- CI runs install, lint, type check, tests and build on a clean machine for every PR.
- GitHub Actions workflows live in
.github/workflows/and are made of triggers, jobs and steps. - Use
npm ci, a pinned Node version, caching, least-privilege permissions and cancelled duplicate runs. - Make the check required on
main, and keep it fast and trustworthy.
Try it
In the Do tab you add this pipeline to a repository of your own, break it on purpose, and fix it.