If you maintain an npm package with both stable and prerelease versions, you may have run into an awkward release setup.
You have one workflow publishing versions such as:
3.4.0
and another handling:
3.5.0-beta.1
Both workflows need permission to publish the same package.
Until recently, npm allowed only one trusted-publisher configuration per package. That could push teams toward workflow workarounds or leave a long-lived npm token in the release path that did not fit the OIDC setup.
npm changed that on September 3, 2026.
A package can now trust multiple CI workflows independently.
That gives us a much cleaner setup:
stable workflow ────────┐
├──> npm staging ──> review ──> publish
prerelease workflow ────┘
Neither workflow needs a persistent npm publishing token.
And if we keep both configurations stage-only, neither workflow can make a package public by itself.
Let's build that version.
First, what does "trusted publishing" mean here?
Normally, CI needs some credential before npm will let it publish a package.
The traditional approach is to create an npm token, save it as a GitHub Actions secret, and let the workflow use it whenever a release runs.
Trusted publishing changes that.
GitHub Actions can prove to npm:
I am this repository
running this workflow
under this configured identity
npm verifies that identity through OIDC and gives the workflow a short-lived credential for that publishing operation.
There is no reusable npm write token sitting in GitHub Secrets.
The September update adds another useful piece: the same npm package can now trust more than one workflow.
A package can currently have up to 10 trusted publisher configurations.
The setup we want
Imagine this repository:
acme/sdk
and this package:
@acme/sdk
We have two GitHub Actions workflows:
.github/workflows/publish-stable.yml
.github/workflows/publish-prerelease.yml
The stable workflow should handle:
3.4.0
3.5.0
4.0.0
The prerelease workflow should handle:
3.5.0-beta.1
3.5.0-beta.2
4.0.0-rc.1
Both workflows should be able to prepare a release.
Neither should publish directly.
Instead:
CI
↓
stage package
↓
npm scans it
↓
maintainer reviews it
↓
2FA approval
↓
package becomes public
That gives us automation without giving CI the final publishing decision.
Requirements
For the staged publishing path in this guide, use:
- Node.js 22.14.0 or newer
- npm CLI 11.15.0 or newer
- a GitHub-hosted Actions runner
- an npm package that already exists
- 2FA enabled for the maintainer approving the package
- a matching
repository.urlinpackage.json
Staged publishing cannot currently create a brand-new npm package. The package has to exist first.
Trusted publishing also does not currently support self-hosted GitHub runners.
Configure the stable workflow on npm
Open your package on npmjs.com and go to:
Package
→ Settings
→ Trusted publishing
Add a GitHub Actions trusted publisher.
For example:
Organization or user:
acme
Repository:
sdk
Workflow:
publish-stable.yml
For allowed actions, keep:
npm stage publish
enabled.
Leave direct:
npm publish
disabled if you want every release to pass through human approval.
Now add another trusted publisher.
This time:
Organization or user:
acme
Repository:
sdk
Workflow:
publish-prerelease.yml
Again, allow staged publishing without enabling direct publishing.
The same package now recognizes two separate GitHub workflows.
Build the stable workflow
Create:
.github/workflows/publish-stable.yml
with:
name: Stage stable npm release
on:
release:
types: [released]
permissions:
contents: read
id-token: write
jobs:
stage:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: "24"
package-manager-cache: false
- name: Use npm 11
run: npm install --global npm@11
- name: Show versions
run: |
node --version
npm --version
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build --if-present
- name: Test
run: npm test
- name: Check package contents
run: npm pack --dry-run
- name: Stage package
run: npm stage publish
The line that makes trusted publishing possible is:
id-token: write
That allows GitHub Actions to request the OIDC identity npm verifies.
There is intentionally no:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
in the publishing step.
Why run npm pack --dry-run first?
Before sending anything to npm, I want to know what is actually going into the package.
npm pack --dry-run shows the files npm intends to include.
That can catch things such as:
- generated files missing from the package
- files accidentally excluded by
.npmignore - unexpected local files entering the tarball
- a build step that did not produce what the package expects
The publishing workflow should fail before staging if the artifact itself is wrong.
Add the prerelease workflow
Now create:
.github/workflows/publish-prerelease.yml
name: Stage prerelease npm release
on:
release:
types: [prereleased]
permissions:
contents: read
id-token: write
jobs:
stage:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: "24"
package-manager-cache: false
- name: Use npm 11
run: npm install --global npm@11
- name: Show versions
run: |
node --version
npm --version
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build --if-present
- name: Test
run: npm test
- name: Check package contents
run: npm pack --dry-run
- name: Stage prerelease
run: npm stage publish --tag next
The important difference is the dist-tag:
--tag next
A prerelease such as:
3.5.0-beta.1
can therefore become:
@acme/sdk@next
instead of replacing the package's normal latest release channel.
What happens when CI finishes?
The package is not public yet.
That is the part I like about this setup.
CI has done the repetitive work:
install
build
test
inspect package
authenticate
stage
Now a maintainer can inspect what was staged.
List staged versions:
npm stage list @acme/sdk
Open one:
npm stage view <stage-id>
Or download its tarball:
npm stage download <stage-id>
Only after reviewing it do you approve:
npm stage approve <stage-id>
That approval requires 2FA.
The automated workflow cannot use its OIDC identity to approve its own staged package.
npm now scans the package before approval
The September 3 update also changed the staged-release experience.
npm runs its malware scan before a staged package can be approved.
While scanning is still in progress, the approval action remains unavailable.
The release path is therefore closer to:
GitHub Actions
↓
OIDC authentication
↓
npm stage publish
↓
malware scan
↓
maintainer review
↓
2FA approval
↓
public package
It is a small change in the interface, but it creates a useful order of operations.
The maintainer is approving an artifact after the automated scan has completed.
What about old npm publishing tokens?
Don't delete them before testing the new path.
A safer migration is:
1. Configure trusted publishing
2. Run a staged release
3. Verify OIDC succeeds
4. Verify the package appears in staging
5. Approve it normally
6. Confirm the published package is correct
7. Remove write tokens that are no longer needed
npm recommends changing the package publishing setting to:
Require two-factor authentication and disallow tokens
after trusted publishing is working.
That prevents a forgotten traditional publishing token from becoming another way around the release process.
Private dependencies need separate authentication
There is one easy point to miss.
Trusted publishing authenticates:
npm publish
and:
npm stage publish
It does not automatically authenticate:
npm ci
against private npm packages.
If your project depends on private packages, installation may still need a read-only token.
For example:
- name: Install dependencies
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_READ_TOKEN }}
- name: Stage release
run: npm stage publish
The distinction is useful:
read-only token
→ install private dependencies
OIDC
→ stage the release
You do not need to bring a persistent publishing token back into CI just because dependency installation needs authentication.
If publishing fails, check these four things first
Workflow filename
npm expects the workflow filename configured in trusted publishing to match the file under:
.github/workflows/
Use:
publish-stable.yml
rather than the complete path.
OIDC permission
Make sure the job has:
permissions:
contents: read
id-token: write
Without id-token: write, the workflow cannot obtain the identity npm expects.
Repository metadata
Check the package's:
{
"repository": {
"type": "git",
"url": "..."
}
}
npm requires the repository URL to match the GitHub repository used by the trusted publisher.
Runner type
Trusted publishing currently works with GitHub-hosted runners.
If the publishing job runs on a self-hosted runner, the OIDC publishing path is not currently supported.
One final verification pass
Before calling the migration finished, I would verify these separately:
[ ] Stable workflow stages successfully
[ ] Prerelease workflow stages successfully
[ ] No npm write token is used for publishing
[ ] Stable releases keep the intended dist-tag
[ ] Prereleases use the intended prerelease tag
[ ] Package contents were checked before staging
[ ] npm malware scanning completes
[ ] A maintainer can inspect the staged artifact
[ ] Approval requires 2FA
[ ] Old publishing tokens are removed only after OIDC works
The value of multiple trusted publishers is not that the release YAML becomes dramatically shorter.
It is that the trust model becomes easier to explain.
One package can have separate release workflows.
Each workflow proves its own identity.
CI can prepare the exact package that should go out.
And the final step can still belong to a maintainer.
For stable and prerelease releases, that is a clean boundary to work with.
Top comments (0)