Skip to content

Commit ecc625a

Browse files
authored
feat: add npmStaged publish config for npm staged publishing (#76)
## Summary - Adds `npmStaged` boolean to `PublishConfig` (default: `false`) to opt into npm's staged publishing feature (`npm stage publish`) - When enabled, packages are staged on npmjs.com and require manual 2FA approval before going live - Validates that `publishManager` is `"npm"` and npm version is >= 11.5.1, with helpful warnings otherwise - Updated docs and added test coverage - Updated frog clipboard image ## Test plan - [x] Existing publish-pipeline tests pass (6/6) - [x] New test verifies `npm stage publish` command is used when `npmStaged: true` - [x] Typecheck passes
1 parent c4f4e52 commit ecc625a

8 files changed

Lines changed: 104 additions & 3 deletions

File tree

.bumpy/npm-staged-publishing.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@varlock/bumpy': minor
3+
---
4+
5+
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.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ Fixed locale fallback logic in utils.
5050

5151
- **All package managers** - npm, pnpm, yarn, and bun workspaces
5252
- **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))
53-
- **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.
53+
- **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.
5454
- **Flexible package management** - include/exclude any package individually via per-package config, glob patterns, or `privatePackages` setting
5555
- **Non-interactive CLI** - `bumpy add` works fully non-interactively for CI/CD and AI-assisted development
5656
- **Aggregated GitHub releases** - optionally create a single consolidated release instead of one per package

