Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions docs/lib/content/commands/npm-stage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
---
title: npm-stage
section: 1
description: Stage packages for publishing
---

### Synopsis

<!-- AUTOGENERATED USAGE DESCRIPTIONS -->

### Description

Staged publishing allows package maintainers to require proof-of-presence
for all publishes. Proof-of-presence is where a human is involved,
interjects, and provides authentication (2FA) during an action β€” in this
case, publishing an npm package.

Typically when maintainers use automated workflows to publish,
proof-of-presence is lacking as there's no convenient way to interject the
process and provide 2FA, as is the case for publishing with a granular
access token with bypass and the trusted publishing flow. Staged publishing
allows users to have their automated workflows stage a package without a 2FA
prompt, deferring the act of 2FA, allowing the maintainer to approve the
staged package and publish at a later point.

The `npm stage publish` command packs the current working directory and
places that version of the package into the registry in a state where it's
not available for public access, allowing maintainers to approve the package
at a later point in time. The act of staging does not prompt for 2FA and can be done with any token
type, the act of approving will.

Key behaviors:

* Staged packages share the same semver version unique index as published
packages β€” you cannot publish a version that already exists as a staged
version for that package.
* You can still publish packages normally while you have staged packages
pending.
* You can stage multiple versions of the same package.
* `npm stage publish` has parity with `npm publish` and will respect
`"private": true` in `package.json`, refusing to stage the package.

### Prerequisites

Before using `npm stage` commands, ensure the following requirements are met:

* **Write permissions on the package:** You must have write access to the
package you're configuring.
* **Package must exist:** The package you're configuring must already exist
on the npm registry.
* **2FA enabled on your account:** Commands that require 2FA will prompt you
to authenticate. If you don't already have 2FA enabled on your account,
you must enable it before using these commands.

### Subcommands

* `npm stage publish [<package-spec>]` - Stage a package for publishing
* `npm stage list [<package-spec>]` - List all staged package versions
* `npm stage view <stage-id>` - View details of a specific staged package
* `npm stage approve <stage-id>` - Approve a staged package for publishing
* `npm stage reject <stage-id>` - Reject a staged package
* `npm stage download <stage-id>` - Download the tarball for inspection

### 2FA Requirements by Subcommand

| Command | Requires 2FA | Notes |
| --- | --- | --- |
| `npm stage publish` | No | Designed for automated workflows; defers 2FA to approval |
| `npm stage list` | Yes | Prompts for 2FA to view staged packages |
| `npm stage view` | Yes | Prompts for 2FA to view staged package details |
| `npm stage approve` | Yes | Prompts for 2FA to publish the staged package |
| `npm stage reject` | Yes | Prompts for 2FA to permanently remove the staged package |
| `npm stage download` | No | Downloads the tarball for local inspection |

### Tag Behavior

The `--tag` flag follows the same logic as `npm publish`. If no tag is
provided, the `latest` tag is used by default. For pre-release versions
(e.g., `1.0.0-beta.1`) and non-latest semver versions, the tag must be
explicitly provided β€” otherwise the CLI will error, just as `npm publish`
would.

The tag is an immutable property of the staged package. Once a package is
staged with a given tag, the tag cannot be changed. If you need to stage the
same version with a different tag, you must first reject the existing staged
package using `npm stage reject` and then re-stage it with the desired tag.

### Token Behavior

The key difference with staged publishing is that `npm stage publish` never
requires a 2FA prompt, regardless of token type. This is what makes it
suitable for automated workflows. The goal of `npm stage publish` is
deferring proof-of-presence to a later point in time.

| Token Type | `npm stage publish` | `npm publish` |
| --- | --- | --- |
| GAT with bypass | Can stage | Can publish (if allowed by package publishing access) |
| GAT without bypass | Can stage | 2FA prompt (if allowed by package publishing access) |
| Session token | Can stage | 2FA prompt |
| Trust token (OIDC) | Can stage (if allowed) | Can publish (if allowed) |

### Trust Relationship Permissions

With staged publishing, trust relationships now support granular command
permissions. Shortlived tokens issued through trust relationships can only be
used with `npm stage publish` and `npm publish`. Shortlived tokens cannot run
`npm stage` subcommands.

`npm trust <provider>` supports `--allow-publish` and `--allow-stage-publish`
to control which commands are available through each trust relationship.

### Best Practices

**Note:** The addition of staged publishing does not make your account or org
more secure. Maintainers must still use the best practices listed below.

1. **Delete Granular Access Tokens (GAT) with bypass 2FA enabled.**
Now with staged publishing, we've eliminated the need for a GAT token
that can bypass 2FA. We encourage you to delete all your tokens with
bypass enabled and switch to using a trust relationship in your automated
workflows, or create a GAT without bypass and use `npm stage publish`.

