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:

  • Triggerson: push and on: pull_request scoped to the branches you protect, typically main.
  • Permissions — a CI job that only reads code and runs tests needs no write access to the repository. Set permissions: contents: read explicitly 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, with cache: '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 with fail-fast: false so 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

  1. Add .github/workflows/ci.yml with checkout, setup-node, and npm ci as the foundation.
  2. Set permissions: contents: read at the workflow level so the job carries no more access than it needs.
  3. Add npm run lint and npm run build steps so style and compile-time issues surface alongside test failures.
  4. Introduce strategy.matrix with the Node.js versions your package actually supports, plus fail-fast: false.
  5. Enable cache: 'npm' in setup-node so each matrix leg restores the shared npm download cache instead of hitting the registry cold.
  6. Add a concurrency group so superseded runs on the same ref are cancelled automatically.
  7. Upload coverage as a build artifact from a single matrix leg, not all of them.
  8. Add a downstream quality-gate job with needs and if: always() that fails if any matrix leg failed.
  9. In the repository’s branch protection settings, require the Quality Gate check before merging into main.

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 plain install — so a broken or drifted lockfile fails the build instead of installing silently.
  • Set permissions: contents: read explicitly; 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: false so a matrix failure doesn’t hide other failures.
  • Cache the package manager’s download cache, not the installed node_modules tree.
  • Add a concurrency group 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_TOKEN for 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.json without updating package-lock.json, push it, and observe how npm ci fails — then fix it by regenerating the lockfile locally and committing it.
  • Add the quality-gate job 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 concurrency block, 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.