Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .bumpy/npm-staged-publishing.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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": {
Expand Down
2 changes: 2 additions & 0 deletions docs/github-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Binary file modified images/frog-clipboard.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 21 additions & 1 deletion packages/bumpy/src/core/publish-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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');
Expand Down
8 changes: 8 additions & 0 deletions packages/bumpy/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -157,6 +164,7 @@ export const DEFAULT_PUBLISH_CONFIG: PublishConfig = {
packManager: 'auto',
publishManager: 'npm',
publishArgs: [],
npmStaged: false,
protocolResolution: 'pack',
};

Expand Down
46 changes: 45 additions & 1 deletion packages/bumpy/test/core/publish-pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;

Expand Down Expand Up @@ -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<string, WorkspacePackage>();
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);
});
});