# GitHub Actions Node CI

> This workflow runs your lint, type check, tests and build on every pull request and every push to main, with npm's cache so repeat runs install fast. Save it as .github/workflows/ci.yml, make sure the four npm scripts exist in package.json, and push.

Updated November 8, 2026. Canonical: https://sniptide.com/templates/github-actions-node-ci
Open it in the Sniptide editor: https://app.sniptide.com/new?template=github-actions-node-ci
Files as JSON: https://sniptide.com/templates/github-actions-node-ci.json

## Files

### .github/workflows/ci.yml

```yaml
name: CI

on:
  push:
    branches: [main]
  pull_request:

# A new push to the same branch or pull request cancels the run still in progress.
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v5

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

      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck
      - run: npm test
      - run: npm run build
```

## What each part does [#what-each-part-does]

| Part | What it does | When to change it |
| --- | --- | --- |
| `on` | Runs on pull requests and on pushes to `main`. Pushes to other branches run through their pull request, so nothing runs twice. | Add `workflow_dispatch:` to start runs by hand. |
| `concurrency` | Cancels a run that is still going when a newer push arrives on the same branch. | Rarely. |
| `permissions` | Gives the job read access to the code and nothing else. | Only when a step needs more, such as writing a comment. |
| `timeout-minutes` | Stops a stuck job after 15 minutes instead of the default 6 hours. | When your build legitimately takes longer. |
| `setup-node` | Installs the Node version named in `.nvmrc` and caches npm's download cache by the lockfile. | Use `node-version: 24` if you don't keep an `.nvmrc`. |
| `npm ci` | Installs exactly what the lockfile says, and fails if the lockfile is out of date. | Use the variant below for pnpm. |
| Checks | One step per script, so the log shows which one failed. | Remove the scripts your project doesn't have. |

To block merging when a check fails, open the repository's settings, add a branch ruleset for `main` and require the `test` job.

## Variant: pnpm [#variant-pnpm]

pnpm has to be installed before `setup-node` can cache it. `pnpm/action-setup` reads the version from `packageManager` in `package.json`.

```yaml title=".github/workflows/ci.yml"
    steps:
      - uses: actions/checkout@v5
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v5
        with:
          node-version-file: .nvmrc
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint
      - run: pnpm typecheck
      - run: pnpm test
      - run: pnpm build
```

## Variant: several Node versions [#variant-several-node-versions]

A library that supports more than one Node version can test them all with a matrix. Each version runs as its own job.

```yaml title=".github/workflows/ci.yml"
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        node: [20, 22, 24]
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v5
        with:
          node-version: ${{ matrix.node }}
          cache: npm
      - run: npm ci
      - run: npm test
```

`fail-fast: false` keeps the other versions running when one fails, so you see every failure at once.

## Variant: tests that need Postgres [#variant-tests-that-need-postgres]

A service container starts Postgres next to the job. The health check makes the steps wait until it accepts connections.

```yaml title=".github/workflows/ci.yml"
jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:17
        env:
          POSTGRES_USER: app
          POSTGRES_PASSWORD: app
          POSTGRES_DB: app_test
        ports:
          - 5432:5432
        options: >-
          --health-cmd "pg_isready -U app"
          --health-interval 5s
          --health-timeout 5s
          --health-retries 10
    env:
      DATABASE_URL: postgres://app:app@localhost:5432/app_test
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v5
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm test
```

The same database runs on your machine with the [Docker Compose Postgres template](/templates/docker-compose-postgres).
