diff --git a/.bumpy/npm-staged-publishing.md b/.bumpy/npm-staged-publishing.md new file mode 100644 index 0000000..10c2e5e --- /dev/null +++ b/.bumpy/npm-staged-publishing.md @@ -0,0 +1,5 @@ +--- +'@varlock/bumpy': minor +--- + +Add `npmStaged` publish config option for npm staged publishing (`npm stage publish`), which stages packages on npmjs.com requiring manual 2FA approval before going live. diff --git a/README.md b/README.md index fff2bee..d0146f7 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Fixed locale fallback logic in utils. - **All package managers** - npm, pnpm, yarn, and bun workspaces - **Smart dependency propagation** - configurable rules for how version bumps cascade through your dependency graph (see [version propagation docs](https://github.com/dmno-dev/bumpy/blob/main/docs/version-propagation.md)) -- **Pack-then-publish** - by default, publishes to npm (resolving `workspace:` and `catalog:` protocols, with OIDC/provenance support). Per-package custom publish commands let you target anything - VSCode extensions, Docker images, JSR, private registries, etc. +- **Pack-then-publish** - by default, publishes to npm (resolving `workspace:` and `catalog:` protocols, with OIDC/provenance support). Supports [npm staged publishing](https://docs.npmjs.com/about-staged-publishes) for 2FA-gated releases. Per-package custom publish commands let you target anything - VSCode extensions, Docker images, JSR, private registries, etc. - **Flexible package management** - include/exclude any package individually via per-package config, glob patterns, or `privatePackages` setting - **Non-interactive CLI** - `bumpy add` works fully non-interactively for CI/CD and AI-assisted development - **Aggregated GitHub releases** - optionally create a single consolidated release instead of one per package diff --git a/docs/configuration.md b/docs/configuration.md index 99976f9..f5b49b8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -68,6 +68,25 @@ The `publish` object controls how packages are packed and published: | `publishManager` | `string` | `"npm"` | Which tool runs `publish` (npm supports OIDC/provenance) | | `publishArgs` | `string[]` | `[]` | Extra args passed to publish (e.g., `["--provenance"]`) | | `protocolResolution` | `"pack" \| "in-place"` | `"pack"` | How `workspace:` and `catalog:` protocols are resolved | +| `npmStaged` | `boolean` | `false` | Use `npm stage publish` — requires 2FA approval on npmjs.com | + +#### Staged publishing + +When `npmStaged` is enabled, bumpy uses `npm stage publish` instead of `npm publish`. This stages packages on npmjs.com, where they must be manually approved with 2FA before going live. This adds an extra security gate to your release process — even if CI credentials are compromised, packages can't be published without maintainer approval. + +Requirements: + +- `publishManager` must be `"npm"` (the default) +- npm >= 11.5.1 +- [npm trusted publishing (OIDC)](https://docs.npmjs.com/trusted-publishers/) configured for your repo + +```json +{ + "publish": { + "npmStaged": true + } +} +``` ### Version PR config @@ -210,6 +229,9 @@ See the [Changelog Formatters](./changelog-formatters.md) docs for full details "dependencyBumpRules": { "peerDependencies": { "trigger": "minor", "bumpAs": "match" } }, + "publish": { + "npmStaged": true + }, "aggregateRelease": true, "packages": { "@myorg/vscode-extension": { diff --git a/docs/github-actions.md b/docs/github-actions.md index cd23231..b1543dd 100644 --- a/docs/github-actions.md +++ b/docs/github-actions.md @@ -72,6 +72,8 @@ jobs: **Trusted publishing setup:** Configure each package on [npmjs.com](https://docs.npmjs.com/trusted-publishers/) → Package Settings → Trusted Publishers → GitHub Actions. Specify your org/user, repo, and the workflow filename (`bumpy-release.yml`). +> **Staged publishing:** For an extra layer of security, enable `npmStaged` in your [publish config](./configuration.md#staged-publishing). This uses `npm stage publish` to stage packages on npmjs.com, requiring manual 2FA approval before they go live — even if your CI credentials are compromised, nothing gets published without maintainer approval. + ### Token-based auth (NPM_TOKEN) If you can't use trusted publishing, use an npm access token instead: diff --git a/images/frog-clipboard.png b/images/frog-clipboard.png index b77834d..df350e0 100644 Binary files a/images/frog-clipboard.png and b/images/frog-clipboard.png differ diff --git a/packages/bumpy/src/core/publish-pipeline.ts b/packages/bumpy/src/core/publish-pipeline.ts index 7281059..116279b 100644 --- a/packages/bumpy/src/core/publish-pipeline.ts +++ b/packages/bumpy/src/core/publish-pipeline.ts @@ -132,6 +132,24 @@ export async function publishPackages( // Set up npm authentication before publishing setupNpmAuth(rootDir, publishConfig.publishManager); + // Validate staged publishing config + if (publishConfig.npmStaged) { + if (publishConfig.publishManager !== 'npm') { + log.warn('Staged publishing is only supported with publishManager "npm" — ignoring staged option'); + } else { + const npmVersion = tryRunArgs(['npm', '--version']); + if (npmVersion) { + const [major, minor, patch] = npmVersion.split('.').map(Number); + const meetsMinVersion = major! > 11 || (major === 11 && (minor! > 5 || (minor === 5 && patch! >= 1))); + if (!meetsMinVersion) { + log.warn(`Staged publishing requires npm >= 11.5.1 (found ${npmVersion})`); + } else { + log.dim(`Staged publishing enabled — packages will require 2FA approval on npmjs.com`); + } + } + } + } + // Resolve "auto" pack manager to detected PM const packManager = publishConfig.packManager === 'auto' ? detectedPm : publishConfig.packManager; @@ -302,7 +320,9 @@ function buildPublishArgs( const args: string[] = []; // Base command - if (publishManager === 'yarn') { + if (config.publish.npmStaged && publishManager === 'npm') { + args.push('npm', 'stage', 'publish'); + } else if (publishManager === 'yarn') { args.push('yarn', 'npm', 'publish'); } else { args.push(publishManager, 'publish'); diff --git a/packages/bumpy/src/types.ts b/packages/bumpy/src/types.ts index 5181ab6..3a82b99 100644 --- a/packages/bumpy/src/types.ts +++ b/packages/bumpy/src/types.ts @@ -79,6 +79,13 @@ export interface PublishConfig { * Default: "pack" */ protocolResolution: 'pack' | 'in-place' | 'none'; + /** + * Use npm staged publishing (`npm stage publish`). + * Stages the publish on npmjs.com, requiring manual 2FA approval before going live. + * Only works with publishManager "npm" and requires npm >= 11.5.1. + * Default: false + */ + npmStaged: boolean; } export interface BumpyConfig { @@ -157,6 +164,7 @@ export const DEFAULT_PUBLISH_CONFIG: PublishConfig = { packManager: 'auto', publishManager: 'npm', publishArgs: [], + npmStaged: false, protocolResolution: 'pack', }; diff --git a/packages/bumpy/test/core/publish-pipeline.test.ts b/packages/bumpy/test/core/publish-pipeline.test.ts index bae70ba..32ba4b4 100644 --- a/packages/bumpy/test/core/publish-pipeline.test.ts +++ b/packages/bumpy/test/core/publish-pipeline.test.ts @@ -4,7 +4,7 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { writeJson, readJson, ensureDir, writeText } from '../../src/utils/fs.ts'; import { makePkg, gitInDir } from '../helpers.ts'; -import { installShellMock, uninstallShellMock } from '../helpers-shell-mock.ts'; +import { installShellMock, uninstallShellMock, addMockRule, getCallsMatching } from '../helpers-shell-mock.ts'; import { DependencyGraph } from '../../src/core/dep-graph.ts'; import { publishPackages } from '../../src/core/publish-pipeline.ts'; import type { WorkspacePackage, ReleasePlan, BumpyConfig } from '../../src/types.ts'; @@ -15,6 +15,11 @@ const IN_PLACE_CONFIG: BumpyConfig = { publish: { ...DEFAULT_PUBLISH_CONFIG, protocolResolution: 'in-place' }, }; +const STAGED_CONFIG: BumpyConfig = { + ...DEFAULT_CONFIG, + publish: { ...DEFAULT_PUBLISH_CONFIG, npmStaged: true, protocolResolution: 'in-place' }, +}; + describe('publishPackages', () => { let tmpDir: string; @@ -266,4 +271,43 @@ describe('publishPackages', () => { expect(deps.react).toBe('^19.0.0'); expect(deps.jest).toBe('^30.0.0'); }); + + test('staged publishing uses npm stage publish', async () => { + const pkgDir = resolve(tmpDir, 'packages/staged-pkg'); + await ensureDir(pkgDir); + await writeJson(resolve(pkgDir, 'package.json'), { name: 'staged-pkg', version: '1.0.0' }); + await setupGitRepo(); + + // Mock npm --version (for staged validation) and the publish command + addMockRule({ match: 'npm --version', response: '11.5.1' }); + addMockRule({ match: 'npm stage publish', response: '' }); + + const packages = new Map(); + packages.set('staged-pkg', makePkg('staged-pkg', '1.0.0', { dir: pkgDir })); + + const depGraph = new DependencyGraph(packages); + const plan: ReleasePlan = { + bumpFiles: [], + warnings: [], + releases: [ + { + name: 'staged-pkg', + type: 'patch', + oldVersion: '1.0.0', + newVersion: '1.0.1', + bumpFiles: [], + isDependencyBump: false, + isCascadeBump: false, + isGroupBump: false, + bumpSources: [], + }, + ], + }; + + const result = await publishPackages(plan, packages, depGraph, STAGED_CONFIG, tmpDir, {}); + + expect(result.published).toHaveLength(1); + const publishCalls = getCallsMatching('npm stage publish'); + expect(publishCalls.length).toBeGreaterThanOrEqual(1); + }); });