Skip to content

feat: npm stage#9201

Merged
reggi merged 21 commits into
latestfrom
reggi/stage
May 20, 2026
Merged

feat: npm stage#9201
reggi merged 21 commits into
latestfrom
reggi/stage

Conversation

@reggi
Copy link
Copy Markdown
Contributor

@reggi reggi commented Apr 7, 2026

Introducing npm stage 🎉

A new command for staged publishing — allowing package maintainers to decouple the act of publishing from proof-of-presence (2FA), making automated workflows more secure.

🔗 Docs

Why Staged Publishing?

With npm stage publish, an automated workflow can stage a package version without a 2FA prompt. The maintainer can then review and approve the staged package at their convenience, providing 2FA only at the approval step. This keeps proof-of-presence in the loop while keeping CI/CD fully automated.

Subcommands

Command Description Requires 2FA
npm stage publish [<package-spec>] Stage a package for publishing No
npm stage list [<package-spec>] List all staged package versions No
npm stage view <stage-id> View details of a specific staged package No
npm stage approve <stage-id> Approve and publish a staged package Yes
npm stage reject <stage-id> Reject and remove a staged package Yes
npm stage download <stage-id> Download the staged tarball for inspection No

How It Works

  1. Stage — CI runs npm stage publish using any token type (no 2FA needed). The package version is held in a pending state, not publicly available.
  2. Review — Maintainer runs npm stage list to see pending staged packages, and npm stage view <id> or npm stage download <id> to inspect them.
  3. Approve or Reject — Maintainer runs npm stage approve <id> (with 2FA) to publish, or npm stage reject <id> to discard.

Key Behaviors

  • Staged packages share the same semver uniqueness constraint as published packages — you can't publish a version that's already staged.
  • Normal npm publish continues to work alongside staged publishing.
  • Multiple versions of the same package can be staged concurrently.
  • npm stage publish has full parity with npm publish (respects "private": true, workspace support, etc).
  • Tags are immutable once staged — reject and re-stage to change a tag.

Future Work

  • Trust relationship permissions — A follow-up PR will add granular command permissions to npm trust, with --allow-publish and --allow-stage-publish flags to control whether a trust relationship can be used for npm publish, npm stage publish, or both.

@reggi reggi requested a review from a team as a code owner April 7, 2026 19:52
@ljharb
Copy link
Copy Markdown
Contributor

ljharb commented Apr 8, 2026

Why would download not require 2fa but view require it? Either both should, or neither, I’d expect.

@reggi
Copy link
Copy Markdown
Contributor Author

reggi commented Apr 8, 2026

Why would download not require 2fa but view require it? Either both should, or neither, I’d expect.

@ljharb that was a mistake, fixed!

@ljharb
Copy link
Copy Markdown
Contributor

ljharb commented Apr 8, 2026