2. **Disallow tokens from publishing at the package level.**
All packages have their own access controls under "package access"
allowing packages to be published with bypass tokens, which is no longer
a necessity. We encourage you to select "Require two-factor
authentication and disallow tokens (recommended)" for all your packages
on the package access page.

3. **Configure trust relationship permissions to prevent `npm publish`.**
We encourage you to only enable `npm stage publish` on your trust
relationships and disable `npm publish`.

### Configuration

<!-- AUTOGENERATED CONFIG DESCRIPTIONS -->

### See Also

* [npm publish](/commands/npm-publish)
* [npm unpublish](/commands/npm-unpublish)
* [npm trust](/commands/npm-trust)
3 changes: 3 additions & 0 deletions docs/lib/content/nav.yml
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,9 @@
- title: npm shrinkwrap
url: /commands/npm-shrinkwrap
description: Lock down dependency versions for publication
- title: npm stage
url: /commands/npm-stage
description: Stage packages for publishing
- title: npm star
url: /commands/npm-star
description: Mark your favorite packages
Expand Down
51 changes: 40 additions & 11 deletions lib/commands/publish.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const { log, output } = require('proc-log')
const { log, output, META } = require('proc-log')
const semver = require('semver')
const pack = require('libnpmpack')
const libpub = require('libnpmpublish').publish
Expand All @@ -14,11 +14,17 @@ const { getContents, logTar } = require('../utils/tar.js')
const { flatten } = require('@npmcli/config/lib/definitions')
const pkgJson = require('@npmcli/package-json')
const BaseCommand = require('../base-cmd.js')
const { oidc } = require('../../lib/utils/oidc.js')
const { oidc } = require('../utils/oidc.js')

class Publish extends BaseCommand {
static description = 'Publish a package'
static name = 'publish'
static stage = false

get isStage () {
return this.constructor.stage
}

static params = [
'tag',
'access',
Expand Down Expand Up @@ -60,13 +66,18 @@ class Publish extends BaseCommand {
if (err.code !== 'EPRIVATE') {
throw err
}
log.warn('publish', `Skipping workspace ${this.npm.chalk.cyan(name)}, marked as ${this.npm.chalk.bold('private')}`)
log.warn(this.#command, `Skipping workspace ${this.npm.chalk.cyan(name)}, marked as ${this.npm.chalk.bold('private')}`)
}
}
}

get #command () {
return this.isStage ? 'stage' : 'publish'
}

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

