GitHub Actions: A CI Pipeline That Stays Fast
A practical GitHub Actions setup for a Node project — caching, concurrency cancellation, matrix builds, and the permissions and secrets mistakes to avoid.
Table of contents
- The baseline workflow
- Run independent checks in parallel
- Matrix builds
- Cache more than node_modules
- Security: three things that matter
- Only run what changed
- Frequently asked questions
- Should I use GitHub Actions for deployment?
- How do I speed up a slow test suite?
- Are self-hosted runners worth it?
- How do I stop CI running twice on a PR from a branch?
- Related reading
- References
A CI pipeline that takes eight minutes gets ignored. Here is a setup that stays under two for a typical Node project.
The baseline workflow#
name: CI
on:
push:
branches: [main]
pull_request:
# Cancel superseded runs. Without this, pushing three commits to a PR runs
# three full pipelines and you pay for two you no longer care about.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# Least privilege. The default token is read/write on everything.
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
# Caches ~/.npm keyed on the lockfile. One line, usually 30-60s saved.
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm run buildTwo of those lines do most of the work: concurrency with cancel-in-progress, and cache: npm.
Run independent checks in parallel#
Lint, typecheck and test do not depend on each other. Running them as separate jobs turns a sum into a maximum:
jobs:
lint:
runs-on: ubuntu-latest
steps: [/* checkout, setup, npm ci */, { run: npm run lint }]
typecheck:
runs-on: ubuntu-latest
steps: [/* ... */, { run: npm run typecheck }]
test:
runs-on: ubuntu-latest
steps: [/* ... */, { run: npm test }]The trade-off is that each job re-runs npm ci. With the cache warm that is fast, and it is almost always worth it. If install genuinely dominates, use one job with a build matrix instead.
Matrix builds#
strategy:
fail-fast: false # report all failures, not just the first
matrix:
node: [20, 22, 24]fail-fast: false matters for a library: knowing it breaks on Node 20 and 24 is more useful than knowing it breaks on 20.
Cache more than node_modules#
For Next.js, caching the build cache turns a cold build into an incremental one:
- uses: actions/cache@v4
with:
path: |
~/.npm
.next/cache
key: ${{ runner.os }}-next-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.ts', '**/*.tsx') }}
restore-keys: |
${{ runner.os }}-next-${{ hashFiles('**/package-lock.json') }}-The restore-keys fallback is the important part: on a source change the exact key misses, but the partial key still restores the previous build cache, so the build is incremental rather than cold.
Security: three things that matter#
Pin third-party actions to a SHA. A tag is mutable — whoever controls the repo can move v1 to point at anything, and it runs with your secrets:
- uses: some-org/some-action@a1b2c3d4e5f6... # not @v1Official actions/* are lower risk; anything else should be pinned.
Never use pull_request_target with a checkout of the PR head. That combination runs untrusted code with write permissions and access to secrets. It is the single most exploited GitHub Actions misconfiguration.
Do not interpolate untrusted input into a shell. A PR title is attacker-controlled:
# Script injection: a title containing backticks executes
- run: echo "Title: ${{ github.event.pull_request.title }}"
# Safe: pass through the environment
- run: echo "Title: $TITLE"
env:
TITLE: ${{ github.event.pull_request.title }}Only run what changed#
In a monorepo, path filters stop the whole pipeline running for a docs typo:
on:
pull_request:
paths:
- 'apps/web/**'
- 'packages/ui/**'
- '.github/workflows/web.yml'Note the workflow file itself is in the list — otherwise a change to the pipeline does not test the pipeline.
Frequently asked questions#
Should I use GitHub Actions for deployment?#
It works well. For Vercel or Netlify, their own Git integration is simpler and gives you preview URLs for free — use Actions for the checks and let the platform deploy.
How do I speed up a slow test suite?#
Shard it across jobs with a matrix, and cache anything derived. Beyond that the fix is usually in the tests, not the CI: unmocked network calls and a real database per test are the usual culprits.
Are self-hosted runners worth it?#
For heavy builds or when you need specific hardware, yes. They come with real maintenance and security responsibility — a self-hosted runner on a public repo can be used to run arbitrary code.
How do I stop CI running twice on a PR from a branch?#
The on: [push, pull_request] pair triggers both. Restrict push to branches: [main], as above.