docs/configuration.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,25 @@ The `publish` object controls how packages are packed and published:
6868
| `publishManager` | `string` | `"npm"` | Which tool runs `publish` (npm supports OIDC/provenance) |
6969
| `publishArgs` | `string[]` | `[]` | Extra args passed to publish (e.g., `["--provenance"]`) |
7070
| `protocolResolution` | `"pack" \| "in-place"` | `"pack"` | How `workspace:` and `catalog:` protocols are resolved |
71+
| `npmStaged` | `boolean` | `false` | Use `npm stage publish` — requires 2FA approval on npmjs.com |
72+
73+
#### Staged publishing
74+
75+
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.
76+
77+
Requirements:
78+
79+
- `publishManager` must be `"npm"` (the default)
80+
- npm >= 11.5.1
81+
- [npm trusted publishing (OIDC)](https://docs.npmjs.com/trusted-publishers/) configured for your repo
82+
83+
```json
84+
{
85+
"publish": {
86+
"npmStaged": true
87+
}
88+
}
89+
```
7190

7291
### Version PR config
7392

@@ -210,6 +229,9 @@ See the [Changelog Formatters](./changelog-formatters.md) docs for full details
210229
"dependencyBumpRules": {
211230
"peerDependencies": { "trigger": "minor", "bumpAs": "match" }
212231
},
232+
"publish": {
233+
"npmStaged": true
234+
},
213235
"aggregateRelease": true,
214236
"packages": {
215237
"@myorg/vscode-extension": {

docs/github-actions.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ jobs:
7272

7373
**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`).
7474

75+
> **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.
76+
7577
### Token-based auth (NPM_TOKEN)
7678

7779
If you can't use trusted publishing, use an npm access token instead:

images/frog-clipboard.png

5 Bytes
Loading

packages/bumpy/src/core/publish-pipeline.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,24 @@ export async function publishPackages(
132132
// Set up npm authentication before publishing
133133
setupNpmAuth(rootDir, publishConfig.publishManager);
134134

135+
// Validate staged publishing config
136+
if (publishConfig.npmStaged) {
137+
if (publishConfig.publishManager !== 'npm') {
138+
log.warn('Staged publishing is only supported with publishManager "npm" — ignoring staged option');
139+
} else {
140+
const npmVersion = tryRunArgs(['npm', '--version']);
141+
if (npmVersion) {
142+
const [major, minor, patch] = npmVersion.split('.').map(Number);
143+
const meetsMinVersion = major! > 11 || (major === 11 && (minor! > 5 || (minor === 5 && patch! >= 1)));
144+
if (!meetsMinVersion) {
145+
log.warn(`Staged publishing requires npm >= 11.5.1 (found ${npmVersion})`);
146+
} else {
147+
log.dim(`Staged publishing enabled — packages will require 2FA approval on npmjs.com`);
148+
}
149+
}
150+
}
151+
}
152+
135153
// Resolve "auto" pack manager to detected PM
136154
const packManager = publishConfig.packManager === 'auto' ? detectedPm : publishConfig.packManager;
137155

@@ -302,7 +320,9 @@ function buildPublishArgs(
302320
const args: string[] = [];
303321

304322
// Base command
305-
if (publishManager === 'yarn') {
323+
if (config.publish.npmStaged && publishManager === 'npm') {
324+
args.push('npm', 'stage', 'publish');
325+
} else if (publishManager === 'yarn') {
306326
args.push('yarn', 'npm', 'publish');
307327
} else {
308328
args.push(publishManager, 'publish');

packages/bumpy/src/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,13 @@ export interface PublishConfig {
7979
* Default: "pack"
8080
*/
8181
protocolResolution: 'pack' | 'in-place' | 'none';
82+
/**
83+
* Use npm staged publishing (`npm stage publish`).
84+
* Stages the publish on npmjs.com, requiring manual 2FA approval before going live.
85+
* Only works with publishManager "npm" and requires npm >= 11.5.1.
86+
* Default: false
87+
*/
88+
npmStaged: boolean;
8289
}
8390

8491
export interface BumpyConfig {
@@ -157,6 +164,7 @@ export const DEFAULT_PUBLISH_CONFIG: PublishConfig = {
157164
packManager: 'auto',
158165
publishManager: 'npm',
159166
publishArgs: [],
167+
npmStaged: false,
160168
protocolResolution: 'pack',
161169
};
162170

packages/bumpy/test/core/publish-pipeline.test.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { mkdtemp, rm } from 'node:fs/promises';
44
import { tmpdir } from 'node:os';
55
import { writeJson, readJson, ensureDir, writeText } from '../../src/utils/fs.ts';
66
import { makePkg, gitInDir } from '../helpers.ts';
7-
import { installShellMock, uninstallShellMock } from '../helpers-shell-mock.ts';
7+
import { installShellMock, uninstallShellMock, addMockRule, getCallsMatching } from '../helpers-shell-mock.ts';
88
import { DependencyGraph } from '../../src/core/dep-graph.ts';
99
import { publishPackages } from '../../src/core/publish-pipeline.ts';
1010
import type { WorkspacePackage, ReleasePlan, BumpyConfig } from '../../src/types.ts';
@@ -15,6 +15,11 @@ const IN_PLACE_CONFIG: BumpyConfig = {
1515
publish: { ...DEFAULT_PUBLISH_CONFIG, protocolResolution: 'in-place' },
1616
};
1717

18+
const STAGED_CONFIG: BumpyConfig = {
19+
...DEFAULT_CONFIG,
20+
publish: { ...DEFAULT_PUBLISH_CONFIG, npmStaged: true, protocolResolution: 'in-place' },
21+
};
22+
1823
describe('publishPackages', () => {
1924
let tmpDir: string;
2025

@@ -266,4 +271,43 @@ describe('publishPackages', () => {
266271
expect(deps.react).toBe('^19.0.0');
267272
expect(deps.jest).toBe('^30.0.0');
268273
});
274+
275+
test('staged publishing uses npm stage publish', async () => {
276+
const pkgDir = resolve(tmpDir, 'packages/staged-pkg');
277+
await ensureDir(pkgDir);
278+
await writeJson(resolve(pkgDir, 'package.json'), { name: 'staged-pkg', version: '1.0.0' });
279+
await setupGitRepo();
280+
281+
// Mock npm --version (for staged validation) and the publish command
282+
addMockRule({ match: 'npm --version', response: '11.5.1' });
283+
addMockRule({ match: 'npm stage publish', response: '' });
284+
285+
const packages = new Map<string, WorkspacePackage>();
286+
packages.set('staged-pkg', makePkg('staged-pkg', '1.0.0', { dir: pkgDir }));
287+
288+
const depGraph = new DependencyGraph(packages);
289+
const plan: ReleasePlan = {
290+
bumpFiles: [],
291+
warnings: [],
292+
releases: [
293+
{
294+
name: 'staged-pkg',
295+
type: 'patch',
296+
oldVersion: '1.0.0',
297+
newVersion: '1.0.1',
298+
bumpFiles: [],
299+
isDependencyBump: false,
300+
isCascadeBump: false,
301+
isGroupBump: false,
302+
bumpSources: [],
303+
},
304+
],
305+
};
306+
307+
const result = await publishPackages(plan, packages, depGraph, STAGED_CONFIG, tmpDir, {});
308+
309+
expect(result.published).toHaveLength(1);
310+
const publishCalls = getCallsMatching('npm stage publish');
311+
expect(publishCalls.length).toBeGreaterThanOrEqual(1);
312+
});
269313
});

0 commit comments

Comments
 (0)