Automating npm Releases with GitHub Actions
Build a GitHub Actions release pipeline for dual-format npm packages: matrix builds, OIDC token auth, provenance, and automated version tagging.
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
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
mainunexpectedly triggers a publish, or a re-run of an old tag re-publishes an already-released version.Root cause: The
on.pushtrigger matched bothbranchesandtagswithout a correspondingifcondition 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 ciwas run without a followingnpm run buildstep, 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-artifactanddownload-artifactunless you also re-verify the tarball contents withnpm pack --dry-runafter 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
- Configuring OIDC Trusted Publishing for npm — remove the stored
NPM_TOKENfrom this workflow entirely by registering a trusted publisher on npmjs.com. - semantic-release for Dual-Format Packages — replace the manual tag-and-push step with commit-driven version bumps, changelogs, and publishes.
Related
- npm Provenance & Sigstore Attestation — the cryptographic attestation this workflow’s
--provenanceflag generates on every publish. - Validating Packages Before Publish — the
publintandattwchecks that belong in the release job beforenpm publishruns. - Mastering the package.json Exports Field — the condition map your build step must emit correctly for this pipeline’s validation steps to pass.
- Modern Build Tools: tsup, Rollup, and esbuild — configuring the
npm run buildscript this workflow depends on to emit dual ESM/CJS output.