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.
npm ci, not npm install
Section titled “npm ci, not npm install”- run: npm ciWhat 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 install | npm ci | |
|---|---|---|
| Reads the lockfile | As a hint | As the specification |
| May update the lockfile | Yes | Never |
| Requires a lockfile | No | Yes |
| Fails on lockfile drift | No | Yes |
Deletes node_modules first | No | Yes |
| Speed | Slower | Faster |
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.
Caching
Section titled “Caching”actions/setup-node caches natively:
- uses: actions/setup-node@v7 with: node-version: "22" cache: npmcache: 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.jsonThis 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.
Version matrix
Section titled “Version matrix”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.
The pipeline
Section titled “The pipeline”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: 14Calling 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.
Building
Section titled “Building”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: 7That 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.
Other package managers
Section titled “Other package managers”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: npmnode-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.
Integration tests with service containers
Section titled “Integration tests with service containers”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_testThe 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.
End-to-end tests
Section titled “End-to-end tests”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-depsThe 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 }}/4Upload 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: 7if: 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.
Monorepos
Section titled “Monorepos”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.
Publishing to npm with provenance
Section titled “Publishing to npm with provenance”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.
Determinism
Section titled “Determinism”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: UTCin 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 namedbutton.tsxworks locally and fails on the runner. This is one of the most common CI-only failures in the ecosystem. - Locale.
Intlformatting andlocaleComparedepend 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 installrather thannpm cican 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.
Auditing dependencies
Section titled “Auditing dependencies”- 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: highIts availability depends on the repository — see software supply-chain security.
Bundle size as a check
Section titled “Bundle size as a check”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-limitWith 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.
Flaky tests
Section titled “Flaky tests”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=2This 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.
Common mistakes
Section titled “Common mistakes”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.
Exercise
Section titled “Exercise”- Build the workflow against a Node project and confirm it runs.
- Change a dependency version in
package.jsonwithout regenerating the lockfile. Confirmnpm cifails, and thatnpm installwould not have. - Add
cache: npmand compare install times across two runs. - Add the three-version matrix with
fail-fast: false. - Add the separate build job and confirm the artifact appears.
- Download the artifact with
gh run downloadand 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.
What you learned
Section titled “What you learned”npm ciinstalls the lockfile exactly and fails on drift;npm installdoes neither.- A committed lockfile is a requirement for reproducible CI, not a preference.
cache: npmkeys on the lockfile and caches downloads, notnode_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.
Related lessons
Section titled “Related lessons”Skip the boilerplate — the Professional Toolkit has five production-ready workflow templates.