DEV Community

Anthany Wingo
Anthany Wingo

Posted on

A small GitHub Actions workflow for Node.js that doesn't waste your CI minutes

Most Node.js projects need the same CI job: install, lint, test, build. The default starter workflows do that, but two small settings make a noticeable difference: caching npm downloads, and cancelling runs that a newer push has already replaced.

Here's the whole workflow, then a walk-through.

name: CI (Node.js)

on:
  push:
    branches: [main]
  pull_request:

# Cancel superseded runs on the same branch to save minutes.
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run lint --if-present
      - run: npm test --if-present
      - run: npm run build --if-present
Enter fullscreen mode Exit fullscreen mode

1. Cancel runs you no longer care about

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true
Enter fullscreen mode Exit fullscreen mode

If you push twice to the same branch in a row, the first run is now pointless. The concurrency group is the workflow name plus the branch ref, so a new run on that branch cancels the older one instead of letting both finish. That saves Actions minutes, and you get your result sooner.

Use this for CI only. For deploy or release workflows, cancelling halfway through is usually not what you want.

2. Cache npm downloads

- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: npm
Enter fullscreen mode Exit fullscreen mode

cache: npm tells setup-node to cache npm's download cache (~/.npm), keyed on your package-lock.json. When the lockfile hasn't changed, packages you already have aren't downloaded again.

3. Use npm ci, not npm install

npm ci installs exactly what's in your lockfile and fails if package.json and the lockfile disagree. In CI that's what you want: the same dependency tree on every run. It does need a committed package-lock.json.

4. Let missing scripts be skipped

- run: npm run lint --if-present
- run: npm test --if-present
- run: npm run build --if-present
Enter fullscreen mode Exit fullscreen mode

--if-present means npm skips a script that isn't defined in package.json instead of failing. So one workflow works for a library with no build step, or for a new repo that doesn't have a linter yet. Add the scripts later and CI picks them up.

5. Read-only token by default

permissions:
  contents: read
Enter fullscreen mode Exit fullscreen mode

The job only needs to read your code, so the GITHUB_TOKEN gets read access to repository contents and nothing more. If a later step needs to write (comment on a PR, push a tag), grant that permission explicitly.

Using it

Save the file as .github/workflows/ci-node.yml, change node-version and the main branch name if yours differ, and push. It runs on pushes to main and on every pull request.

This is a single Node.js CI workflow, not a full CI setup. It doesn't deploy, publish, or test across several Node versions or operating systems.

Get the file

If you'd rather copy a commented version than retype it, it's a free ($0) download on Gumroad: https://8686702537545.gumroad.com/l/sqjvco?ref=devto-nodeci

Disclosure: I make ShipKit, which gives away this sample and sells paid GitHub Actions packs.

Top comments (0)