Skip to content

Commit 254809e

Browse files
reggiCopilot
andauthored
feat: npm stage (#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](https://github.com/npm/cli/blob/014d8b211ae4a2eb35e65a3f54cb962ca04f009f/docs/lib/content/commands/npm-stage.md) ## 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. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 18ebb0f commit 254809e

31 files changed

Lines changed: 1657 additions & 65 deletions

File tree

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
---
2+
title: npm-stage
3+
section: 1
4+
description: Stage packages for publishing
5+
---
6+
7+
### Synopsis
8+
9+
<!-- AUTOGENERATED USAGE DESCRIPTIONS -->
10+
11+
### Description
12+
13+
Staged publishing allows package maintainers to require proof-of-presence
14+
for all publishes. Proof-of-presence is where a human is involved,
15+
interjects, and provides authentication (2FA) during an action β€” in this
16+
case, publishing an npm package.
17+
18+
Typically when maintainers use automated workflows to publish,
19+
proof-of-presence is lacking as there's no convenient way to interject the
20+
process and provide 2FA, as is the case for publishing with a granular
21+
access token with bypass and the trusted publishing flow. Staged publishing
22+
allows users to have their automated workflows stage a package without a 2FA
23+
prompt, deferring the act of 2FA, allowing the maintainer to approve the
24+
staged package and publish at a later point.
25+
26+
The `npm stage publish` command packs the current working directory and
27+
places that version of the package into the registry in a state where it's
28+
not available for public access, allowing maintainers to approve the package
29+
at a later point in time. The act of staging does not prompt for 2FA and can be done with any token
30+
type, the act of approving will.
31+
32+
Key behaviors:
33+
34+
* Staged packages share the same semver version unique index as published
35+
packages β€” you cannot publish a version that already exists as a staged
36+
version for that package.
37+
* You can still publish packages normally while you have staged packages
38+
pending.
39+
* You can stage multiple versions of the same package.
40+
* `npm stage publish` has parity with `npm publish` and will respect
41+
`"private": true` in `package.json`, refusing to stage the package.
42+
43+
### Prerequisites
44+
45+
Before using `npm stage` commands, ensure the following requirements are met:
46+
47+
* **Write permissions on the package:** You must have write access to the
48+
package you're configuring.
49+
* **Package must exist:** The package you're configuring must already exist
50+
on the npm registry.
51+
* **2FA enabled on your account:** Commands that require 2FA will prompt you
52+
to authenticate. If you don't already have 2FA enabled on your account,
53+
you must enable it before using these commands.
54+
55+
### Subcommands
56+
57+
* `npm stage publish [<package-spec>]` - Stage a package for publishing
58+
* `npm stage list [<package-spec>]` - List all staged package versions
59+
* `npm stage view <stage-id>` - View details of a specific staged package
60+
* `npm stage approve <stage-id>` - Approve a staged package for publishing
61+
* `npm stage reject <stage-id>` - Reject a staged package
62+
* `npm stage download <stage-id>` - Download the tarball for inspection
63+
64+
### 2FA Requirements by Subcommand
65+
66+
| Command | Requires 2FA | Notes |
67+
| --- | --- | --- |
68+
| `npm stage publish` | No | Designed for automated workflows; defers 2FA to approval |
69+
| `npm stage list` | No | View staged packages |
70+
| `npm stage view` | No | View staged package details |
71+
| `npm stage approve` | Yes | Prompts for 2FA to publish the staged package |
72+
| `npm stage reject` | Yes | Prompts for 2FA to permanently remove the staged package |
73+
| `npm stage download` | No | Downloads the tarball for local inspection |
74+
75+
### Tag Behavior
76+
77+
The `--tag` flag follows the same logic as `npm publish`. If no tag is
78+
provided, the `latest` tag is used by default. For pre-release versions
79+
(e.g., `1.0.0-beta.1`) and non-latest semver versions, the tag must be
80+
explicitly provided β€” otherwise the CLI will error, just as `npm publish`
81+
would.
82+
83+
The tag is an immutable property of the staged package. Once a package is
84+
staged with a given tag, the tag cannot be changed. If you need to stage the
85+
same version with a different tag, you must first reject the existing staged
86+
package using `npm stage reject` and then re-stage it with the desired tag.
87+
88+
### Token Behavior
89+
90+
The key difference with staged publishing is that `npm stage publish` never
91+
requires a 2FA prompt, regardless of token type. This is what makes it
92+
suitable for automated workflows. The goal of `npm stage publish` is
93+
deferring proof-of-presence to a later point in time.
94+
95+
| Token Type | `npm stage publish` | `npm publish` |
96+
| --- | --- | --- |
97+
| GAT with bypass | Can stage | Can publish (if allowed by package publishing access) |
98+
| GAT without bypass | Can stage | 2FA prompt (if allowed by package publishing access) |
99+
| Session token | Can stage | 2FA prompt |
100+
| Trust token (OIDC) | Can stage (if allowed) | Can publish (if allowed) |
101+
102+
### Trust Relationship Permissions
103+
104+
With staged publishing, trust relationships now support granular command
105+
permissions. Shortlived tokens issued through trust relationships can only be
106+
used with `npm stage publish` and `npm publish`. Shortlived tokens cannot run
107+
`npm stage` subcommands.
108+
109+
`npm trust <provider>` supports `--allow-publish` and `--allow-stage-publish`
110+
to control which commands are available through each trust relationship.
111+
112+
### Best Practices
113+
114+
**Note:** The addition of staged publishing does not make your account or org
115+
more secure. Maintainers must still use the best practices listed below.
116+
117+
1. **Delete Granular Access Tokens (GAT) with bypass 2FA enabled.**
118+
Now with staged publishing, we've eliminated the need for a GAT token
119+
that can bypass 2FA. We encourage you to delete all your tokens with
120+
bypass enabled and switch to using a trust relationship in your automated
121+
workflows, or create a GAT without bypass and use `npm stage publish`.
122+
123+
2. **Disallow tokens from publishing at the package level.**
124+
All packages have their own access controls under "package access"
125+
allowing packages to be published with bypass tokens, which is no longer
126+
a necessity. We encourage you to select "Require two-factor
127+
authentication and disallow tokens (recommended)" for all your packages
128+
on the package access page.
129+
130+
3. **Configure trust relationship permissions to prevent `npm publish`.**
131+
We encourage you to only enable `npm stage publish` on your trust
132+
relationships and disable `npm publish`.
133+
134+
### Configuration
135+
136+
<!-- AUTOGENERATED CONFIG DESCRIPTIONS -->
137+
138+
### See Also
139+
140+
* [npm publish](/commands/npm-publish)
141+
* [npm unpublish](/commands/npm-unpublish)
142+
* [npm trust](/commands/npm-trust)

β€Ždocs/lib/content/nav.ymlβ€Ž

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,9 @@
159159
- title: npm set
160160
url: /commands/npm-set
161161
description: Set a value in the npm configuration
162+
- title: npm stage
163+
url: /commands/npm-stage
164+
description: Stage packages for publishing
162165
- title: npm start
163166
url: /commands/npm-start
164167
description: Start a package

β€Žlib/commands/publish.jsβ€Ž

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const { log, output } = require('proc-log')
1+
const { log, output, META } = require('proc-log')
22
const semver = require('semver')
33
const pack = require('libnpmpack')
44
const libpub = require('libnpmpublish').publish
@@ -14,11 +14,17 @@ const { getContents, logTar } = require('../utils/tar.js')
1414
const { flatten } = require('@npmcli/config/lib/definitions')
1515
const pkgJson = require('@npmcli/package-json')
1616
const BaseCommand = require('../base-cmd.js')
17-
const { oidc } = require('../../lib/utils/oidc.js')
17+
const { oidc } = require('../utils/oidc.js')
1818

1919
class Publish extends BaseCommand {
2020
static description = 'Publish a package'
2121
static name = 'publish'
22+
static stage = false
23+
24+
get isStage () {
25+
return this.constructor.stage
26+
}
27+
2228
static params = [
2329
'tag',
2430
'access',
@@ -60,13 +66,17 @@ class Publish extends BaseCommand {
6066
if (err.code !== 'EPRIVATE') {
6167
throw err
6268
}
63-
log.warn('publish', `Skipping workspace ${this.npm.chalk.cyan(name)}, marked as ${this.npm.chalk.bold('private')}`)
69+
log.warn(this.#command, `Skipping workspace ${this.npm.chalk.cyan(name)}, marked as ${this.npm.chalk.bold('private')}`)
6470
}
6571
}
6672
}
6773

74+
get #command () {
75+
return this.isStage ? 'stage' : 'publish'
76+
}
77+
6878
async #publish (args, { workspace } = {}) {
69-
log.verbose('publish', replaceInfo(args))
79+
log.verbose(this.#command, replaceInfo(args))
7080

7181
const unicode = this.npm.config.get('unicode')
7282
const dryRun = this.npm.config.get('dry-run')
@@ -138,7 +148,6 @@ class Publish extends BaseCommand {
138148
const noCreds = !(creds.token || creds.username || creds.certfile && creds.keyfile)
139149
const outputRegistry = replaceInfo(registry)
140150

141-
// if a workspace package is marked private then we skip it
142151
if (workspace && manifest.private) {
143152
throw Object.assign(
144153
new Error(`This package has been marked as private
@@ -150,7 +159,7 @@ class Publish extends BaseCommand {
150159
if (noCreds) {
151160
const msg = `This command requires you to be logged in to ${outputRegistry}`
152161
if (dryRun) {
153-
log.warn('', `${msg} (dry-run)`)
162+
log.warn(this.#command, `${msg} (dry-run)`)
154163
} else {
155164
throw Object.assign(new Error(msg), { code: 'ENEEDAUTH' })
156165
}
@@ -171,20 +180,36 @@ class Publish extends BaseCommand {
171180
}
172181

173182
const access = opts.access === null ? 'default' : opts.access
174-
let msg = `Publishing to ${outputRegistry} with tag ${defaultTag} and ${access} access`
183+
const verb = this.isStage ? 'Staging' : 'Publishing'
184+
let msg = `${verb} to ${outputRegistry} with tag ${defaultTag} and ${access} access`
175185
if (dryRun) {
176186
msg = `${msg} (dry-run)`
177187
}
178188

179189
log.notice('', msg)
180190

191+
let stageId
181192
if (!dryRun) {
182-
await otplease(this.npm, opts, o => libpub(manifest, tarballData, o))
193+
if (this.isStage) {
194+
// Stage intentionally bypasses otplease β€” 2FA is deferred to approve/reject
195+
const res = await libpub(manifest, tarballData, {
196+
...opts,
197+
command: this.#command,
198+
stage: true,
199+
})
200+
stageId = res.stageId
201+
} else {
202+
await otplease(this.npm, opts, o => libpub(manifest, tarballData, o))
203+
}
183204
}
184205

185206
// In json mode we don't log until the publish has completed as this will add it to the output only if completes successfully
186207
if (json) {
187-
logPkg()
208+
if (stageId) {
209+
pkgContents.stageId = stageId
210+
}
211+
logTar(pkgContents, {
212+
unicode, json, key: pkgContents.name, redact: stageId ? false : undefined })
188213
}
189214

190215
if (spec.type === 'directory' && !ignoreScripts) {
@@ -204,7 +229,15 @@ class Publish extends BaseCommand {
204229
}
205230

206231
if (!json && !silent) {
207-
output.standard(`+ ${pkgContents.id}`)
232+
if (this.isStage) {
233+
const stagedMsg = stageId
234+
? `+ ${pkgContents.id} (staged with id ${stageId})`
235+
: `+ ${pkgContents.id} (staged)`
236+
output.standard(stagedMsg, { [META]: true, redact: false })
237+
log.notice(this.#command, `package ${pkgContents.id} has been staged with tag ${defaultTag}`)
238+
} else {
239+
output.standard(`+ ${pkgContents.id}`)
240+
}
208241
}
209242
}
210243

@@ -245,8 +278,8 @@ class Publish extends BaseCommand {
245278
const changes = []
246279
const pkg = await pkgJson.fix(spec.fetchSpec, { changes })
247280
if (changes.length && logWarnings) {
248-
log.warn('publish', 'npm auto-corrected some errors in your package.json when publishing. Please run "npm pkg fix" to address these errors.')
249-
log.warn('publish', `errors corrected:\n${changes.join('\n')}`)
281+
log.warn(this.#command, 'npm auto-corrected some errors in your package.json when publishing. Please run "npm pkg fix" to address these errors.')
282+
log.warn(this.#command, `errors corrected:\n${changes.join('\n')}`)
250283
}
251284
// Prepare is the special function for publishing, different than normalize
252285
const { content } = await pkg.prepare()
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
const { log, output, META } = require('proc-log')
2+
const npmFetch = require('npm-registry-fetch')
3+
const { otplease } = require('../../utils/auth.js')
4+
const { validateUUID } = require('../../utils/validate-uuid.js')
5+
const BaseCommand = require('../../base-cmd.js')
6+
7+
class StageApprove extends BaseCommand {
8+
static description = 'Approve a staged package, publishing it to the npm registry'
9+
static name = 'approve'
10+
static usage = ['<stage-id>']
11+
static params = ['otp', 'registry']
12+
static positionals = 1
13+
14+
async exec (args) {
15+
if (!args[0]) {
16+
throw this.usageError('Missing required <stage-id>')
17+
}
18+
const stageId = args[0]
19+
validateUUID(stageId, 'stage-id')
20+
const opts = { ...this.npm.flatOptions }
21+
22+
log.notice('', `Approving staged package ${stageId}`)
23+
24+
await otplease(this.npm, opts, o =>
25+
npmFetch.json(`/-/stage/${stageId}/approve`, {
26+
...o,
27+
method: 'POST',
28+
})
29+
)
30+
31+
output.standard(`Staged package ${stageId} approved and published successfully.`, { [META]: true, redact: false })
32+
}
33+
}
34+
35+
module.exports = StageApprove
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
const { log, output, META } = require('proc-log')
2+
const { writeFile } = require('node:fs/promises')
3+
const { resolve } = require('node:path')
4+
const tar = require('tar')
5+
const npmFetch = require('npm-registry-fetch')
6+
const { getContents, logTar } = require('../../utils/tar.js')
7+
const { validateUUID } = require('../../utils/validate-uuid.js')
8+
const BaseCommand = require('../../base-cmd.js')
9+
10+
class StageDownload extends BaseCommand {
11+
static description = 'Download the tarball of a staged package for inspection'
12+
static name = 'download'
13+
static usage = ['<stage-id>']
14+
static params = ['json', 'registry']
15+
static positionals = 1
16+
17+
async exec (args) {
18+
if (!args[0]) {
19+
throw this.usageError('Missing required <stage-id>')
20+
}
21+
const stageId = args[0]
22+
validateUUID(stageId, 'stage-id')
23+
const opts = { ...this.npm.flatOptions }
24+
const unicode = this.npm.config.get('unicode')
25+
const json = this.npm.config.get('json')
26+
27+
log.notice('', `Downloading staged package ${stageId}`)
28+
29+
const res = await npmFetch(`/-/stage/${stageId}/tarball`, opts)
30+
const data = Buffer.from(await res.arrayBuffer())
31+
32+
const manifest = await this.#readManifestFromTarball(data)
33+
const pkgContents = await getContents(manifest, data)
34+
logTar(pkgContents, { unicode, json })
35+
36+
const safeName = pkgContents.name.replace('@', '').replace('/', '-')
37+
const filename = `${safeName}-${pkgContents.version}-${stageId}.tgz`
38+
const dest = resolve(process.cwd(), filename)
39+
40+
await writeFile(dest, data)
41+
if (!json) {
42+
output.standard(filename, { [META]: true, redact: false })
43+
}
44+
}
45+
46+
async #readManifestFromTarball (tarballData) {
47+
let manifestJson
48+
const stream = tar.t({
49+
onentry (entry) {
50+
if (entry.path === 'package/package.json') {
51+
const chunks = []
52+
entry.on('data', c => chunks.push(c))
53+
entry.on('end', () => {
54+
manifestJson = JSON.parse(Buffer.concat(chunks).toString())
55+
})
56+
} else {
57+
entry.resume()
58+
}
59+
},
60+
})
61+
// node-tar uses Minipass which processes synchronously on .end()
62+
stream.end(tarballData)
63+
if (!manifestJson) {
64+
throw new Error('Could not read package.json from tarball')
65+
}
66+
return manifestJson
67+
}
68+
}
69+
70+
module.exports = StageDownload

0 commit comments

Comments
Β (0)