GitHub How-to

Let the agent cut the release, keep the approve button for yourself: npm staged publishing from Actions

npm stage publish lets CI upload a version without 2FA and holds it until a maintainer approves. About twenty minutes to set up, and it fits agent-driven release PRs well.

A paper-cut staircase rising to a closed iron gate with one glowing peach latch

Picture the release step in a repo where a coding agent does a lot of the work. It bumps the version, writes the changelog from the merged PRs, opens the release PR. Then somebody has to publish, and the publish credential has to live somewhere the automation can reach it. That’s the part nobody loves: either a long-lived npm token in CI secrets, or a human at a laptop typing a one-time code.

npm now has a middle path. npm stage publish uploads a version without asking for 2FA and parks it in a queue. Nothing is installable until a maintainer runs npm stage approve, which does ask for 2FA. GitHub’s October 2 changelog added the last missing piece: staging can now create a package that doesn’t exist yet.

The result is a release pipeline where CI (and any agent upstream of it) can do everything except the one irreversible step. Here’s the setup, what I ran to check it, and where it bites.

What staging does, exactly

According to the npm CLI docs , the subcommands are publish, list, view, approve, reject and download. Only approve and reject prompt for 2FA. Staging, listing, viewing and downloading the tarball don’t.

A few behaviours matter for a pipeline:

  • Staged and published versions share one semver index. A staged 1.4.0 blocks anyone publishing 1.4.0 the normal way until you approve or reject it.
  • A tag is fixed at stage time. To change it, reject the staged version and stage it again.
  • npm stage publish refuses packages marked "private": true.
  • You can keep publishing other versions normally while one sits in the queue.

Requirements per the staged publishing page : npm CLI 11.15.0 or later, Node.js 22.14.0 or higher, publish access, and 2FA on your account.

What I ran

I don’t have an npm account to stage to from this sandbox, so nothing below touched the registry. What I did run, in a scratch directory with npm 12.2.0 installed locally (it warned that it wanted a newer Node patch release than the sandbox’s 22.22.0, and worked anyway):

npm stage --help
npm stage publish --dry-run --access public
npm trust github --help

The help output lists the six subcommands above. The dry run, against a throwaway package named @example-scope/demo-lib at version 1.4.0, printed the tarball contents and then:

npm warn stage This command requires you to be logged in to https://registry.npmjs.org/ (dry-run)
npm notice Staging to https://registry.npmjs.org/ with tag latest and public access (dry-run)
+ @example-scope/demo-lib@1.4.0 (staged)

So the dry run behaves like npm publish --dry-run: it packs, reports, and says “staged” without staging anything. Everything else in this piece, including the workflow file, comes from the docs and is documented behaviour, not observed behaviour.

Step 1: stage from a tag, with no publish rights

Use trusted publishing, so there’s no npm token in your repo secrets at all. Create the workflow first, because the trust record refers to its filename. This follows the example in the trusted publishing docs , with the last line changed:

name: Stage release
on:
  push:
    tags:
      - 'v*'
permissions:
  id-token: write
  contents: read
jobs:
  stage:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with:
          node-version: '24'
          registry-url: 'https://registry.npmjs.org'
          package-manager-cache: false
      - run: npm --version
      - run: npm ci
      - run: npm test
      - run: npm stage publish

id-token: write is what lets the job mint the OIDC token npm checks. The npm --version line is there because the runner’s bundled npm has to be at least 11.15.0 for stage to exist. If it prints something older, add a step that upgrades npm before the stage line.

The trigger is a pushed tag. That choice matters, and I’ll come back to it.

Step 2: tell npm to trust that workflow for staging only

On npmjs.com, the package’s Trusted Publishing section takes the organisation or user, the repository, the workflow filename (filename only, with the .yml extension, living in .github/workflows/) and an optional environment name. Per the docs, npm stage publish is always allowed for a trusted publisher, while plain npm publish is a separate checkbox. Leave that one off.

The CLI has a route too. npm trust github --help in npm 12.2.0 shows:

npm trust github @your-scope/your-lib \
  --file stage-release.yml \
  --repo your-org/your-lib \
  --allow-stage-publish