(i'm assuming that all the "no"s still require one factor)

@wraithgar
Copy link
Copy Markdown
Contributor

(i'm assuming that all the "no"s still require one factor)

All staged content routes will require authentication with access to that package.

reggi and others added 3 commits May 8, 2026 14:23
Adds staged publishing support with subcommands: publish, list, view, approve, reject, and download.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The logTar utility wraps JSON output under the package name key,
so tests need to access out[pkg] instead of out directly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@reggi reggi requested a review from a team as a code owner May 8, 2026 18:30
Update smoke-tests/tap-snapshots/test/index.js.test.cjs to match
current CLI output after rebasing on latest.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Comment thread lib/utils/display.js
Comment thread lib/utils/key-values.js Outdated
Comment thread lib/commands/stage/download.js
Comment thread lib/commands/stage/index.js
Comment thread lib/commands/stage/reject.js
Comment thread test/lib/commands/stage/approve.js
Comment thread workspaces/libnpmpublish/lib/publish.js Outdated
Comment thread workspaces/libnpmpublish/lib/publish.js
Comment thread lib/commands/publish.js Outdated
shmam added a commit to npm/api-documentation that referenced this pull request May 15, 2026
…ns (#54)

## Summary 
This PR introduces six new public endpoints for supporting managing
staged package versions. These endpoint support viewing, filtering,
approving, rejecting and inspecting staged package versions on the npm
registry.

#### E1. GET https://registry.npmjs.org/-/stage
Gets the list of all staged package versions that the user has access to

#### E2. POST https://registry.npmjs.org/-/stage/package/{package-name}
Creates a staged package version

#### E3. GET https://registry.npmjs.org/-/stage/{stage-id}
Gets a specific staged package version

#### E4. DELETE https://registry.npmjs.org/-/stage/{stage-id}
Reject a staged staged package version

#### E5. POST https://registry.npmjs.org/-/stage/{stage-id}/approve
Approve a staged package version to published

#### E6. GET https://registry.npmjs.org/-/stage/{stage-id}/tarball
Gets a staged package version tarball 

## References
- for more info on npm stage: npm/cli#9201
JSON output no longer disables credential redaction globally.
Previously, all --json commands would skip redactLog(), which
could leak embedded credentials from registry URLs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Comment thread tap-snapshots/TAP.test.cjs Outdated
Comment thread tap-snapshots/smoke-tests/test/index.js.test.cjs Outdated
@reggi
Copy link
Copy Markdown
Contributor Author

reggi commented May 18, 2026

Regarding the redact: false change in display.js:

The JSON output buffer now redacts each item individually by default. Callsites can opt out of redaction by passing { redact: false } in the buffer meta. This was needed because @npmcli/redact redacts all UUIDs (including stageId), and there is no way to selectively redact parts of a merged JSON object after the fact.

We could not simply add stageId as a separate buffer entry because getArrayOrObject uses Object.assign to merge items — a second { [pkgName]: { stageId } } would overwrite the first { [pkgName]: { ...tarball } }. So the approach is: put stageId on the tarball contents and mark that entire buffer item as redact: false via logTar. The tarball metadata (name, version, shasum, etc.) does not contain sensitive data, so skipping redaction there is safe.

Note: publish --json already outputs multiple JSON objects in the webauthn flow, so a separate JSON output for stageId was also considered as an alternative simpler approach, but per-item buffer redaction is more correct long-term.

reggi and others added 8 commits May 18, 2026 13:26
The JSON output buffer now redacts each buffered item individually
based on its meta. Callsites opt out with { redact: false }.
This allows stage publish to include stageId (a UUID) unredacted
while other buffer items remain redacted by default.

Also guards against actorType: null rendering as "(null)" in
stage view output.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Mirrors the pattern used by dist-tag, access, and owner commands
to provide subcommand suggestions (publish, list, view, approve,
reject, download) when pressing TAB after `npm stage`.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Cover the common failure modes users will hit:
- Invalid UUID format (validateUUID throws)
- 404 (stage-id not found or already approved/rejected)
- 403 (not an owner)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds a test case for the falsy actorType path on line 36 of
lib/utils/key-values.js, bringing branch coverage to 100%.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Mutate stageId directly onto res instead of spreading, matching
the existing transparencyLogUrl pattern. Spreading dropped
non-enumerable Response getters (headers, status, url).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
reggi and others added 3 commits May 18, 2026 15:21
Remove local variable aliases for this.#command, using the
getter directly everywhere for consistent style.

Also adds clarifying comment for sync tar usage in download.js.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@reggi reggi requested review from nishantms and owlstronaut May 18, 2026 19:30
reggi and others added 4 commits May 18, 2026 15:56
Change static name from 'stage' to 'publish' to accurately reflect
the subcommand identity within the stage command group.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Use pkgContents.name and include version in the download filename
for better identification of staged tarballs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Clarify that stage publish intentionally bypasses otplease since
2FA is deferred to the approve/reject step.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Update expected filenames to include version, matching the updated
download filename logic.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
owlstronaut
owlstronaut previously approved these changes May 19, 2026
nishantms
nishantms previously approved these changes May 20, 2026
reggi added a commit that referenced this pull request May 20, 2026
## Summary

Adds permission flags to trust create operations. Users must now specify
at least one of `--allow-publish` or `--allow-stage-publish` (alias:
`--allow-staged-publish`) when creating trust configurations.

## Changes

- Add `--allow-publish` and `--allow-stage-publish` flags to all trust
provider commands (GitHub, GitLab, CircleCI)
- Require at least one permission flag when creating trust
configurations
- Include permissions in the request body and display output
- Add `PERMISSIONS` constants for permission values
- Update tests and completion snapshots for new flags

## Related

- #9201

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Resolve conflict in lib/commands/trust/circleci.js by keeping both
imports: validateUUID from the new shared utils/validate-uuid.js
(introduced on this branch) and trustDefinitions from trust-cmd.js
(added on latest, needed for allow-publish/allow-stage-publish).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@reggi reggi dismissed stale reviews from nishantms and owlstronaut via c0af6bb May 20, 2026 16:26
@reggi reggi merged commit 254809e into latest May 20, 2026
74 of 77 checks passed
@reggi reggi deleted the reggi/stage branch May 20, 2026 16:59
@github-actions
Copy link
Copy Markdown
Contributor

⚠️ Backport to release/v11 failed.

This usually means the cherry-pick had conflicts. Please create a manual backport:

git fetch origin release/v11
git checkout -b backport/v11/9201 origin/release/v11
git cherry-pick -x 254809e318ee0046092d07d68a99154c3f672147
# resolve any conflicts, then:
git push origin backport/v11/9201
Error details
Command failed: git cherry-pick -x 254809e318ee0046092d07d68a99154c3f672147
error: could not apply 254809e31... feat: npm stage (#9201)
hint: After resolving the conflicts, mark them with
hint: "git add/rm <pathspec>", then run
hint: "git cherry-pick --continue".
hint: You can instead skip this commit with "git cherry-pick --skip".
hint: To abort and get back to the state before "git cherry-pick",
hint: run "git cherry-pick --abort".
hint: Disable this message with "git config set advice.mergeConflict false"

@reggi reggi mentioned this pull request May 20, 2026
reggi added a commit that referenced this pull request May 20, 2026
Backports #9201 to `release/v11`.

Original commit: 254809e

## Conflict resolutions

- **docs/lib/content/nav.yml** and **lib/utils/cmd-list.js**: kept
`shrinkwrap`, `star`, and `stars` (still present on `release/v11`) and
added `stage` in alphabetical order. On `latest`, those three commands
were dropped in separate commits not part of this backport.
- **lib/utils/tar.js**: combined both sides — kept the `key == null ?
tarball : { [key]: tarball }` form from `release/v11` and added the new
`redact` / `META` handling from the stage commit.
- **Snapshots** (`test/lib/docs.js.test.cjs`,
`test/lib/npm.js.test.cjs`, `smoke-tests/test/index.js.test.cjs`,
`tap-snapshots/test/lib/commands/publish.js.test.cjs`): regenerated
locally via `npm test` and `npm run snap -w smoke-tests`.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
reggi added a commit that referenced this pull request May 20, 2026
`npm stage download <id> --json` currently emits the package contents
under a literal `"undefined"` key because `logTar` is called without a
`key` option.

### Before

```json
{
  "undefined": {
    "name": "polo-meow-meow-meow",
    "version": "1.0.3",
    ...
  }
}
```

### After

```json
{
  "polo-meow-meow-meow": {
    "name": "polo-meow-meow-meow",
    "version": "1.0.3",
    ...
  }
}
```

This matches the JSON shape of `npm publish --json` and `npm pack
--json`.

### Background

The `key == null` fallback in `lib/utils/tar.js` (that would have
rendered a bare object when no key was passed) was removed from `latest`
in #9247 ("fix: sync json output of pack and publish") as a `BREAKING
CHANGE`. Per that PR:

> BREAKING CHANGE: the --json output of `npm pack` and `npm publish`
have changed. They are now always consistent, and in the same format.
>
> Previously, `npm pack` would output an array of entries and `npm
publish` an object. The `npm publish` object also changed forms
depending on if workspaces were being published.
>
> Now, the output is always an object with the package name as the top
level index.

When #9201 (npm stage) landed, it added a new `logTar` caller in
`lib/commands/stage/download.js` that did not pass a `key`, silently
violating the v12 contract established in #9247 and producing the
`"undefined"` wrapper. This PR brings the new caller into compliance.

### Repro

```
npm stage download <stage-id> --json
```

A follow-up backports this to `release/v11` for consistent output across
branches: #9381.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
dubzzz added a commit to dubzzz/fast-check that referenced this pull request May 21, 2026
## Description

> AI-agent disclosure: this PR was authored by an automated agent
(Claude Code) and has not been line-by-line reviewed by a human before
submission.

Switches every `publish_package_*` job in
`.github/workflows/build-status.yml` from `pnpm publish` to [`npm stage
publish`](npm/cli#9201). With staged publishing,
CI uploads the tarball into a holding area on the registry instead of
releasing it immediately — a maintainer then has to approve the staged
version (with 2FA) before it becomes installable. End users still
receive the same artifacts on npm, but the release step is no longer
fully automated: there is now an explicit approval gate between a tag
being pushed and the package going live. This is a follow-up to #7011
(which already removed the OTP prompt from the publish call); together
they move the workflow toward npm's recommended staged-release flow.

Each publish job now:

- installs `npm@latest` so the runner-provided npm is upgraded before
publishing — the `stage` subcommand landed in npm 11.15.0 and is not yet
bundled with Node 24's default npm;
- runs both `npm install -g` and `npm stage publish` from the parent
directory (`cd ..`), because the repo's `package.json` pins pnpm via
`devEngines` and would otherwise block npm from executing inside the
workspace;
- no longer needs the `pnpm/action-setup` step or the pnpm-specific
`--no-git-checks` flag, both of which were dropped from the seven
affected jobs.

Non-publish jobs were intentionally left alone and still install pnpm —
only the release plumbing changes. Because the diff only touches the
workflow file, there are no source, dependency, or published-artifact
changes, no semver impact to flag, and no changeset to add; the new code
path is exercised the next time a `v*` tag is pushed.

## Checklist

— _Don't delete this checklist and make sure you do the following before
opening the PR_

- [x] I have a full understanding of every line in this PR — whether the
code was hand-written, AI-generated, copied from external sources or
produced by any other tool
- [x] I flagged the impact of my change (minor / patch / major) either
by running `pnpm run bump` or by following the instructions from the
changeset bot
- [x] I kept this PR focused on a single concern and did not bundle
unrelated changes
- [x] I followed the [gitmoji](https://gitmoji.dev/) specification for
the name of the PR, including the package scope (e.g. `🐛(vitest)
Something...`) when the change targets a package other than `fast-check`
- [x] I added relevant tests and they would have failed without my PR
(when applicable)

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants