Skip to content
compiler.dev

Node.js GitHub Actions Workflow Template

Last updated

This is a complete .github/workflows/ci.yml for a Node.js project using npm. It installs dependencies from a cache, runs lint, build and tests on two Node versions, and cancels superseded pull request runs. Copy it, adjust the scripts to match your package.json, and commit.

The workflow

name: CI

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

concurrency:
  group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    strategy:
      fail-fast: false
      matrix:
        node: [20, 22]
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
          cache: npm

      - run: npm ci
      - run: npm run lint --if-present
      - run: npm run build --if-present
      - run: npm test

What the non-obvious parts do

  • permissions: contents: read limits the automatic GITHUB_TOKEN to reading the repo. Add more scopes per job only when needed.
  • concurrency gives each pull request its own group. A new push cancels the older run. Pushes to main are not cancelled, so every commit on main gets a result.
  • timeout-minutes: 15 stops a hung test from using the six-hour default. Set it to about twice your normal run time.
  • fail-fast: false lets both Node versions finish, so you see every failure, not just the first.
  • cache: npm makes setup-node cache npm's download folder (not node_modules), keyed on package-lock.json. npm ci then installs from local tarballs. Details in caching npm in GitHub Actions.
  • npm ci installs exactly what the lockfile says and fails if it is out of sync. Use it instead of npm install in CI.
  • --if-present skips scripts you have not defined, so the template works on small projects.

Variants

pnpm or Yarn

Switch the cache option and install command. For pnpm, add pnpm/action-setup@v4 before setup-node and set cache: pnpm. See caching pnpm and caching Yarn.

Monorepo

Run tasks through your build tool and pass a remote cache so unchanged packages are skipped. See CI for Nx and Turborepo monorepos.

Add a deploy job

  deploy:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/deploy.sh

Use OIDC for cloud credentials instead of stored keys; see the OIDC glossary entry.

macOS or Windows

Add them to the matrix with os: [ubuntu-latest, macos-latest] and runs-on: ${{ matrix.os }}. Use them only if you ship to those platforms, since they cost more per minute.

Run it on faster machines

If your tests are the slow part, change one line and keep everything else:

    runs-on: [self-hosted, Linux, compiler-ci]

compiler.dev runs the job on a fresh 8 vCPU / 16 GB machine. If it ever cannot, the job falls back to your original runner. See pricing and how to speed up GitHub Actions.

FAQ

Should I cache node_modules?

Usually no. Caching npm's download cache and running npm ci is safer, because node_modules can hold platform-specific binaries and npm ci deletes it anyway.

Which Node versions should I test?

The active LTS lines you support. Check the Node.js release schedule and drop versions that have reached end of life.

How do I run tests in parallel?

Use your test runner's workers locally and test sharding across machines. Jest and Vitest both support --shard.

Why is my cache not restoring?

Usually the lockfile path is wrong or you are on a different branch scope. Read about cache keys.