Skip to content

Commit 7f7b235

Browse files
committed
feat: add bumpy publish reopen for rejected staged publishes
npm exposes no public 'rejected' signal (a rejected stage is indistinguishable from a pending one via npm info), and the finalize job is credential-free, so bumpy can't auto-detect a rejection. After `npm stage reject`, run `bumpy publish reopen <name@version>`: it flips the staged target back to failed, which rejoins the fix-forward path — the tag un-freezes and the next publish re-stages. Abandon instead by not reopening (the next version supersedes the draft). Adds the command + CLI wiring, tests, and docs (cli.md, configuration.md, github-actions.md rejection subsection).
1 parent 48e5aa8 commit 7f7b235

6 files changed

Lines changed: 175 additions & 1 deletion

File tree

docs/cli.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,23 @@ bumpy publish finalize --dry-run # show what would be finalized
128128
| `[name@version]` | Finalize only this release; omit to reconcile all staged releases |
129129
| `--dry-run` | Report what would be finalized without editing any releases |
130130

131-
Idempotent — a version that's still staged is left untouched — so it's safe to run on a schedule, manually, or from an approval webhook. See [the finalize workflow](github-actions.md#staged-publishing-finalize-workflow) for wiring it into CI.
131+
Idempotent — a version that's still staged is left untouched — so it's safe to run on a schedule, manually, or from an approval webhook. See [Staged publishing (finalizing a release)](github-actions.md#staged-publishing-finalizing-a-release) for wiring it into CI.
132+
133+
## `bumpy publish reopen`
134+
135+
Reopen a [staged](configuration.md#staged-publishing) release whose staged publish was **rejected** on npm. Rejection isn't publicly observable (a rejected stage looks the same as a pending one to the registry), so bumpy can't detect it — run this after `npm stage reject <stage-id>` to tell it.
136+
137+
```bash
138+
npm stage reject <stage-id> # reject on npm
139+
bumpy publish reopen @myorg/pkg@1.2.3 # then reopen the release
140+
```
141+
142+
| Argument / Flag | Description |
143+
| --------------- | ---------------------------------------------------- |
144+
| `name@version` | The rejected release to reopen (required) |
145+
| `--dry-run` | Report what would change without editing the release |
146+
147+
It flips the staged target back to **failed**, which rejoins the fix-forward path: the 🟡 marker clears, the version tag un-freezes, and the next `bumpy publish` re-stages the same version. To _abandon_ the version instead, don't reopen — ship a different version and the draft is superseded.
132148

133149
## `bumpy check`
134150

docs/configuration.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ Staging is an **npm-registry feature** — it only applies to packages published
121121

122122
Because a staged package isn't live yet, bumpy does **not** mark the release as published: the publish target shows as **🟡 staged, awaiting approval** and the GitHub release stays a **draft** (so the `release: published` event doesn't fire prematurely). Going live is a two-step handoff: you **approve on npm** (`npm stage approve <stage-id>` — the 2FA gate), then run **`bumpy publish finalize`** to update the GitHub release (flip it to ✅ published, link the live package). You can run finalize by hand or on a schedule — see [Staged publishing (finalizing a release)](github-actions.md#staged-publishing-finalizing-a-release) for the full lifecycle and both setups.
123123

124+
If you instead **reject** a stage on npm (`npm stage reject <stage-id>`), run **`bumpy publish reopen <name@version>`** — bumpy can't detect a rejection on its own, and this reopens the release so the next publish re-stages the fixed build. To abandon the version entirely, don't reopen; the next version bump supersedes the draft.
125+
124126
### Version PR config
125127

126128
The `versionPr` object customizes the PR that `bumpy ci release` creates:

docs/github-actions.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,19 @@ Because finalize decides what to publish by probing the registry (not from the p
411411
>
412412
> For most repos you won't need this — the no-payload nudge above is the norm.
413413

414+
### If a staged publish is rejected
415+
416+
Approval is publicly observable (the package goes live, and finalize notices), but **rejection is not** — a rejected stage looks identical to a still-pending one to `npm info` (both are simply "not live"). So bumpy can't auto-detect a rejection, and the release would otherwise sit at 🟡 forever. When you reject a stage, tell bumpy:
417+
418+
```bash
419+
npm stage reject <stage-id> # reject on npm
420+
bumpy publish reopen my-pkg@1.2.3 # tell bumpy — reopens the release for re-publish
421+
```
422+
423+
`publish reopen` flips the staged target back to **failed**, which rejoins the normal fix-forward path: the 🟡 marker clears, the version tag un-freezes, and the **next `bumpy publish` re-stages the same version** — so push your fix and let CI re-stage it. (If you're rejecting to _abandon_ the version rather than redo it, don't reopen — just ship a different version and the draft gets superseded automatically.)
424+
425+
If you approve/reject through tooling, have it run `bumpy publish reopen <tag>` (e.g. via a `repository_dispatch`) at rejection time, the mirror of the finalize nudge.
426+
414427
## Advanced: per-package conditional builds
415428

416429
If you have one expensive package whose build you only want to run when that package itself is being released, use `ci plan`'s `packages` output to gate per-package steps:

packages/bumpy/src/cli.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,21 @@ async function main() {
222222
break;
223223
}
224224

225+
// `bumpy publish reopen <name@version>` — after `npm stage reject`, flip a rejected
226+
// staged release back to failed so the next publish re-stages it.
227+
if (args[1] === 'reopen') {
228+
const reopenFlags = parseFlags(args.slice(2));
229+
const tagArg = args[2] && !args[2].startsWith('--') ? args[2] : undefined;
230+
const tag = (reopenFlags.tag as string | undefined) ?? tagArg;
231+
if (!tag) {
232+
log.error('`bumpy publish reopen` requires a release: `bumpy publish reopen <name@version>`');
233+
process.exit(1);
234+
}
235+
const { reopenCommand } = await import('./commands/reopen.ts');
236+
await reopenCommand(rootDir, { tag, dryRun: reopenFlags['dry-run'] === true });
237+
break;
238+
}
239+
225240
const { publishCommand } = await import('./commands/publish.ts');
226241
if (flags.snapshot === true) {
227242
log.error('--snapshot requires a name, e.g. `bumpy publish --snapshot pr-123`.');
@@ -292,6 +307,8 @@ function printHelp() {
292307
(--snapshot <name>: transient preview publish to a throwaway dist-tag)
293308
publish finalize Finalize staged releases that have been approved and gone live
294309
([name@version]: finalize one release; otherwise reconcile all staged)
310+
publish reopen Reopen a staged release rejected on npm so it re-stages on next publish
311+
(name@version required; run after "npm stage reject")
295312
ci check PR check — report pending releases, comment on PR
296313
ci comment Post a pre-rendered comment (workflow_run half of the fork-comment split)
297314
ci plan Report what ci release would do (JSON + GitHub Actions outputs)
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { log, colorize } from '../utils/logger.ts';
2+
import { isGhAvailable, findReleaseByTag, updateReleaseBody, updateReleaseBodyStatus } from '../core/github-release.ts';
3+
4+
export interface ReopenCommandOptions {
5+
/** The rejected release to reopen (`name@version`). */
6+
tag: string;
7+
dryRun?: boolean;
8+
}
9+
10+
/**
11+
* Reopen a staged release whose staged publish was rejected on npm.
12+
*
13+
* npm exposes no public "rejected" signal (a rejected stage looks identical to a still-pending
14+
* one — both are simply not live), and the finalize job is intentionally credential-free, so
15+
* bumpy can't auto-detect a rejection. Instead the maintainer (or their approval tooling) runs
16+
* this after `npm stage reject <stage-id>`.
17+
*
18+
* It flips the staged target(s) back to `failed`, which reuses bumpy's existing fix-forward
19+
* path: the stale 🟡 clears, the version tag un-freezes (a `failed` target isn't "shipped"),
20+
* and the next `bumpy publish` re-stages the same version. To abandon the version instead, just
21+
* don't reopen — the next version bump supersedes the draft.
22+
*/
23+
export async function reopenCommand(rootDir: string, opts: ReopenCommandOptions): Promise<void> {
24+
if (!isGhAvailable()) {
25+
log.error('gh CLI not found — cannot reopen a staged release.');
26+
process.exit(1);
27+
}
28+
29+
const info = await findReleaseByTag(opts.tag, rootDir);
30+
if (!info) {
31+
log.error(`No GitHub release found for ${opts.tag}.`);
32+
process.exit(1);
33+
}
34+
const meta = info.metadata;
35+
if (!meta) {
36+
log.error(`${opts.tag} has no bumpy metadata — nothing to reopen.`);
37+
process.exit(1);
38+
}
39+
40+
const stagedTargets = Object.entries(meta.targets).filter(([, s]) => s.status === 'staged');
41+
if (stagedTargets.length === 0) {
42+
log.info(`${opts.tag} has no staged targets — nothing to reopen.`);
43+
return;
44+
}
45+
46+
for (const [targetName, state] of stagedTargets) {
47+
meta.targets[targetName] = {
48+
status: 'failed',
49+
error: 'staged publish was rejected — will re-stage on next publish',
50+
lastAttempt: new Date().toISOString(),
51+
...(state.label ? { label: state.label } : {}),
52+
};
53+
}
54+
55+
if (opts.dryRun) {
56+
log.dim(` Would reopen ${opts.tag}${stagedTargets.length} staged target(s) → failed`);
57+
return;
58+
}
59+
60+
const updatedBody = updateReleaseBodyStatus(info.body, meta);
61+
await updateReleaseBody(opts.tag, updatedBody, rootDir);
62+
log.success(` Reopened ${colorize(opts.tag, 'cyan')} — will re-stage on the next publish run`);
63+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { test, expect, describe, beforeEach, afterEach } from 'bun:test';
2+
import { installShellMock, uninstallShellMock, getCallsMatching, addMockRule } from '../helpers-shell-mock.ts';
3+
import { composeReleaseBody, parseReleaseMetadata, type ReleaseMetadata } from '../../src/core/github-release.ts';
4+
import { reopenCommand } from '../../src/commands/reopen.ts';
5+
6+
/** Mock `gh release view <tag> --json ...` to return a release with the given body. */
7+
function mockReleaseView(body: string, isDraft = true) {
8+
addMockRule({
9+
match: /^gh release view/,
10+
response: JSON.stringify({ tagName: 'pkg-a@1.2.3', name: 'pkg-a v1.2.3', body, isDraft }),
11+
});
12+
}
13+
14+
describe('reopenCommand', () => {
15+
beforeEach(() => installShellMock());
16+
afterEach(() => uninstallShellMock());
17+
18+
const stagedMeta: ReleaseMetadata = {
19+
version: '1.2.3',
20+
targets: { npm: { status: 'staged', stageId: 'uuid-1', stagedAt: '2026-01-01T00:00:00Z' } },
21+
};
22+
23+
test('flips a staged target to failed and edits the release body', async () => {
24+
mockReleaseView(composeReleaseBody('- A change', stagedMeta));
25+
addMockRule({ match: /^gh release edit/, response: '' });
26+
27+
await reopenCommand('/tmp/x', { tag: 'pkg-a@1.2.3' });
28+
29+
const editCalls = getCallsMatching('gh release edit');
30+
expect(editCalls.length).toBe(1);
31+
32+
// The edited body's metadata should now show the target as failed (rejected).
33+
const notesIdx = editCalls[0]!.args.indexOf('--notes');
34+
const newBody = editCalls[0]!.args[notesIdx + 1]!;
35+
const meta = parseReleaseMetadata(newBody)!;
36+
expect(meta.targets.npm!.status).toBe('failed');
37+
expect(meta.targets.npm!.error).toContain('rejected');
38+
// No longer staged — the stale 🟡 marker is gone.
39+
expect(newBody).not.toContain('🟡');
40+
});
41+
42+
test('dry-run does not edit the release', async () => {
43+
mockReleaseView(composeReleaseBody('- A change', stagedMeta));
44+
addMockRule({ match: /^gh release edit/, response: '' });
45+
46+
await reopenCommand('/tmp/x', { tag: 'pkg-a@1.2.3', dryRun: true });
47+
48+
expect(getCallsMatching('gh release edit')).toHaveLength(0);
49+
});
50+
51+
test('no-ops when the release has no staged targets', async () => {
52+
const liveMeta: ReleaseMetadata = {
53+
version: '1.2.3',
54+
targets: { npm: { status: 'success', url: 'https://npmjs.com/package/pkg-a/v/1.2.3' } },
55+
};
56+
mockReleaseView(composeReleaseBody('- A change', liveMeta));
57+
addMockRule({ match: /^gh release edit/, response: '' });
58+
59+
await reopenCommand('/tmp/x', { tag: 'pkg-a@1.2.3' });
60+
61+
expect(getCallsMatching('gh release edit')).toHaveLength(0);
62+
});
63+
});

0 commit comments

Comments
 (0)