const unicode = this.npm.config.get('unicode')
const dryRun = this.npm.config.get('dry-run')
Expand Down Expand Up @@ -138,7 +149,6 @@ class Publish extends BaseCommand {
const noCreds = !(creds.token || creds.username || creds.certfile && creds.keyfile)
const outputRegistry = replaceInfo(registry)

// if a workspace package is marked private then we skip it
if (workspace && manifest.private) {
throw Object.assign(
new Error(`This package has been marked as private
Expand All @@ -150,7 +160,7 @@ class Publish extends BaseCommand {
if (noCreds) {
const msg = `This command requires you to be logged in to ${outputRegistry}`
if (dryRun) {
log.warn('', `${msg} (dry-run)`)
log.warn(command, `${msg} (dry-run)`)
} else {
throw Object.assign(new Error(msg), { code: 'ENEEDAUTH' })
}
Expand All @@ -171,19 +181,29 @@ class Publish extends BaseCommand {
}

const access = opts.access === null ? 'default' : opts.access
let msg = `Publishing to ${outputRegistry} with tag ${defaultTag} and ${access} access`
const verb = this.isStage ? 'Staging' : 'Publishing'
let msg = `${verb} to ${outputRegistry} with tag ${defaultTag} and ${access} access`
if (dryRun) {
msg = `${msg} (dry-run)`
}

log.notice('', msg)

let stageId
if (!dryRun) {
await otplease(this.npm, opts, o => libpub(manifest, tarballData, o))
if (this.isStage) {
const res = await libpub(manifest, tarballData, { ...opts, command, stage: true })
stageId = res.stageId
} else {
await otplease(this.npm, opts, o => libpub(manifest, tarballData, o))
}
}

// In json mode we don't log until the publish has completed as this will add it to the output only if completes successfully
if (json) {
if (stageId) {
pkgContents.stageId = stageId
}
logPkg()
}

Expand All @@ -204,7 +224,15 @@ class Publish extends BaseCommand {
}

if (!json && !silent) {
output.standard(`+ ${pkgContents.id}`)
if (this.isStage) {
const stagedMsg = stageId
? `+ ${pkgContents.id} (staged with id ${stageId})`
: `+ ${pkgContents.id} (staged)`
output.standard(stagedMsg, { [META]: true, redact: false })
log.notice(command, `package ${pkgContents.id} has been staged with tag ${defaultTag}`)
} else {
output.standard(`+ ${pkgContents.id}`)
}
}
}

Expand Down Expand Up @@ -240,13 +268,14 @@ class Publish extends BaseCommand {
// otherwise, get the full metadata from whatever it is
// XXX can't pacote read the manifest from a directory?
async #getManifest (spec, opts, logWarnings = false) {
const command = this.#command
let manifest
if (spec.type === 'directory') {
const changes = []
const pkg = await pkgJson.fix(spec.fetchSpec, { changes })
if (changes.length && logWarnings) {
log.warn('publish', 'npm auto-corrected some errors in your package.json when publishing. Please run "npm pkg fix" to address these errors.')
log.warn('publish', `errors corrected:\n${changes.join('\n')}`)
log.warn(command, 'npm auto-corrected some errors in your package.json when publishing. Please run "npm pkg fix" to address these errors.')
log.warn(command, `errors corrected:\n${changes.join('\n')}`)
}
// Prepare is the special function for publishing, different than normalize
const { content } = await pkg.prepare()
Expand Down
35 changes: 35 additions & 0 deletions lib/commands/stage/approve.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
const { log, output, META } = require('proc-log')
const npmFetch = require('npm-registry-fetch')
const { otplease } = require('../../utils/auth.js')
const { validateUUID } = require('../../utils/validate-uuid.js')
const BaseCommand = require('../../base-cmd.js')

class StageApprove extends BaseCommand {
static description = 'Approve a staged package, publishing it to the npm registry'
static name = 'approve'
static usage = ['<stage-id>']
static params = ['otp', 'registry']
static positionals = 1

async exec (args) {
if (!args[0]) {
throw this.usageError('Missing required <stage-id>')
}
const stageId = args[0]
validateUUID(stageId, 'stage-id')
const opts = { ...this.npm.flatOptions }

log.notice('', `Approving staged package ${stageId}`)

await otplease(this.npm, opts, o =>
npmFetch.json(`/-/stage/${stageId}/approve`, {
...o,
method: 'POST',
})
)

output.standard(`Staged package ${stageId} approved and published successfully.`, { [META]: true, redact: false })
}
}

module.exports = StageApprove
69 changes: 69 additions & 0 deletions lib/commands/stage/download.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
const { log, output, META } = require('proc-log')
const { writeFile } = require('node:fs/promises')
const { resolve } = require('node:path')
const tar = require('tar')
const npmFetch = require('npm-registry-fetch')
const { getContents, logTar } = require('../../utils/tar.js')
const { validateUUID } = require('../../utils/validate-uuid.js')
const BaseCommand = require('../../base-cmd.js')

class StageDownload extends BaseCommand {
static description = 'Download the tarball of a staged package for inspection'
static name = 'download'
static usage = ['<stage-id>']
static params = ['json', 'registry']
static positionals = 1

async exec (args) {
if (!args[0]) {
throw this.usageError('Missing required <stage-id>')
}
const stageId = args[0]
validateUUID(stageId, 'stage-id')
const opts = { ...this.npm.flatOptions }
const unicode = this.npm.config.get('unicode')
const json = this.npm.config.get('json')

log.notice('', `Downloading staged package ${stageId}`)

const res = await npmFetch(`/-/stage/${stageId}/tarball`, opts)
const data = Buffer.from(await res.arrayBuffer())

const manifest = await this.#readManifestFromTarball(data)
const pkgContents = await getContents(manifest, data)
logTar(pkgContents, { unicode, json })

const safeName = manifest.name.replace('@', '').replace('/', '-')
const filename = `${safeName}-${stageId}.tgz`
const dest = resolve(process.cwd(), filename)

await writeFile(dest, data)
if (!json) {
output.standard(filename, { [META]: true, redact: false })
}
}

async #readManifestFromTarball (tarballData) {
let manifestJson
const stream = tar.t({
onentry (entry) {
if (entry.path === 'package/package.json') {
const chunks = []
entry.on('data', c => chunks.push(c))
entry.on('end', () => {
manifestJson = JSON.parse(Buffer.concat(chunks).toString())
})
} else {
entry.resume()
}
},
})
stream.end(tarballData)
if (!manifestJson) {
throw new Error('Could not read package.json from tarball')
}
return manifestJson
}
}

module.exports = StageDownload
Loading
Loading