DEV Community

Cover image for npm Trusted Publishing with Multiple Workflows: Stable and Prerelease Releases Without Write Tokens

npm Trusted Publishing with Multiple Workflows: Stable and Prerelease Releases Without Write Tokens

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
Enter fullscreen mode Exit fullscreen mode

and another handling:

3.5.0-beta.1
Enter fullscreen mode Exit fullscreen mode

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 ────┘
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

and this package:

@acme/sdk
Enter fullscreen mode Exit fullscreen mode

We have two GitHub Actions workflows:

.github/workflows/publish-stable.yml
.github/workflows/publish-prerelease.yml
Enter fullscreen mode Exit fullscreen mode

The stable workflow should handle:

3.4.0
3.5.0
4.0.0
Enter fullscreen mode Exit fullscreen mode

The prerelease workflow should handle:

3.5.0-beta.1
3.5.0-beta.2
4.0.0-rc.1
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.url in package.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
Enter fullscreen mode Exit fullscreen mode

Add a GitHub Actions trusted publisher.

For example:

Organization or user:
acme

Repository:
sdk

Workflow:
publish-stable.yml
Enter fullscreen mode Exit fullscreen mode

For allowed actions, keep:

npm stage publish
Enter fullscreen mode Exit fullscreen mode

enabled.

Leave direct:

npm publish
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The line that makes trusted publishing possible is:

id-token: write
Enter fullscreen mode Exit fullscreen mode

That allows GitHub Actions to request the OIDC identity npm verifies.

There is intentionally no:

NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

The important difference is the dist-tag:

--tag next
Enter fullscreen mode Exit fullscreen mode

A prerelease such as:

3.5.0-beta.1
Enter fullscreen mode Exit fullscreen mode

can therefore become:

@acme/sdk@next
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Now a maintainer can inspect what was staged.

List staged versions:

npm stage list @acme/sdk
Enter fullscreen mode Exit fullscreen mode

Open one:

npm stage view <stage-id>
Enter fullscreen mode Exit fullscreen mode

Or download its tarball:

npm stage download <stage-id>
Enter fullscreen mode Exit fullscreen mode

Only after reviewing it do you approve:

npm stage approve <stage-id>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

npm recommends changing the package publishing setting to:

Require two-factor authentication and disallow tokens
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

and:

npm stage publish
Enter fullscreen mode Exit fullscreen mode

It does not automatically authenticate:

npm ci
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The distinction is useful:

read-only token
→ install private dependencies

OIDC
→ stage the release
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

Use:

publish-stable.yml
Enter fullscreen mode Exit fullscreen mode

rather than the complete path.

OIDC permission

Make sure the job has:

permissions:
  contents: read
  id-token: write
Enter fullscreen mode Exit fullscreen mode

Without id-token: write, the workflow cannot obtain the identity npm expects.

Repository metadata

Check the package's:

{
  "repository": {
    "type": "git",
    "url": "..."
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Sources

Top comments (0)