Node.js CI Pipeline
A Node.js project’s continuous integration pipeline has one job: prove, on every push and pull request, that the code installs cleanly, passes lint and tests, and builds without human intervention. This lesson builds that pipeline incrementally — from a single-job workflow to a matrix-tested, cached, coverage-reporting pipeline with one consolidated status check that branch protection can rely on.
Overview / How it works
Each workflow run starts a fresh, ephemeral virtual machine. Nothing from a previous run persists unless you explicitly cache or upload it. For a Node.js project this means every run must: check out the repository, install a Node.js runtime, install dependencies from the lockfile, then run lint, test, and build scripts. Because the runner is thrown away afterward, speed depends entirely on caching the right things — primarily the npm download cache, not node_modules itself — and on running independent checks in parallel rather than in one long sequential job.
A mature Node CI pipeline also tests against more than one Node.js version when the package is a library or shared service, reports code coverage as a retrievable artifact, and exposes a single required status check so branch protection rules do not need to track every matrix leg individually.
Syntax or workflow structure
The building blocks used throughout this lesson:
- Triggers —
on: pushandon: pull_requestscoped to the branches you protect, typicallymain. - Permissions — a CI job that only reads code and runs tests needs no write access to the repository. Set
permissions: contents: readexplicitly at the workflow level instead of relying on the default token, which can otherwise carry broader access depending on repository settings. actions/checkout— clones the repository into the runner’s workspace.actions/setup-node— installs the requested Node.js version and, withcache: 'npm', automatically caches and restores the npm download cache keyed on your lockfile hash.strategy.matrix— runs the same job once per listed Node.js version in parallel. Pair it withfail-fast: falseso a failure on one version does not cancel the others; you want to see every failure, not just the first.concurrency— cancels a stale, still-running workflow for the same branch or PR when a new commit supersedes it, saving runner minutes.needs— makes one job depend on another’s outcome, used here to build a single quality-gate check.
On action versions: pinning to a major-version tag such as actions/checkout@v4 is readable and receives patch and minor updates automatically, but a compromised or re-tagged release could silently change what runs. Pinning to a full commit SHA, e.g. actions/checkout@11bd719..., is immutable and the stronger security posture for anything with elevated permissions or secrets — the trade-off is you must update the SHA manually (or with a bot like Dependabot) to get fixes.
Examples
Example 1 — minimal single-job pipeline. This is the smallest correct Node CI workflow: it checks out the code, installs Node 20 with npm caching enabled, installs dependencies deterministically, and runs the test script.
name: Node.js CI
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
Expected behavior: on every push or PR targeting main, one job runs on Node 20. npm ci installs exactly what package-lock.json specifies and fails immediately if the lockfile and package.json are out of sync — a sign the developer forgot to commit a lockfile update.
Example 2 — matrix testing with lint and build. Real projects need to catch version-specific regressions and run more than just tests.
name: Node.js CI
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version: [18.x, 20.x, 22.x]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Run unit tests
run: npm test -- --ci
- name: Build
run: npm run build
Expected behavior: three parallel jobs run, one per Node.js version. fail-fast: false means a Node 18 failure does not cancel the Node 22 job — you get a complete picture of which versions are affected. If a second commit lands on the same PR before the first run finishes, concurrency cancels the outdated run automatically.
Example 3 — coverage artifact and a single quality gate. With three matrix legs, branch protection would otherwise need to list three separate required checks, and adding a fourth Node version would silently create an unprotected gap. A summary job fixes that.
name: Node.js CI
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version: [18.x, 20.x, 22.x]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Run tests with coverage
run: npm test -- --coverage --ci
- name: Upload coverage report
if: matrix.node-version == '20.x'
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
retention-days: 14
quality-gate:
name: Quality Gate
needs: build-and-test
runs-on: ubuntu-latest
if: always()
steps:
- name: Check matrix result
run: |
if [ "${{ needs.build-and-test.result }}" != "success" ]; then
echo "One or more Node.js versions failed."
exit 1
fi
echo "All Node.js versions passed."
Expected behavior: coverage uploads once, from the Node 20 leg only, avoiding three duplicate artifacts. The quality-gate job uses if: always() so it still runs and reports failure even when a matrix leg fails (by default a failed dependency skips downstream jobs). Branch protection only needs to require Quality Gate — it stays correct even if you later add or remove Node versions from the matrix.
Step by step
- Add
.github/workflows/ci.ymlwith checkout, setup-node, andnpm cias the foundation. - Set
permissions: contents: readat the workflow level so the job carries no more access than it needs. - Add
npm run lintandnpm run buildsteps so style and compile-time issues surface alongside test failures. - Introduce
strategy.matrixwith the Node.js versions your package actually supports, plusfail-fast: false. - Enable
cache: 'npm'insetup-nodeso each matrix leg restores the shared npm download cache instead of hitting the registry cold. - Add a
concurrencygroup so superseded runs on the same ref are cancelled automatically. - Upload coverage as a build artifact from a single matrix leg, not all of them.
- Add a downstream
quality-gatejob withneedsandif: always()that fails if any matrix leg failed. - In the repository’s branch protection settings, require the
Quality Gatecheck before merging intomain.
Common Mistakes
Mistake 1 — using npm install in CI. npm install can update package-lock.json and resolve slightly different transitive versions than what was tested locally, making CI non-reproducible.
- name: Install dependencies
run: npm install
Correction — use npm ci, which installs strictly from the lockfile and fails fast if the lockfile is out of date instead of silently rewriting it:
- name: Install dependencies
run: npm ci
Mistake 2 — caching node_modules directly instead of the npm download cache. Caching the installed node_modules directory across a version matrix causes native modules built for Node 18 to be restored into a Node 22 job, producing confusing runtime errors that have nothing to do with the actual code change.
- uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node_modules-${{ hashFiles('**/package-lock.json') }}
- run: npm ci
Correction — cache the npm download cache via setup-node‘s built-in support, which is automatically keyed per Node version and lockfile, and let npm ci rebuild node_modules fresh every run:
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
Best Practices
- Always use
npm ci(or the CI-equivalent for pnpm/yarn) — never plaininstall— so a broken or drifted lockfile fails the build instead of installing silently. - Set
permissions: contents: readexplicitly; add write scopes only on the specific job that needs them, such as one that publishes a package. - Test against the Node.js versions you actually claim to support, and use
fail-fast: falseso a matrix failure doesn’t hide other failures. - Cache the package manager’s download cache, not the installed
node_modulestree. - Add a
concurrencygroup to cancel superseded runs and stop paying for outdated commits. - Collapse a version matrix into one downstream status check so branch protection rules stay correct as the matrix changes.
- Treat pull requests from forks as untrusted: the default
GITHUB_TOKENfor fork PRs is already read-only by default, and this CI workflow should never need secrets — keep it that way rather than adding deploy credentials here. - Pin third-party actions to a known-good major tag for routine CI steps, and to a commit SHA for anything privileged, self-hosted, or security-sensitive.
Practice Exercises
- Starting from Example 1, add a matrix over Node.js versions 18.x, 20.x, and 22.x with
fail-fast: false, and confirm three separate job runs appear in the Actions tab. - Deliberately edit
package.jsonwithout updatingpackage-lock.json, push it, and observe hownpm cifails — then fix it by regenerating the lockfile locally and committing it. - Add the
quality-gatejob from Example 3 to a matrix workflow, then require it (not the matrix legs) in your branch protection settings, and verify a failing matrix leg still blocks the merge. - Add a
concurrencyblock, push two commits to the same branch a few seconds apart, and confirm the first run is cancelled in favor of the second.
Summary
A production-quality Node.js CI pipeline installs dependencies deterministically with npm ci, runs lint, test, and build in parallel matrix legs across supported Node versions, caches the package manager’s download cache rather than node_modules, and exposes one consolidated status check for branch protection. Minimal explicit permissions and careful action pinning keep the pipeline itself from becoming an attack surface. The next lesson in this section builds on this foundation to add stricter quality gates such as coverage thresholds and dependency vulnerability scanning.
