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

Choosing the Release Trigger

The workflow above fires on a pushed tag, but that is only one of four trigger styles a publishing pipeline can use, and the choice determines who can cut a release, how reviewable it is, and what happens when two releases race each other. A tag push (on: push: tags: ['v*']) keeps the decision in Git: the tag is the release record, and anyone with push access to tags can publish. A manual workflow_dispatch moves the decision into the Actions UI, which is useful when a human needs to pick a dist-tag or re-run a release without creating a new tag. A merge trigger (on: push: branches: [main]) combined with a version-diff check publishes automatically whenever package.json’s version changes, which suits trunk-based teams. A release-tool trigger — a Changesets or semantic-release job that opens a “Version Packages” pull request — separates deciding to release from performing the release, so the version bump gets a code review before anything reaches the registry.

Four release triggers compared A four-column comparison of npm release triggers. Tag push is initiated by a maintainer and is auditable in Git history. Manual dispatch is initiated in the Actions UI and allows choosing a dist-tag. Merge to main publishes on every version change. A release pull request routes the version bump through code review before publishing. Release triggers, and what each one optimises for tag push git tag v1.4.0 && git push audit trail lives in Git no UI step to forget tag typo = wrong version workflow_dispatch run button in Actions inputs pick the dist-tag safe to re-run a failure decision is not in Git merge to main version diff triggers job no separate release step fits trunk-based teams accidental bump publishes release pull request bot opens version PR bump is code-reviewed changelog written for you extra tool to maintain All four converge on the same publish job build → validate (publint, attw) → npm publish --provenance → verify with npm view

Whichever trigger you pick, keep the publish job itself identical. The trigger decides when the pipeline runs; the job decides what gets published, and duplicating that logic across triggers is how a package ends up published without provenance from one path and with it from another. Extract the publish steps into a reusable workflow (workflow_call) and let each trigger call it with different inputs.

Concurrency deserves the same care. Two tags pushed within seconds of each other start two workflow runs that both try to publish, and the loser fails with EPUBLISHCONFLICT after having already built artefacts:

concurrency:
  group: npm-publish-${{ github.repository }}
  cancel-in-progress: false

Setting cancel-in-progress: false is deliberate — cancelling a half-finished publish is worse than queueing it, because npm may have already accepted the tarball when the job dies. Serialise instead of cancelling, and let the second run fail loudly on the duplicate version.

When a Release Fails Mid-Run

A release pipeline is not atomic. It bumps a version, builds artefacts, publishes to the registry, pushes a tag, and creates a GitHub release — and it can die between any two of those. What you do next depends entirely on whether the tarball reached the registry, because npm treats a published version as immutable: republishing the same version number is refused, and npm unpublish is only permitted within 72 hours (and only when nothing depends on it).

Recovering from a failed release A decision path starting from a failed release run. If npm view shows the version is absent, the run can simply be retried. If the version exists on the registry, the fix is to deprecate or re-tag and publish a new patch version, never to force-republish the same number. Release run failed npm view [email protected] version not on registry already published Safe to retry re-run the failed job as-is Version is immutable never force the same number delete the tag if it was pushed fix the cause, re-tag same version number is fine npm deprecate the bad version move the latest dist-tag back publish a patch with the fix The registry is the source of truth — check it before touching Git

The first command after any failed release is therefore a registry query, not a Git command:

npm view @scope/[email protected] version 2>/dev/null \
  && echo "PUBLISHED — recover forward" \
  || echo "NOT PUBLISHED — safe to retry"

If the version never reached npm, delete the tag (git push --delete origin v1.4.0), fix the failure, and re-tag the same number. Nothing external observed the failed attempt, so reusing the version is honest.

If the version did publish but the run died before pushing the tag or creating the release, do not try to reconcile by force-pushing. Create the tag at the exact commit that was built, then repair the missing metadata:

git tag v1.4.0 <commit-sha> && git push origin v1.4.0
gh release create v1.4.0 --generate-notes

If the published artefact itself is wrong — a missing dist/ folder, a broken exports map, types that fail arethetypeswrong — recover forward with a patch release and demote the broken one so new installs skip it:

npm dist-tag add @scope/[email protected] latest   # point latest back at the good version
npm deprecate @scope/[email protected] "Broken exports map — use 1.4.1"
# ...fix, then publish 1.4.1 through the normal pipeline

HAZARD PREVENTION

Symptom: A re-run of the failed workflow reports npm error code EPUBLISHCONFLICT — cannot publish over the previously published versions, and the job fails again.

Root cause: The first run published successfully and then failed on a later step (tag push, release creation, or a smoke test). The publish itself is not idempotent, so the retry cannot repeat it.

Fix: Make the publish step conditional on the registry state — npm view "$PKG@$VERSION" version || npm publish --provenance --access public — so a retry skips an already-completed publish and continues with the remaining steps.

The deeper structural fix is ordering: put every irreversible action as late in the job as possible, and every cheap validation as early as possible. Build, run publint, pack the tarball, inspect it, and only then publish — so the step that cannot be undone is the last one that can fail.


Keeping the Pipeline Fast and Cheap

A release workflow runs rarely, so its cost is easy to ignore — but its duration is not, because every minute between pushing a tag and the package appearing on the registry is a minute in which a colleague may push a second tag, or a consumer may install a half-released version. Three settings account for most of the wall-clock time in a typical dual-format publish.

The first is dependency installation. npm ci on a cold runner re-downloads the entire dependency tree; actions/setup-node with cache: npm restores it from the runner cache keyed on the lockfile hash, which typically turns a 40-second install into a 6-second restore. The cache key must be the lockfile, not the branch — keying on a branch means every feature branch warms its own copy and the release job on main still misses.

The second is the build matrix. Testing against several Node.js versions is valuable, but it belongs in the validation job, not the publish job. Run the matrix on pull requests and on the tag build, then have a single non-matrix publish job that depends on it with needs:. Publishing from inside a matrix is a classic mistake: a three-entry matrix publishes three times, and two of those runs fail with a duplicate-version error after the first has already succeeded.

The third is artefact reuse. If the validation job already built dist/, uploading it with actions/upload-artifact and downloading it in the publish job guarantees that the bytes you validated are exactly the bytes you publish. Rebuilding in the publish job re-runs the compiler against the same source, which is usually deterministic — but a floating dependency range, a timestamp baked into a banner comment, or a differently-resolved transitive type package can produce a different tarball from the one that passed validation. Publishing an unvalidated artefact is the failure mode that provenance attestation cannot help with, because the attestation faithfully records the build of the wrong bytes.

jobs:
  validate:
    strategy:
      matrix:
        node: [20, 22, 24]
    steps:
      - uses: actions/setup-node@v4
        with: { node-version: '${{ matrix.node }}', cache: npm }
      # ...build, test, publint, attw
      - uses: actions/upload-artifact@v4
        if: matrix.node == 22            # upload once, from the canonical version
        with: { name: dist, path: dist/ }

  publish:
    needs: validate                       # single job — never a matrix
    permissions: { contents: read, id-token: write }
    steps:
      - uses: actions/download-artifact@v4
        with: { name: dist, path: dist/ }
      - run: npm publish --provenance --access public

The id-token: write permission on the publish job (and only that job) is what lets npm mint a provenance attestation from the runner’s OIDC token; granting it repository-wide instead of per-job widens the blast radius of any compromised action for no benefit.


Guides in This Section



Back to CI/CD, Publishing & npm Provenance