feat: npm stage#9201
Conversation
|
Why would download not require 2fa but view require it? Either both should, or neither, I’d expect. |
@ljharb that was a mistake, fixed! |
|
(i'm assuming that all the "no"s still require one factor) |
All staged content routes will require authentication with access to that package. |
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>
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>
| 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 } |
There was a problem hiding this comment.
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).
| version, | ||
| tag, | ||
| 'date staged': createdAt, | ||
| 'staged by': `${actor} (${actorType})`, |
There was a problem hiding this comment.
If the server returns actorType: null, this renders as "staged by: user (null)". Quick guard:
| 'staged by': `${actor} (${actorType})`, | |
| 'staged by': actorType ? `${actor} (${actorType})` : actor, |
| }, | ||
| }) | ||
| stream.end(tarballData) | ||
| if (!manifestJson) { |
There was a problem hiding this comment.
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:
| 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'), | ||
| } |
There was a problem hiding this comment.
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 => |
There was a problem hiding this comment.
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', | ||
| }) | ||
| }) |
There was a problem hiding this comment.
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 →
validateUUIDthrows - 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 } |
There was a problem hiding this comment.
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}` |
There was a problem hiding this comment.
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.
|
|
||
| async #publish (args, { workspace } = {}) { | ||
| log.verbose('publish', replaceInfo(args)) | ||
| const command = this.#command |
There was a problem hiding this comment.
nit: mixed style — #publish captures const command = this.#command and uses the local everywhere, but #skipped (above) reads this.#command directly. Pick one.
…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
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
npm stage publish [<package-spec>]npm stage list [<package-spec>]npm stage view <stage-id>npm stage approve <stage-id>npm stage reject <stage-id>npm stage download <stage-id>How It Works
npm stage publishusing any token type (no 2FA needed). The package version is held in a pending state, not publicly available.npm stage listto see pending staged packages, andnpm stage view <id>ornpm stage download <id>to inspect them.npm stage approve <id>(with 2FA) to publish, ornpm stage reject <id>to discard.Key Behaviors
npm publishcontinues to work alongside staged publishing.npm stage publishhas full parity withnpm publish(respects"private": true, workspace support, etc).Future Work
npm trust, with--allow-publishand--allow-stage-publishflags to control whether a trust relationship can be used fornpm publish,npm stage publish, or both.