GitHub Actions — Node.js CI
A production-ready GitHub Actions workflow that runs your Node.js test suite against Node 18, 20, and 22 using a build matrix — so you catch version-specific regressions before they reach production.
Overview
This workflow triggers on every push to main and on all pull requests targeting main.
It uses GitHub Actions' build matrix to run the same job three times in parallel — once for
each active Node.js LTS version (18, 20, and 22). This approach catches version-specific API changes or
deprecations early, without needing to maintain separate workflow files per version.
The workflow checks out the code, installs the correct Node.js version using the official
actions/setup-node action (with npm caching enabled), installs dependencies with
npm ci, runs the build step if present, and finally executes the test suite.
It's intentionally minimal — add linting, coverage reporting, or artifact uploads on top as needed.
.github/workflows/ci.yml (or any .yml name) inside your repository.
The .github/workflows/ directory must exist at the root of your repo.
Full YAML Copy-paste ready
name: Node.js CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x, 22.x]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm run build --if-present
- run: npm test
Key sections explained
The on trigger block
The on key defines which events activate the workflow. Here, two events are configured:
push to the main branch and pull_request targeting main.
The branches filter under each event means the workflow only runs for changes to or targeting
main — pushes to feature branches won't trigger it unless a PR is opened against main.
If you want CI to run on every branch push (not just main), remove the branches
filter entirely, or replace it with a wildcard: branches: ['**'].
The strategy.matrix block
The strategy.matrix key instructs GitHub Actions to run the job multiple times with different
variable values. Here, node-version: [18.x, 20.x, 22.x] produces three parallel jobs.
Each job gets a different value for matrix.node-version, which is then referenced in the step
via ${{ matrix.node-version }}. The .x suffix tells setup-node
to resolve the latest patch release within that major version (e.g. 20.x might resolve to 20.19.1).
fail-fast: false under strategy to keep this behavior explicit, or set it to
true to cancel all jobs immediately on the first failure.
npm ci vs npm install
This workflow uses npm ci rather than npm install. The difference matters in CI:
npm ci installs exactly what's in package-lock.json (failing if it's out of sync
with package.json), never updates the lockfile, and deletes node_modules before
installing. This produces a clean, deterministic install every time — which is exactly what you want in CI.
npm install may silently update the lockfile or resolve differently across runs.
The cache: 'npm' option in setup-node caches the npm dependency cache directory
(keyed by package-lock.json), so subsequent runs with unchanged dependencies are much faster.
The --if-present flag
npm run build --if-present runs the build script from package.json
only if it exists. Without this flag, the step would fail with an error if no build script is
defined — which would break the workflow for projects (like pure Node.js CLIs or libraries) that don't have
a build step. It's a safe default: it runs your build if you have one, and silently succeeds if you don't.
Tips & variations
Add a Node.js version to the matrix
When Node.js releases a new LTS version, just append it to the array:
node-version: [18.x, 20.x, 22.x, 24.x]. GitHub Actions will automatically add a fourth
parallel job — no other changes needed.
Cache with a Yarn or pnpm lockfile
If your project uses Yarn or pnpm, change cache: 'npm' to cache: 'yarn' or
cache: 'pnpm', and replace the npm ci step with
yarn install --frozen-lockfile or pnpm install --frozen-lockfile respectively.
Upload a coverage report
After the npm test step, you can upload coverage to Codecov by adding:
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
Pin action versions
This workflow uses @v4 tags for actions. For maximum supply-chain security, pin to a full
commit SHA (e.g. actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683). Tools like
Dependabot (see the Dependabot example)
can keep these pins up to date automatically.