A hand-run npm publish cannot guarantee that the tarball a maintainer uploads matches the commit reviewers approved — local node_modules, an uncommitted .env, or a stale dist/ directory all slip through undetected. Automating the release inside GitHub Actions closes that gap: the same workflow that runs tests on every pull request also owns the one path that can publish, and that path only runs against a tagged, reviewed commit with a clean checkout.

Prerequisites


Canonical Configuration Block

The complete workflow below separates continuous integration (runs on every push and pull request) from the release job (runs only on a version tag), while keeping both paths built from the identical npm ci && npm run build sequence:

name: CI and Release

on:
  push:
    branches: [main]
    tags: ['v*']
  pull_request:
    branches: [main]

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [18, 20, 22]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci
      - run: npm run build
      - run: npm test

  release:
    needs: test
    if: startsWith(github.ref, 'refs/tags/v')
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          registry-url: 'https://registry.npmjs.org'
      - run: npm ci
      - run: npm run build
      - run: npx publint --strict
      - run: npx attw --pack .
      - run: npm publish --provenance --access public
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

The release job’s needs: test dependency and if: startsWith(github.ref, 'refs/tags/v') condition together guarantee that a publish only happens after the full test matrix passes on a tagged commit — never on an arbitrary push to main.

Understanding how the exports field routes ESM and CJS consumers is a prerequisite for writing a build script that produces artifacts this workflow can validate correctly.


Release Pipeline Diagram

CI and Release Pipeline A four-stage horizontal flow showing a tag push triggering a matrix test job across three Node.js versions, gating a single release job that builds, validates, and publishes with provenance. Tag Push to Publish Tag push v1.2.3 Test matrix Node 18 / 20 / 22 Build + validate publint, attw Publish --provenance

Step-by-Step Implementation

Step 1 — Trigger the workflow from a version tag

Scope the release job’s execution to tag pushes matching your convention, leaving ordinary commits to main running only tests:

on:
  push:
    branches: [main]
    tags: ['v*']
jobs:
  release:
    if: startsWith(github.ref, 'refs/tags/v')

Expected result: pushing a commit to main runs the test job only; pushing git tag v2.1.0 && git push origin v2.1.0 runs both test and, once it passes, release.

HAZARD PREVENTION

Symptom: A merge to main unexpectedly triggers a publish, or a re-run of an old tag re-publishes an already-released version.

Root cause: The on.push trigger matched both branches and tags without a corresponding if condition scoping the release job to tags specifically.

Fix: Always pair a tag-based trigger with an explicit if: startsWith(github.ref, 'refs/tags/v') guard on the release job itself, not just the workflow-level trigger.

Step 2 — Configure Node.js and the registry

actions/setup-node must set registry-url for the release job — this is what writes the .npmrc entry npm’s CLI needs to authenticate:

- uses: actions/setup-node@v4
  with:
    node-version: 20
    registry-url: 'https://registry.npmjs.org'

Expected result: the runner’s ~/.npmrc contains an authenticated registry entry; omitting registry-url here is the single most common cause of ENEEDAUTH even when NPM_TOKEN is set correctly.

Step 3 — Run the build before validation and publish, in one job

Install, build, and validate must happen in the same job as npm publish — never split across jobs with an artifact hand-off unless that artifact is the exact tarball being published:

- run: npm ci
- run: npm run build
- run: npx publint --strict
- run: npx attw --pack .
- run: npm publish --provenance --access public

Expected result: a failing publint or attw check exits the job before npm publish executes, since GitHub Actions steps run sequentially and stop at the first non-zero exit code.

HAZARD PREVENTION

Symptom: The published package is missing the dist/ output, or ships an older version of it.

Root cause: npm ci was run without a following npm run build step, or the build ran in a separate job whose artifacts were never downloaded into the publish job.

Fix: Keep build and publish in a single job so the filesystem state carries over directly, as shown above — do not rely on actions/upload-artifact and download-artifact unless you also re-verify the tarball contents with npm pack --dry-run after download.

Step 4 — Publish once from a single canonical job

The test matrix runs across multiple Node.js versions to catch runtime differences, but publishing must happen exactly once. Gate the release job behind needs: test so it only proceeds after every matrix combination succeeds, and never add a matrix strategy to the release job itself:

jobs:
  test:
    strategy:
      matrix:
        node-version: [18, 20, 22]
    # ...

  release:
    needs: test
    # no matrix — publishes exactly once

Expected result: GitHub Actions’ job graph shows release waiting on all three test matrix legs before starting; a single npm publish call executes regardless of how many Node.js versions were tested.


Tooling Validation

# Dry-run the exact tarball contents before trusting the workflow
npm pack --dry-run

# Confirm the workflow YAML has no syntax errors before pushing a tag
npx action-validator .github/workflows/release.yml

# Confirm the version in package.json matches the pushed tag
node -p "require('./package.json').version" # compare against $GITHUB_REF_NAME

Sample npm pack --dry-run output for a healthy dual-format package:

npm notice === Tarball Contents ===
npm notice 12.4kB dist/index.mjs
npm notice 9.8kB  dist/index.cjs
npm notice 3.1kB  dist/index.d.ts
npm notice 1.2kB  package.json
npm notice === Tarball Details ===
npm notice name:          @acme/sdk
npm notice version:       2.1.0
npm notice package size:  8.9 kB
npm notice unpacked size: 26.5 kB

Add a version-tag consistency check as a required step so a mismatched tag fails loudly instead of publishing the wrong version number:

- name: Verify tag matches package.json version
  run: |
    PKG_VERSION=$(node -p "require('./package.json').version")
    TAG_VERSION=${GITHUB_REF_NAME#v}
    if [ "$PKG_VERSION" != "$TAG_VERSION" ]; then
      echo "Tag v$TAG_VERSION does not match package.json version $PKG_VERSION"
      exit 1
    fi

Compatibility Matrix

Setup actions/setup-node version Registry auth method Provenance support
npm CLI 9.5–9.x v3 or v4 NPM_TOKEN via NODE_AUTH_TOKEN Yes
npm CLI 10.x v4 NPM_TOKEN or OIDC trusted publisher Yes
npm CLI 11.x v4 OIDC trusted publisher (token optional) Yes
pnpm workspaces v4 + pnpm/action-setup NPM_TOKEN or OIDC Yes, per-package
Yarn Berry (v3/v4) v4 .yarnrc.yml npmAuthToken Yes (npm CLI shells out)
GitLab CI N/A (native runner) OIDC (id_tokens) or NPM_TOKEN var Yes

Guides in This Section



Back to CI/CD, Publishing & npm Provenance