Skip to content

feat: npm stage#9201

Open
reggi wants to merge 4 commits into
latestfrom
reggi/stage
Open

feat: npm stage#9201
reggi wants to merge 4 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
this.#writeOutput(output.KEYS.standard, meta, JSON.stringify(json, null, 2))
// JSON output is structured data for programmatic
// consumption and should not have its values redacted
const jsonMeta = { ...meta, redact: false }
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sets redact: false for all JSON output globally — not just stage commands. redactLog() strips embedded credentials from registry URLs (e.g. https://user:token@registry/...), so disabling it here means any --json command could leak creds in output.

Worth splitting into its own PR with a targeted security review, or scoping it to stage commands only (e.g. via a meta flag on the output call site).

Comment thread lib/utils/key-values.js
version,
tag,
'date staged': createdAt,
'staged by': `${actor} (${actorType})`,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the server returns actorType: null, this renders as "staged by: user (null)". Quick guard:

Suggested change
'staged by': `${actor} (${actorType})`,
'staged by': actorType ? `${actor} (${actorType})` : actor,

},
})
stream.end(tarballData)
if (!manifestJson) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This relies on tar.t().end() executing synchronously via Minipass internals. It works, but reads like a bug to anyone who doesn't know node-tar. A one-liner would save a future "wait, shouldn't this be async?" moment:

Suggested change
if (!manifestJson) {
// node-tar uses Minipass which processes synchronously on .end()
stream.end(tarballData)

approve: require('./approve.js'),
reject: require('./reject.js'),
download: require('./download.js'),
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The other directory-style multi-subcommand command (trust) defines static completion on the parent so npm stage <TAB> completes to the subcommand list. Worth mirroring here for consistency:

static async completion (opts) {
  const argv = opts.conf.argv.remain
  if (argv.length === 2) {
    return Object.keys(Stage.subcommands)
  }
  return []
}

log.notice('', `Rejecting staged package ${stageId}`)
log.warn('', 'Rejecting will permanently delete this staged publish record and tarball from the registry.')

await otplease(this.npm, opts, o =>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The log.warn fires after the action is committed (it's just informational). There's no interactive confirm and no --force flag, so a fat-fingered UUID permanently destroys the staged tarball with no recovery.

May be by design (staged versions aren't published, blast radius is lower than unpublish) — but worth either gating on --force/-y or a confirmation prompt. Curious what the thinking was.

await t.rejects(npm.exec('stage', ['approve']), {
code: 'EUSAGE',
})
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only happy path + missing-arg are covered here (and same in reject.js, plus thin coverage in view.js/download.js). The error paths users will actually hit aren't tested:

  • Invalid UUID format → validateUUID throws
  • 404 (stage-id doesn't exist or already approved/rejected)
  • 403 (not an owner)

These are the common failure modes — worth a few nock.reply(404) / nock.reply(403) cases.

}
if (opts.stage) {
const json = await res.json()
return { ...res, stageId: json.stageId }
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

res from npm-registry-fetch is a Minipass-fetch Response — .headers, .status, .url are non-enumerable getters, so { ...res, stageId } drops them, and await res.json() already consumed the body. Today's only caller (Publish.#publish) just reads stageId so it works, but any future caller expecting a Response back will get undefined for everything except stageId. Either Object.assign(res, { stageId }) to preserve the prototype, or return { stageId } and document it.

)

const res = await npmFetch(spec.escapedName, {
const stageRoute = `/-/stage/package/${spec.escapedName}`
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

libnpmpublish is a separately published package whose README enumerates opts and the publish() contract for external consumers. This PR adds opts.stage (changes URL + method) and changes the return shape (now includes stageId) without updating workspaces/libnpmpublish/README.md. Worth a short blurb under "opts of note" + a line on the new return field so out-of-tree users discover staging exists.

Comment thread lib/commands/publish.js

async #publish (args, { workspace } = {}) {
log.verbose('publish', replaceInfo(args))
const command = this.#command
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: mixed style — #publish captures const command = this.#command and uses the local everywhere, but #skipped (above) reads this.#command directly. Pick one.

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants