Skip to content

Node.js CI with GitHub Actions: Complete Pipeline

Lesson 2 of 8Beginner → Intermediate12 min readGitHub Actions & CI/CD · Continuous IntegrationVerified: actions/setup-node v7, actions/upload-artifact v7, August 2026

The JavaScript ecosystem’s CI has one decision that matters more than any other: whether the install respects the lockfile. Everything else follows from getting that right.

The complete workflow is at examples/github-actions/nodejs-ci/ci.yml, validated by npm run check:workflows.

- run: npm ci

What it doesInstalls exactly the dependency tree recorded in package-lock.json, deleting node_modules first.

Why we run it`npm install` resolves versions afresh and may update the lockfile. In CI that means the tested tree can differ from the committed one — the pipeline validates something nobody has.

Expected resultA clean install. It fails if package.json and the lockfile disagree, which is the behaviour you want.

The difference:

npm installnpm ci
Reads the lockfileAs a hintAs the specification
May update the lockfileYesNever
Requires a lockfileNoYes
Fails on lockfile driftNoYes
Deletes node_modules firstNoYes
SpeedSlowerFaster

That “fails on drift” row is a feature. If someone edits package.json without regenerating the lockfile, npm ci fails and the pull request is blocked — which is the correct outcome, because the two files disagreeing means nobody knows what will actually install.

actions/setup-node caches natively:

- uses: actions/setup-node@v7
with:
node-version: "22"
cache: npm

cache: npm keys on package-lock.json, so it invalidates exactly when dependencies change. cache: yarn and cache: pnpm work the same way against their lockfiles.

For a monorepo with lockfiles in several places, name them:

cache: npm
cache-dependency-path: |
package-lock.json
packages/*/package-lock.json

This caches npm’s download cache, not node_modules. npm ci still runs and still links the tree, which takes a few seconds. Caching node_modules directly is possible and more fragile — it must key on the Node version and platform too, and a stale cache produces failures that look like dependency bugs.

Node’s release cadence means supporting several lines. Active LTS plus current is the usual choice:

strategy:
fail-fast: false
matrix:
node-version: ["20", "22", "24"]

Quoted, as always — an unquoted 20 is a number and happens to work, and the habit is worth keeping uniform because it is 3.10 in the Python case that silently breaks.

For a library, testing every supported line is the point. For an application deployed on one version, a matrix is mostly wasted minutes — test what you ship, plus the next line if you plan to move.

jobs:
test:
name: Test (Node ${{ matrix.node-version }})
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
node-version: ["20", "22", "24"]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node-version }}
cache: npm
- run: npm ci
- name: Lint
run: npm run lint
- name: Type check
run: npm run typecheck
- name: Test
run: npm test -- --coverage
- name: Upload coverage
if: always()
uses: actions/upload-artifact@v7
with:
name: coverage-node-${{ matrix.node-version }}
path: coverage/
retention-days: 14

Calling npm scripts rather than tools directly is deliberate. npm run lint works identically on a developer’s machine and in CI, and the definition lives in package.json where the project already keeps it. A workflow invoking eslint with a long flag list duplicates configuration that will drift.

The -- in npm test -- --coverage passes the flag through to the underlying test runner rather than to npm.

A build usually belongs in its own job, because it needs one Node version rather than all of them:

build:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: "22"
cache: npm
- run: npm ci
- run: npm run build
- uses: actions/upload-artifact@v7
with:
name: dist
path: dist/
retention-days: 7

That pays a second install, which is the cost of job isolation. The alternative — building inside the matrix — produces the same artifact several times and then has to decide which one to keep.

The artifact is what a deployment job would later consume, which is the build once, promote principle: production deploys this exact dist/, not a rebuild.

pnpm needs its own setup before setup-node, because the cache lookup requires the binary:

- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v7
with:
node-version: "22"
cache: pnpm
- run: pnpm install --frozen-lockfile

--frozen-lockfile is pnpm’s npm ci equivalent.

Yarn varies by major version — classic Yarn uses --frozen-lockfile, Berry uses --immutable. Both are the same idea: fail rather than silently resolving something new.

Taking the Node version from the repository

Section titled “Taking the Node version from the repository”

Hardcoding node-version: "24" in every workflow means the version lives in as many places as you have workflows, and none of them agree with what developers run.

- uses: actions/setup-node@v7
with:
node-version-file: .nvmrc
cache: npm

node-version-file reads .nvmrc, or the engines.node field in package.json, or a .tool-versions file. The version then has one definition that both nvm locally and CI resolve from, so an upgrade is one commit rather than a search.

Use an explicit matrix instead when you deliberately support several versions — a published library must work on more than the version its authors happen to use. An application deployed to one runtime usually should not have a matrix at all: testing on three versions you do not deploy is spending runner minutes to answer a question nobody asked.

jobs:
integration:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:18
env:
POSTGRES_PASSWORD: ci-only-not-a-real-secret
POSTGRES_DB: app_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- run: npm run migrate
- run: npm run test:integration
env:
DATABASE_URL: postgresql://postgres:ci-only-not-a-real-secret@localhost:5432/app_test

The health check is the load-bearing part. Without it, steps begin as soon as the container starts, and the first query hits a Postgres still initialising — producing a connection error that reads like a bad DATABASE_URL.

The password is deliberately inline and obviously disposable. It protects a database that exists for the length of one job and is reachable only from that runner. Storing it as a repository secret would mask it in logs, making the connection string unreadable while protecting nothing.

Browser tests are the slowest and most artifact-hungry part of most Node pipelines, and they benefit from three specific things.

Cache the browsers, not just the packages. Playwright downloads browser binaries separately from npm packages, and they are large:

- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps
- if: steps.playwright-cache.outputs.cache-hit == 'true'
run: npx playwright install-deps

The split is deliberate. On a cache hit the browsers are present but their system dependencies are not, because those are installed into the runner image rather than into the cached directory. Skipping install-deps on a hit produces browsers that fail to launch with a missing shared library — a confusing failure that only appears on cached runs.

Shard across runners, because a browser suite parallelises badly within one machine:

strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- run: npx playwright test --shard=${{ matrix.shard }}/4

Upload the evidence on failure. A browser test that fails in CI and passes locally is unresolvable without the trace:

- uses: actions/upload-artifact@v7
if: failure()
with:
name: playwright-report-${{ matrix.shard }}
path: |
playwright-report/
test-results/
retention-days: 7

if: failure() rather than if: always() here, because a passing run’s trace files are large and nobody opens them. The artifact name carries the shard, since names must be unique within a run.

A workspace repository has two problems CI has to answer: installing once, and not testing everything on every change.

- uses: actions/setup-node@v7
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: '**/package-lock.json'
- run: npm ci
- run: npm run test --workspaces --if-present

--if-present stops the command failing on a workspace with no test script, which is normal for a package of shared types or config.

Scoping the work to what changed is the part that saves real time, and there are two approaches.

Path filters are the simple one, and they scale badly — one job per package, each with its own paths:, and a required check that never reports when its paths do not match. That last part is the trap covered in matrix builds: a required check that is skipped blocks the pull request forever.

A dynamic matrix scales better:

jobs:
changed:
runs-on: ubuntu-latest
outputs:
packages: ${{ steps.detect.outputs.packages }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- id: detect
run: |
changed="$(git diff --name-only "origin/${GITHUB_BASE_REF}...HEAD" \
| grep '^packages/' | cut -d/ -f2 | sort -u | jq -R . | jq -sc .)"
echo "packages=${changed:-[]}" >> "$GITHUB_OUTPUT"
test:
needs: changed
if: needs.changed.outputs.packages != '[]'
strategy:
fail-fast: false
matrix:
package: ${{ fromJSON(needs.changed.outputs.packages) }}
runs-on: ubuntu-latest
steps:
- run: echo "testing ${{ matrix.package }}"

fetch-depth: 0 is required — the default checkout is shallow and git diff against the base branch has nothing to compare with. The if: guard handles the empty case, because a matrix built from an empty array produces zero jobs and a required check that never reports.

If the pipeline publishes a package, the npm token is usually the last long-lived credential in it. npm supports OIDC-backed provenance, which both removes the need for a stored token in the newer flow and attaches a verifiable statement about which workflow built the package:

jobs:
publish:
needs: test
runs-on: ubuntu-latest
environment: npm
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version-file: .nvmrc
registry-url: https://registry.npmjs.org
- run: npm ci
- run: npm publish --provenance --access public

--provenance requires id-token: write and publishes a signed statement linking the package version to this repository, commit and workflow. Consumers can then check that a release came from the source repository it claims — the same idea as build provenance attestations, applied to a package registry.

registry-url on setup-node is what writes the .npmrc the publish step reads. Without it, npm publish does not know where to authenticate and fails in a way that suggests a permissions problem.

Put the publish job behind an environment: with required reviewers. A tag push should propose a release, not complete one.

The recurring causes of “passes locally, fails in CI” in Node projects:

  • Time zone. Runners are UTC. Any test asserting a formatted local date will disagree with a developer machine. Set TZ: UTC in the job and in local test scripts so both agree.
  • Filesystem case sensitivity. macOS is case-insensitive by default; Linux is not. import './Button' for a file named button.tsx works locally and fails on the runner. This is one of the most common CI-only failures in the ecosystem.
  • Locale. Intl formatting and localeCompare depend on the available ICU data, which differs between Node builds.
  • Available cores. Test runners size their worker pool from the CPU count, so a suite with isolation bugs fails at a different rate on a runner than on a laptop.
  • Install order. A project using npm install rather than npm ci can resolve a different tree on a different day — the argument this page opened with.
env:
TZ: UTC
CI: "true"

CI is set automatically by Actions, but declaring it makes the intent explicit for anyone reading the file, and many tools change behaviour on it — disabling watch mode, interactive prompts and progress spinners, all of which hang or produce unreadable logs in CI.

Type checking is a separate step from building

Section titled “Type checking is a separate step from building”

A common mistake is assuming the build proves the types are sound. For most modern toolchains it does not: bundlers like esbuild, swc and Vite strip types without checking them, because that is what makes them fast. A project can build cleanly and be full of type errors.

- run: npx tsc --noEmit

--noEmit runs the full type check and writes nothing. It is the only step in a typical pipeline that actually validates the types.

For a monorepo using project references, tsc --build respects the dependency graph and can reuse previous results:

- name: Cache TypeScript build info
uses: actions/cache@v6
with:
path: '**/*.tsbuildinfo'
key: tsbuildinfo-${{ runner.os }}-${{ github.sha }}
restore-keys: tsbuildinfo-${{ runner.os }}-
- run: npx tsc --build

.tsbuildinfo records what was checked and when, so an incremental run only rechecks what changed. The restore-keys fallback matters more than the exact key here: a partial hit still saves most of the work, and a cold check of a large monorepo can take minutes.

- name: Audit production dependencies
run: npm audit --audit-level=high --omit=dev

--omit=dev is the flag that makes this usable. A development-only advisory in a build tool is not a vulnerability in your deployed application, and a pipeline that fails on every one of them is a pipeline people learn to ignore. Audit production dependencies strictly; report on development ones separately.

npm audit fails the step on any finding at or above the level, including findings with no available fix. That combination is what produces a permanently red pipeline. Two workable policies:

  • Fail on high-and-above findings that have a fix available, and open an issue for the rest.
  • Run the strict audit on a schedule rather than on every pull request, so the signal arrives without blocking unrelated work.

On the pull request itself, dependency review is a better fit, because it reports what this change adds rather than the state of the whole tree:

- uses: actions/dependency-review-action@v5
with:
fail-on-severity: high

Its availability depends on the repository — see software supply-chain security.

For anything shipped to a browser, bundle size is a regression that no test catches and every user feels.

- name: Check bundle size
run: npx size-limit

With a size-limit configuration declaring a budget per entry point, this fails when a change pushes a bundle over its limit — usually a dependency added without realising what it pulls in.

The reason to enforce it in CI rather than review it periodically is that the cause is cheapest to identify at the moment it appears. Six weeks later, the bundle is 200 KB larger and nobody knows which of forty merges did it.

A test that fails intermittently costs more than a test that fails consistently, because it trains everyone to re-run rather than to read.

Retries are the obvious response and they are a trade:

- run: npx playwright test --retries=2

This converts a flaky failure into a pass, and it also hides a real intermittent bug — the two are indistinguishable from the outside. Retries are defensible for browser tests, where genuine environmental timing variance exists. They are much harder to justify for unit tests, where a flake almost always means shared state.

What makes retries safe is treating them as a signal rather than a fix:

  • Configure the runner to report which tests needed a retry, and publish that in the job summary.
  • Quarantine a test that retries regularly rather than leaving it retrying forever.
  • Never retry the whole job. Re-running a job re-runs everything, including the deployment steps some workflows put after tests, and it hides which test was unstable.

npm install in CI. Ignores lockfile drift and may test a different tree than you ship.

No committed lockfile. Builds become non-reproducible and fail for external reasons.

Caching node_modules. Fragile; cache the download cache.

Running tools directly instead of npm scripts. Duplicates configuration that drifts.

A matrix for an application deployed on one version. Minutes for no information.

Building inside the matrix. Produces several artifacts and no clear answer about which is the one.

Forgetting -- before a runner flag. The flag goes to npm rather than the test runner.

  1. Build the workflow against a Node project and confirm it runs.
  2. Change a dependency version in package.json without regenerating the lockfile. Confirm npm ci fails, and that npm install would not have.
  3. Add cache: npm and compare install times across two runs.
  4. Add the three-version matrix with fail-fast: false.
  5. Add the separate build job and confirm the artifact appears.
  6. Download the artifact with gh run download and check the contents.

Step 2 is the one worth doing deliberately — the failure it produces is the pipeline protecting you, and seeing it once makes the npm ci rule stick.

  • npm ci installs the lockfile exactly and fails on drift; npm install does neither.
  • A committed lockfile is a requirement for reproducible CI, not a preference.
  • cache: npm keys on the lockfile and caches downloads, not node_modules.
  • Call npm scripts so CI and local development run the same commands.
  • A build job separate from the test matrix produces one artifact rather than several.
  • pnpm and Yarn have equivalent frozen-install flags.
GitHub Actions Security ChecklistAudit your workflows against the failure modes that actually cause incidents. Free and complete.

Skip the boilerplate — the Professional Toolkit has five production-ready workflow templates.