I confirmed those flags exist from the help text and didn’t execute the command, so check the prompt it gives you before saying yes. The docs page for trusted publishing doesn’t describe npm trust at all, and documents the web form only.

Three gotchas from that same page. npm doesn’t validate the configuration when you save it, so a typo in the filename shows up as a failed publish. The package’s repository.url must exactly match the GitHub repository. And only cloud-hosted runners work, not self-hosted ones.

Step 3: the human half

After the tag pushes and the workflow goes green, the maintainer reviews the queue:

npm stage list @your-scope/your-lib
npm stage view <stage-id>
npm stage download <stage-id>

download fetches the staged tarball so you can inspect exactly what CI built, which is the review you couldn’t do on a package that was already live. Unpack it, check the file list against what you expect, and look for anything a build step shouldn’t have put in there. Then:

npm stage approve <stage-id>

That prompts for 2FA and publishes. You can do the same from the Staged Packages tab on npmjs.com. If something’s wrong, npm stage reject <stage-id> removes it, also behind 2FA.

Where the agent fits

My suggestion, and it’s an opinion, not something the docs prescribe: let the agent own everything up to the tag, and let a person own the tag-to-registry hop.

Concretely, ask Claude Code or Codex to prepare the release PR: bump the version, draft the changelog entry, run the test suite. You merge it and push the tag (or have a protected workflow do it). The staging job runs with an OIDC identity that can stage and nothing else. The agent never holds a credential that can publish, and you don’t need to rely on a prompt saying “never run npm publish”.

That’s a stronger guarantee than a permission rule. If you’ve set up deny rules or a PreToolUse hook to keep an agent from publishing, staging is the same idea enforced by the registry rather than by the agent’s own configuration. Use both.

If you’d rather authenticate with a token than trusted publishing, the access token docs describe a “Read and write (stage only)” permission. It can stage new versions for a maintainer to review and promote, and can’t publish them directly. The docs call it the safer option for CI.

Two other changes from the same day

GitHub’s second October 2 entry is easy to trip over if you’re setting this up.

First, a trusted publishing configuration that hasn’t been validated now expires 48 hours after you create it. It becomes permanent after its first successful publish. Create the trust record when you’re ready to cut a release, not a week early. Changing the repository or project identity starts a fresh 48-hour window. The changelog doesn’t say whether a staged-and-approved version counts as that first successful publish, so don’t assume. Run the first release promptly and look at the settings page afterwards.

Second, npm now rejects trusted publishing tokens from GitHub Actions issue_comment events, on top of the existing pull_request_target restriction. If you’ve built a “comment /release on the PR” bot, which is a very natural thing to build when an agent is helping out in review threads, it can’t publish that way any more. GitHub’s suggested events are push, release and workflow_dispatch. That’s the reason for the tag trigger above.

What goes wrong

Staging doesn’t review the code for you. If your build step is compromised, you’ll stage a poisoned tarball, and approval will happily publish it unless the person approving looks. The gate only helps if someone actually runs npm stage download now and then, or at least checks the file list. If approving becomes muscle memory, you’ve added a step without adding safety.

It’s one more thing to do per release, and a staged version holds that semver slot until someone acts. A queue nobody watches is a release that silently didn’t happen.

New packages get a public placeholder. The staging docs say staging a package that doesn’t exist yet publishes a 0.0.0-stage version, which is public, while the version you staged isn’t until approved. Your package name is visible from that moment.

The CLI docs also contradict themselves slightly. One line says short-lived tokens can’t run npm stage subcommands, while the token table lists OIDC trust tokens as able to stage when allowed. That’s the setup in this piece, so test it on a throwaway package before the first real release.

Don’t bother if you publish a package once a year, or if the package is private and unpublished anyway (staging refuses "private": true).

The first twenty minutes

Pick a low-stakes package. Check that npm --version on your CI runner reaches 11.15.0. Add the workflow, create the trust record with staging allowed and plain publish off, push a throwaway tag such as v0.0.1-rc.1, then run npm stage list and npm stage download on what shows up.