npm Trusted Publisher GitHub Actions Workflow Template (Copy-Paste, 2026)
A copy-paste GitHub Actions workflow for npm trusted publishing in 2026: id-token, registry-url, --provenance, --access public, and the comments explaining why each line is required.
Sources: S-001 S-002 S-003 S-005
A working copy-paste workflow for npm trusted publishing, with every line annotated. Save as .github/workflows/publish.yml. The filename must match the one you registered as a trusted publisher on npmjs.com.
TLDR
name: Publish to npm
on:
release:
types: [published]
permissions:
contents: read
id-token: write
jobs:
publish:
name: npm publish
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20
registry-url: "https://registry.npmjs.org"
cache: npm
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test --if-present
- name: Build (if applicable)
run: npm run build --if-present
- name: Publish to npm
run: npm publish --provenance --access public
Line-by-line: why each block matters
Trigger
on:
release:
types: [published]
Publishing on release.types: [published] is the safest default. The release object is created on the GitHub UI (or via gh release create) and the workflow runs exactly once.
Alternative: on: push: tags: ["v*"] — also acceptable. Avoid on: push to main for publish workflows. The most common “oops we just published v0.0.1-dev” thread in npm/cli (S-005) is a workflow scoped to every main push.
Permissions
permissions:
contents: read
id-token: write
contents: read— needed becauseactions/checkoutreads the repo.id-token: write— needed because the workflow asks GitHub to mint an OIDC token. Without this, OIDC silently does not engage and npm falls back to legacy auth (GitHub OIDC docs —S-002).
If your job creates a tag or release, add contents: write.
Runner
runs-on: ubuntu-latest
GitHub-hosted runners issue OIDC tokens with iss=https://token.actions.githubusercontent.com, which npm trusts. Self-hosted runners may issue tokens npm rejects (npm docs — S-001). Use GitHub-hosted runners for publishing. If you need self-hosted for CI, split publishing into a job pinned to ubuntu-latest.
Checkout
- uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-depth: 0 is optional but useful if your build inspects git history (changelogs, version tags). For a minimal workflow you can omit it.
setup-node
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: "https://registry.npmjs.org"
cache: npm
actions/setup-node@v4— required. v3 does not fully wire the OIDC audience.node-version: 20— pick an LTS that ships withnpm@9.5or newer (provenance support — npm CLI releases,S-003). Node 20 ships with npm 10.registry-url: "https://registry.npmjs.org"— load-bearing. Without it, the OIDC audience claim does not point at npm and the token is rejected.cache: npm— optional, speeds up CI.
Install + test + build
- run: npm ci
- run: npm test --if-present
- run: npm run build --if-present
npm ci over npm install so your package-lock.json is the source of truth. --if-present lets the workflow apply to repos that have not defined those scripts.
Publish
- run: npm publish --provenance --access public
--provenance— generates the Sigstore attestation. Without it, the package publishes but the provenance badge does not appear on npm.--access public— required if your package is scoped (@you/lib). Without it, scoped packages publish privately andnpm installfails for everyone else.
There is no NODE_AUTH_TOKEN, no env: NPM_TOKEN. If either of those lines is in your workflow, OIDC is bypassed.
Variant: matrix publish for monorepos
strategy:
matrix:
package:
- packages/core
- packages/cli
- packages/plugin-x
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 publish --provenance --access public
working-directory: ${{ matrix.package }}
Each package needs its own trusted publisher row on npm pointed at this same publish.yml. The matrix workflow is one file, one trusted-publisher registration per package.
Variant: pnpm
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: "https://registry.npmjs.org"
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm publish --provenance --access public --no-git-checks
pnpm publish speaks OIDC starting at v9.5. --no-git-checks skips the “working tree dirty” guard that pnpm applies by default — fine in CI where the tree is always clean. yarn classic (v1) does not speak OIDC; if you want trusted publishing on a yarn classic project, switch the publish step to npm publish.
Common mistakes
- Pasting this template, leaving an existing
env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }}line below — npm CLI prefers it and bypasses OIDC. - Forgetting that
permissionsmust be set at the workflow or job level, not the step level. - Renaming the file without updating the trusted publisher on npm. The trusted publisher is exact-match on filename.
- Running
npm publish --dry-runin CI as a “test” —--dry-runstill requires auth and can succeed misleadingly.
FAQ
Can I just npm publish without --access public?
If your package is unscoped (super-lib not @you/super-lib), yes. Scoped packages need --access public or publishConfig.access in package.json.
How do I pin the action versions for supply-chain safety?
Replace @v4 with the commit SHA: actions/setup-node@<40-char-sha>. Watch for security advisories on the action repo and bump the SHA when patches land.
What if I need to publish from a Linux self-hosted runner?
Today, use GitHub-hosted for the publish job. Self-hosted for everything else. The runner issuing the OIDC token has to match what npm’s registry trusts.
Next step
Run the preflight checklist against this template and your package.json. If you see green across the board, your next git tag is safe. For the failure shapes this template prevents, read npm publish —provenance failed: 8 reasons and the OIDC migration playbook.