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 to main and 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. The cache: npm option saves npm’s cache directory, using the hash of package-lock.json as 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 install in CI. npm install can update the lockfile and install different versions than your local machine. npm ci deletes node_modules, installs exactly as specified in the lockfile, and reports an error immediately if package.json and package-lock.json are 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: restrict GITHUB_TOKEN permissions 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-ignore has a gotcha when combined with branch protection. Say you require the test check to pass before merging. A pull request that only changes .md files won’t run the workflow, so the check stays in “Expected — Waiting” state forever. You then have two choices: remove paths-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.

github-actionsnodejscitestingdevops