CI for Node.js Projects with GitHub Actions
1. Overview
Every time someone pushes code or opens a pull request, you need to know immediately whether the code still builds and tests still pass. This needs to happen automatically, independent of each person’s machine. This article guides you through setting up a CI pipeline for a Node.js project using GitHub Actions. After reading, you’ll have a workflow that caches dependencies during installation, runs linting, runs tests on multiple Node versions, produces coverage reports, and automatically cancels outdated runs.
2. Setting up a sample project
We’ll use a small module that calculates order totals. First, here’s package.json:
{
"name": "order-service",
"version": "1.0.0",
"type": "module",
"engines": {
"node": ">=22"
},
"scripts": {
"lint": "eslint src test",
"test": "node --test",
"test:coverage": "node --test --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=lcov.info"
}
}
The project uses Node’s built-in test runner (node:test), so you don’t need to install Jest or Mocha. "type": "module" enables import/export syntax. The test:coverage script uses two reporters at once: one prints results to the console, and another writes the lcov.info file for later reference.
The business logic lives in src/order.js:
export function calculateTotal(items, discountPercent = 0) {
if (discountPercent < 0 || discountPercent > 100) {
throw new RangeError('discountPercent must be between 0–100');
}
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
return Math.round(subtotal * (100 - discountPercent)) / 100;
}
This function sums up price * quantity for each item, then applies the discount. If the discount percentage falls outside 0–100, it throws a RangeError.
Corresponding tests go in test/order.test.js:
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { calculateTotal } from '../src/order.js';
test('calculate order total with discount', () => {
const items = [
{ price: 100000, quantity: 2 },
{ price: 50000, quantity: 1 },
];
assert.equal(calculateTotal(items, 10), 225000);
});
test('throw error when discount is invalid', () => {
assert.throws(() => calculateTotal([], 120), RangeError);
});
The node --test command automatically finds files with the .test.js suffix. Try running it locally:
npm install --save-dev eslint @eslint/js
npm test
✔ calculate order total with discount (0.9ms)
✔ throw error when discount is invalid (0.3ms)
ℹ tests 2
ℹ pass 2
ℹ fail 0
ESLint 9 uses flat config. Create eslint.config.js in the project root:
import js from '@eslint/js';
export default [js.configs.recommended];
Finally, make sure to commit package-lock.json. As you’ll see later, CI needs this file.
3. Your first CI workflow
GitHub Actions reads YAML files from the .github/workflows/ directory. Create .github/workflows/ci.yml:
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test
Here’s what each part does:
on: the workflow runs when code is pushed tomainand when a pull request is created to any branch.runs-on: ubuntu-latest: the job runs on a fresh Linux virtual machine provided by GitHub.actions/checkout@v4: clones the source code onto the virtual machine.actions/setup-node@v4: installs Node 22. Thecache: npmoption saves npm’s cache directory, using the hash ofpackage-lock.jsonas the key. On subsequent runs, packages are retrieved from the cache instead of being downloaded from the registry.npm ci: installs dependencies exactly as specified in the lockfile.npm test: runs tests. If the command returns a non-zero exit code, the job fails and the pull request is marked red.
After pushing this file, go to the Actions tab in your repository to see the logs. On the second run, the setup-node step will print something like:
Cache restored successfully
Cache restored from key: node-cache-Linux-x64-npm-3f9a1c...
Note: Don’t use
npm installin CI.npm installcan update the lockfile and install different versions than your local machine.npm cideletesnode_modules, installs exactly as specified in the lockfile, and reports an error immediately ifpackage.jsonandpackage-lock.jsonare out of sync. This behavior helps you catch forgotten lockfile commits early.
4. Running tests on multiple Node versions
Your library or service might need to run on multiple Node versions. The matrix strategy lets you duplicate the job for each version:
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version: [22, 24]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- run: npm ci
- run: npm test
GitHub creates two jobs in parallel, named test (22) and test (24). The expression ${{ matrix.node-version }} is replaced with the corresponding value in each job.
By default, fail-fast is true: if any job fails, all remaining jobs are cancelled. Set fail-fast: false to see the complete picture. For example, you’ll know if an error only happens on Node 24 while Node 22 still passes.
You can expand the matrix to include multiple operating systems:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node-version: [22, 24]
runs-on: ${{ matrix.os }}
This configuration creates 2 × 2 = 4 jobs.
Note: Each dimension you add to the matrix multiplies the number of jobs. For a service that only deploys to Linux with Node 24, a large matrix just wastes CI minutes without providing value. Use matrix only when you genuinely support multiple environments, like when publishing an npm package.
If your project uses only one Node version, keep CI and your development machine aligned on the same version. Create a .nvmrc file containing 24, then declare:
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
5. Separating lint and creating job dependencies
Linting is faster than testing and doesn’t need to run on every Node version. So split linting into its own job and have the test job wait for lint to pass:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run lint
test:
needs: lint
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version: [22, 24]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- run: npm ci
- run: npm test
needs: lint makes the test job wait for lint to succeed. Suppose someone leaves an unused variable:
const unusedTax = 0.1;
The lint job fails with:
/home/runner/work/order-service/src/order.js
1:7 error 'unusedTax' is assigned a value but never used no-unused-vars
✖ 1 problem (1 error, 0 warnings)
The test job won’t run, saving you time from running the entire matrix.
You need to weigh a tradeoff here. Using needs makes total time longer when everything passes, since the two jobs run sequentially. If you prioritize feedback speed, skip needs so lint and test run in parallel.
6. Coverage reports and saving artifacts
All files generated on the virtual machine are deleted when the job ends. To preserve coverage reports, use actions/upload-artifact:
coverage:
needs: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run test:coverage
- uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-report
path: lcov.info
retention-days: 7
The test:coverage script prints a coverage table right in the job’s log:
ℹ start of coverage report
ℹ file | line % | branch % | funcs %
ℹ src/order.js | 100.00 | 100.00 | 100.00
ℹ all files | 100.00 | 100.00 | 100.00
ℹ end of coverage report
The if: always() condition ensures the artifact is uploaded even if tests fail. This is exactly when you need the report most. The lcov.info file appears at the bottom of the workflow run summary page and is deleted after 7 days.
If you want CI to fail when coverage drops below a threshold, from Node 22.8 onwards you can add a threshold flag to your script:
node --test --experimental-test-coverage --test-coverage-lines=80
This command returns exit code 1 if line coverage is below 80%.
7. Optimizing the workflow
Three settings below should be in most CI workflows. Place them at the top level of the file:
name: CI
on:
push:
branches: [main]
paths-ignore:
- '**.md'
- 'docs/**'
pull_request:
paths-ignore:
- '**.md'
- 'docs/**'
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Here’s what each setting does:
paths-ignore: commits that only change README or documentation files won’t trigger CI.permissions: contents: read: restrictGITHUB_TOKENpermissions to read-only. Even if a malicious dependency runs a script in CI, it can’t push code or modify releases.concurrency: if you push three commits in a row to the same pull request, only the latest run is kept. Older runs are cancelled because their results are no longer relevant.
Also add timeout-minutes to each job:
test:
runs-on: ubuntu-latest
timeout-minutes: 10
By default, a job can run up to 6 hours. Even one hanging test—say, forgetting to close a database connection—can burn through your minute quota.
Note:
paths-ignorehas a gotcha when combined with branch protection. Say you require thetestcheck to pass before merging. A pull request that only changes.mdfiles won’t run the workflow, so the check stays in “Expected — Waiting” state forever. You then have two choices: removepaths-ignore, or remove the requirement for that check.
8. Common errors
Missing lockfile. If package-lock.json isn’t committed or is listed in .gitignore, the setup-node step will error:
Error: Dependencies lock file is not found in /home/runner/work/order-service/order-service.
Supported file patterns: package-lock.json,npm-shrinkwrap.json,yarn.lock
The fix is to commit the lockfile. If the project uses pnpm or yarn, change the cache value to the right package manager.
Tests running in watch mode. Some tools, like Vitest or Jest when configured for watch, wait indefinitely for file changes. GitHub Actions automatically sets the CI=true environment variable, which most tools use to disable watch mode. Still, you should explicitly run tests once, like vitest run.
Secrets not available in pull requests from forks. For pull_request events from a forked repository, secrets are not passed to the workflow. This is a deliberate security mechanism. Tests that require real API keys will fail in this case. Mock external services in unit tests. Don’t switch to pull_request_target to “fix” this, because that event runs with full permissions on unreviewed code.
Cross-platform differences. Tests passing on macOS but failing on Ubuntu usually come from case sensitivity in paths. For example, import './Order.js' when the actual file is order.js. Linux filesystems are case-sensitive, so CI catches this error before it reaches production.
When you don’t need the full pipeline. For an internal script that’s only a few dozen lines, a single job with npm ci && npm test is enough. Only add matrix, coverage, and separate jobs as the project grows or more people contribute.
9. Conclusion
In this article, you’ve built a complete CI pipeline for a Node.js project using GitHub Actions. The pipeline uses npm ci with setup-node caching for fast and reliable dependency installation. The matrix strategy checks multiple Node versions. The lint job is separated and linked to the test job using needs. Coverage reports are saved with artifacts. Finally, the workflow is optimized with concurrency, permissions, and timeout-minutes.
The core principle is keeping CI fast and reliable. A pipeline that runs for fifteen minutes, or fails randomly, will gradually cause your team to ignore its results. Start with the minimal workflow in section 3, then add parts as your project genuinely needs them.