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 |
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.
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).
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
- 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.