From 20dfbd4876ffa0778f7eef8778f1a57ea3f9dccc Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Thu, 2 Jul 2026 15:12:00 -0700 Subject: [PATCH 1/9] feat: mark staged publishes as staged in GH release + add `publish finalize` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With npmStaged, `npm stage publish` leaves the package staged (not live), but bumpy treated it as published — marking the release target success with a dead npmjs.com link and finalizing the draft (firing release: published) before any approval. Now a staged publish is routed to PublishResult.staged (with the stage id parsed from `npm stage publish --json`), the release target is marked 🟡 staged, and the release stays a draft. A CI re-run treats staged as handled so it never re-stages. Adds `bumpy publish finalize [name@version]`: an idempotent reconcile that probes the registry and, once a staged version is live, flips it to success + real URL and publishes the release (firing release: published). Wired up via finalize.yaml with three triggers (repository_dispatch, schedule, workflow_dispatch). --- .bumpy/staged-finalize.md | 5 + .github/workflows/finalize.yaml | 60 ++++++++ docs/cli.md | 17 ++ docs/configuration.md | 2 + docs/github-actions.md | 66 ++++++++ packages/bumpy/src/cli.ts | 17 ++ packages/bumpy/src/commands/finalize.ts | 145 ++++++++++++++++++ packages/bumpy/src/commands/publish.ts | 35 ++++- packages/bumpy/src/core/github-release.ts | 45 +++++- packages/bumpy/src/core/publish-pipeline.ts | 75 +++++++-- .../bumpy/test/core/publish-pipeline.test.ts | 86 ++++++++++- .../bumpy/test/core/publish-recovery.test.ts | 6 + 12 files changed, 532 insertions(+), 27 deletions(-) create mode 100644 .bumpy/staged-finalize.md create mode 100644 .github/workflows/finalize.yaml create mode 100644 packages/bumpy/src/commands/finalize.ts diff --git a/.bumpy/staged-finalize.md b/.bumpy/staged-finalize.md new file mode 100644 index 0000000..08f9166 --- /dev/null +++ b/.bumpy/staged-finalize.md @@ -0,0 +1,5 @@ +--- +'@varlock/bumpy': minor +--- + +Handle staged publishing (`npmStaged`) honestly in GitHub releases. A `npm stage publish` is no longer treated as a live publish: the release target is marked **🟡 staged, awaiting approval** (with the npm stage id recorded), and the GitHub release stays a **draft** so the `release: published` event doesn't fire before the package is actually live. A new `bumpy publish finalize [name@version]` command reconciles staged releases once they're approved — it checks the registry and, if the version has gone live, flips the target to ✅ with the live URL and publishes the release. It's idempotent, so it can run on a schedule, manually, or from an approval webhook (`repository_dispatch`). See the new [finalize workflow](docs/github-actions.md#staged-publishing-finalize-workflow) docs. diff --git a/.github/workflows/finalize.yaml b/.github/workflows/finalize.yaml new file mode 100644 index 0000000..1f357e6 --- /dev/null +++ b/.github/workflows/finalize.yaml @@ -0,0 +1,60 @@ +# 🐸 Bumpy finalize staged releases +# Reconciles staged npm publishes: once a staged version is approved on npmjs.com +# and goes live, this flips its draft GitHub release to published (firing the +# `release: published` event) and links to the live package. + +# ⚠️ NOTE - DO NOT COPY THIS FILE +# instead look at the recommended workflow in the docs +# ➡️ https://bumpy.varlock.dev/blob/main/docs/github-actions.md ⬅️ + +name: Finalize + +on: + # 1. Event-driven: stageflight (or any approver) fires this after approving the batch. + # client_payload.tag pins the exact release; omit it to reconcile everything. + repository_dispatch: + types: [bumpy-finalize] + # 2. Scheduled reconcile: self-heals even without a dispatch (e.g. approval done in + # the npm UI). Hourly; tune to taste. + schedule: + - cron: '17 * * * *' + # 3. Manual: run from the Actions tab, optionally targeting a single release. + workflow_dispatch: + inputs: + tag: + description: 'Release to finalize (name@version). Leave blank to reconcile all staged.' + required: false + type: string + +concurrency: + group: bumpy-finalize + cancel-in-progress: false + +jobs: + finalize: + runs-on: ubuntu-latest + permissions: + contents: write # update + publish (finalize) the GitHub release and tags + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: oven-sh/setup-bun@v2 + # Node.js (npm) is needed to check whether staged versions have gone live. + - uses: actions/setup-node@v6 + with: + node-version: latest + - run: bun install + + # --- You wont need this part --- + # Build first since we use the local built version of bumpy instead of the published one + - run: bun run --filter @varlock/bumpy build + - run: bun install + # ------------------------------- + + # `tag` comes from the dispatch payload, the manual input, or is empty (reconcile all). + - run: bunx @varlock/bumpy publish finalize ${{ github.event.client_payload.tag || inputs.tag }} + env: + GH_TOKEN: ${{ github.token }} + # PAT/App token so finalizing the release fires downstream `release: published` workflows + BUMPY_GH_TOKEN: ${{ secrets.BUMPY_GH_TOKEN }} diff --git a/docs/cli.md b/docs/cli.md index f3cde97..39427b7 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -113,6 +113,23 @@ With `--snapshot `, publish derives a throwaway prerelease version per pen 2. Git tags (for packages with `skipNpmPublish` or custom `publishCommand`) 3. npm registry query (default) +## `bumpy publish finalize` + +Reconcile [staged](configuration.md#staged-publishing) releases that have been approved and gone live. With `npmStaged` enabled, a publish leaves the GitHub release as a draft with its target marked 🟡 staged. Once the version is approved on npmjs.com, finalize checks the registry and — if it's live — flips the target to ✅ with the live package URL and publishes the GitHub release (firing `release: published`). + +```bash +bumpy publish finalize # reconcile every staged release +bumpy publish finalize @myorg/pkg@1.2.3 # finalize just this one +bumpy publish finalize --dry-run # show what would be finalized +``` + +| Argument / Flag | Description | +| ---------------- | ----------------------------------------------------------------- | +| `[name@version]` | Finalize only this release; omit to reconcile all staged releases | +| `--dry-run` | Report what would be finalized without editing any releases | + +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. + ## `bumpy check` Verify that changed packages on the current branch have corresponding bump files. Compares your branch to the base branch, maps changed files to packages, and checks for matching bump files. diff --git a/docs/configuration.md b/docs/configuration.md index f24fba7..9f2f1d8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -117,6 +117,8 @@ Requirements: } ``` +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). Once the version is approved and goes live, run `bumpy publish finalize` to flip it to published and link to the live package. Set up a workflow to run finalize automatically — see [Staged publishing (finalize workflow)](github-actions.md#staged-publishing-finalize-workflow). + ### Version PR config The `versionPr` object customizes the PR that `bumpy ci release` creates: diff --git a/docs/github-actions.md b/docs/github-actions.md index 699a43a..be873b8 100644 --- a/docs/github-actions.md +++ b/docs/github-actions.md @@ -306,6 +306,72 @@ jobs: `bumpy ci release --auto-publish` collapses version + publish into a single run, skipping the Version Packages PR. This forfeits the preview/review gate on version bumps — every merge to main with a bump file ships immediately. It's also incompatible with the [split-job pattern](#release-workflow-recommended-split-jobs) above, since both paths run in one command. Prefer the default flow. See [the CLI reference](cli.md#bumpy-ci-release) if you still need it. +## Staged publishing (finalize workflow) + +With [`npmStaged`](configuration.md#staged-publishing) enabled, your release job runs `npm stage publish` instead of `npm publish`. The package is **staged** on npmjs.com, not live — it needs a 2FA approval before anyone can install it. bumpy reflects this honestly: + +- The publish target is marked **🟡 staged, awaiting approval** (not ✅ published). +- The GitHub release stays a **draft** — so the `release: published` event does _not_ fire yet, and downstream release automation doesn't run against a package that isn't out. +- The stage id is recorded in the release metadata for approval tooling to consume. + +Once the staged version is approved and goes live, run `bumpy publish finalize` to reconcile: it checks the registry, flips the target to ✅ with the live package URL, and publishes the GitHub release (which _then_ fires `release: published`). It's 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. + +```yaml +# .github/workflows/bumpy-finalize.yml +name: Finalize +on: + # 1. Event-driven — your approval tooling fires this after approving. + # client_payload.tag pins one release; omit it to reconcile everything. + repository_dispatch: + types: [bumpy-finalize] + # 2. Scheduled reconcile — self-heals even if approval happened in the npm UI. + schedule: + - cron: '17 * * * *' + # 3. Manual — run from the Actions tab, optionally targeting one release. + workflow_dispatch: + inputs: + tag: + description: 'Release to finalize (name@version). Blank = reconcile all staged.' + required: false + type: string + +concurrency: + group: bumpy-finalize + cancel-in-progress: false + +jobs: + finalize: + runs-on: ubuntu-latest + permissions: + contents: write # update + publish the GitHub release and tags + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: oven-sh/setup-bun@v2 + - uses: actions/setup-node@v6 # npm is needed to check whether staged versions went live + with: + node-version: latest + - run: bun install + - run: bunx @varlock/bumpy publish finalize ${{ github.event.client_payload.tag || inputs.tag }} + env: + GH_TOKEN: ${{ github.token }} + # PAT/App token so finalizing fires downstream `release: published` workflows + BUMPY_GH_TOKEN: ${{ secrets.BUMPY_GH_TOKEN }} +``` + +Note that this job needs **no publish credentials** — no `id-token`, no `NPM_TOKEN`. It only reads the registry and edits the GitHub release, so it's safe to run on the low-privilege default token (plus `BUMPY_GH_TOKEN` if you want the finalized release to trigger downstream workflows). + +The three triggers are complementary — wire up whichever fit: + +| Trigger | When it fires | Best for | +| --------------------- | ---------------------------------------------------- | ------------------------------------------------------------ | +| `repository_dispatch` | Your approval tooling sends a `bumpy-finalize` event | Instant finalize the moment a batch is approved | +| `schedule` | On the cron interval | A safety net so nothing stays staged if a dispatch is missed | +| `workflow_dispatch` | You click "Run workflow" | Approving in the npm UI, or one-off recovery | + +> **Dispatch payload:** send `{"event_type": "bumpy-finalize", "client_payload": {"tag": "my-pkg@1.2.3"}}` to the [repository dispatch API](https://docs.github.com/en/rest/repos/repos#create-a-repository-dispatch-event). Omit `tag` to reconcile every staged release in one run. + ## Advanced: per-package conditional builds 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: diff --git a/packages/bumpy/src/cli.ts b/packages/bumpy/src/cli.ts index 68b6c6c..643036d 100644 --- a/packages/bumpy/src/cli.ts +++ b/packages/bumpy/src/cli.ts @@ -207,6 +207,21 @@ async function main() { case 'publish': { const rootDir = await findRoot(); + + // `bumpy publish finalize [name@version]` — reconcile staged releases that + // have since been approved and gone live. `finalize` is a positional, so the + // top-level `flags` (parsed from args.slice(1)) don't include it. + if (args[1] === 'finalize') { + const finalizeFlags = parseFlags(args.slice(2)); + const tagArg = args[2] && !args[2].startsWith('--') ? args[2] : undefined; + const { finalizeCommand } = await import('./commands/finalize.ts'); + await finalizeCommand(rootDir, { + tag: (finalizeFlags.tag as string | undefined) ?? tagArg, + dryRun: finalizeFlags['dry-run'] === true, + }); + break; + } + const { publishCommand } = await import('./commands/publish.ts'); if (flags.snapshot === true) { log.error('--snapshot requires a name, e.g. `bumpy publish --snapshot pr-123`.'); @@ -275,6 +290,8 @@ function printHelp() { publish Publish versioned packages (on a channel branch: derives prerelease versions and publishes to the channel dist-tag) (--snapshot : transient preview publish to a throwaway dist-tag) + publish finalize Finalize staged releases that have been approved and gone live + ([name@version]: finalize one release; otherwise reconcile all staged) ci check PR check — report pending releases, comment on PR ci comment Post a pre-rendered comment (workflow_run half of the fork-comment split) ci plan Report what ci release would do (JSON + GitHub Actions outputs) diff --git a/packages/bumpy/src/commands/finalize.ts b/packages/bumpy/src/commands/finalize.ts new file mode 100644 index 0000000..84ddcfa --- /dev/null +++ b/packages/bumpy/src/commands/finalize.ts @@ -0,0 +1,145 @@ +import { log, colorize } from '../utils/logger.ts'; +import { loadConfig } from '../core/config.ts'; +import { discoverWorkspace } from '../core/workspace.ts'; +import { + isGhAvailable, + findReleaseByTag, + findStagedReleases, + updateReleaseBody, + updateReleaseBodyStatus, + finalizeRelease, + buildPublishUrl, + publishTargetLabel, + resolvePackageRegistry, + parseRepoSlug, + type DraftReleaseInfo, +} from '../core/github-release.ts'; +import { checkIfPublished } from './publish.ts'; + +export interface FinalizeCommandOptions { + /** Finalize only this release (`name@version`). Omit to reconcile every staged release. */ + tag?: string; + dryRun?: boolean; +} + +/** Split a `name@version` tag on the last `@` so scoped names (`@scope/pkg`) survive. */ +function parseTag(tag: string): { name: string; version: string } | null { + const idx = tag.lastIndexOf('@'); + if (idx <= 0) return null; + return { name: tag.slice(0, idx), version: tag.slice(idx + 1) }; +} + +/** + * Reconcile staged releases against the registry. + * + * A staged publish (`npm stage publish`) leaves the GitHub release as a draft with + * its targets marked `staged`, because the package isn't live until it's approved + * with 2FA on npmjs.com. This command checks whether each staged version has since + * gone live and, if so, flips its targets to `success` (with the real package URL) + * and finalizes the draft — which publishes the GitHub release and fires the + * `release: published` event for any downstream workflows. + * + * Idempotent: a version that's still staged is left untouched, so this is safe to + * run on a schedule, from a maintainer's machine, or from a stageflight dispatch. + */ +export async function finalizeCommand(rootDir: string, opts: FinalizeCommandOptions = {}): Promise { + if (!isGhAvailable()) { + log.error('gh CLI not found — cannot finalize staged releases.'); + process.exit(1); + } + + // Gather the releases to reconcile. + let candidates: DraftReleaseInfo[]; + if (opts.tag) { + const info = await findReleaseByTag(opts.tag, rootDir); + if (!info) { + log.error(`No GitHub release found for ${opts.tag}.`); + process.exit(1); + } + candidates = [info]; + } else { + candidates = await findStagedReleases(rootDir); + if (candidates.length === 0) { + log.info('No staged releases awaiting finalization.'); + return; + } + } + + // Workspace lookup gives us per-package registry/repo for building the live URL. + // Best-effort: a release whose package has left the workspace still finalizes + // against the default registry. + const config = await loadConfig(rootDir); + const { packages } = await discoverWorkspace(rootDir, config); + + let finalized = 0; + let stillStaged = 0; + + for (const info of candidates) { + const meta = info.metadata; + if (!meta) { + log.dim(` ${info.tag} — no bumpy metadata, skipping`); + continue; + } + + const stagedTargets = Object.entries(meta.targets).filter(([, s]) => s.status === 'staged'); + if (stagedTargets.length === 0) { + log.dim(` ${info.tag} — nothing staged, skipping`); + continue; + } + + const parsed = parseTag(info.tag); + if (!parsed) { + log.warn(` ${info.tag} — could not parse name@version, skipping`); + continue; + } + const { name, version } = parsed; + + const pkg = packages.get(name); + const pkgConfig = pkg?.bumpy; + const registry = resolvePackageRegistry(pkg, pkgConfig); + const repoSlug = parseRepoSlug(pkg?.packageJson?.repository) ?? process.env.GITHUB_REPOSITORY; + + // Is the staged version live on the registry now? + const live = await checkIfPublished(name, version, pkgConfig); + if (!live) { + log.dim(` ${colorize(info.tag, 'cyan')} — still staged, not yet live on the registry`); + stillStaged++; + continue; + } + + // Flip every staged target to success. + for (const [targetName] of stagedTargets) { + const label = publishTargetLabel(targetName, registry); + meta.targets[targetName] = { + status: 'success', + publishedAt: new Date().toISOString(), + url: buildPublishUrl(name, version, targetName, { registry, repoSlug }), + ...(label !== targetName ? { label } : {}), + }; + } + + if (opts.dryRun) { + log.dim(` Would finalize ${info.tag} — now live`); + finalized++; + continue; + } + + try { + const updatedBody = updateReleaseBodyStatus(info.body, meta); + await updateReleaseBody(info.tag, updatedBody, rootDir); + + // Publish the GitHub release only once every target is live (fires release: published). + const allSucceeded = Object.values(meta.targets).every((t) => t.status === 'success'); + if (allSucceeded && info.isDraft) { + await finalizeRelease(info.tag, rootDir); + } + log.success(` Finalized ${colorize(info.tag, 'cyan')} — now live`); + finalized++; + } catch (err) { + log.warn(` Failed to finalize ${info.tag}: ${err instanceof Error ? err.message : err}`); + } + } + + if (finalized > 0) log.success(`🐸 Finalized ${finalized} release(s)`); + if (stillStaged > 0) log.info(`${stillStaged} release(s) still awaiting approval`); +} diff --git a/packages/bumpy/src/commands/publish.ts b/packages/bumpy/src/commands/publish.ts index da573c4..566306c 100644 --- a/packages/bumpy/src/commands/publish.ts +++ b/packages/bumpy/src/commands/publish.ts @@ -348,7 +348,7 @@ async function publishSnapshot( depGraph, config, rootDir, - { dryRun: opts.dryRun, tag: snapshot.tag, noTag: true }, + { dryRun: opts.dryRun, tag: snapshot.tag, noTag: true, noStage: true }, catalogs, detectedPm, ); @@ -532,20 +532,26 @@ async function runPublishFlow( } } - // Filter out packages where all targets already succeeded (from previous runs) + // Filter out packages where every target is already resolved from a previous run — + // either published (success) or staged and awaiting approval. Staged targets are + // deliberately treated as done here so a CI re-run never re-stages the same version; + // the staged → live transition is reconciled by `bumpy publish finalize`. const alreadyPublished: string[] = []; for (const release of toPublish) { const info = releaseMetadataByPkg.get(release.name); if (!info) continue; const targets = publishTargetsByPkg.get(release.name) || []; - const allDone = targets.every((t) => info.metadata.targets[t]?.status === 'success'); + const allDone = targets.every((t) => { + const status = info.metadata.targets[t]?.status; + return status === 'success' || status === 'staged'; + }); if (allDone) { alreadyPublished.push(release.name); } } if (alreadyPublished.length > 0) { for (const name of alreadyPublished) { - log.dim(` Skipping ${name} — all targets already published (per draft release metadata)`); + log.dim(` Skipping ${name} — all targets already published or staged (per draft release metadata)`); } toPublish = toPublish.filter((r) => !alreadyPublished.includes(r.name)); releasePlan.releases = toPublish; @@ -574,6 +580,11 @@ async function runPublishFlow( if (result.published.length > 0) { log.success(`🐸 Published ${result.published.length} package(s)`); } + if (result.staged.length > 0) { + log.success( + `🐸 Staged ${result.staged.length} package(s) — awaiting approval, then run \`bumpy publish finalize\``, + ); + } if (result.skipped.length > 0) { log.dim(`Skipped ${result.skipped.length}: ${result.skipped.map((s) => s.name).join(', ')}`); } @@ -586,6 +597,7 @@ async function runPublishFlow( const targets = publishTargetsByPkg.get(release.name) || []; const published = result.published.find((p) => p.name === release.name); + const staged = result.staged.find((s) => s.name === release.name); const failed = result.failed.find((f) => f.name === release.name); const { registry, repoSlug } = registryByPkg.get(release.name) || {}; @@ -603,6 +615,17 @@ async function runPublishFlow( ...(label !== targetName ? { label } : {}), }; changed = true; + } else if (staged) { + // Staged on the registry, not yet live — record the stage id and leave the + // release as a draft. `bumpy publish finalize` flips this to success once approved. + const label = publishTargetLabel(targetName, registry); + info.metadata.targets[targetName] = { + status: 'staged', + stagedAt: new Date().toISOString(), + ...(staged.stageId ? { stageId: staged.stageId } : {}), + ...(label !== targetName ? { label } : {}), + }; + changed = true; } else if (failed) { const label = publishTargetLabel(targetName, registry); info.metadata.targets[targetName] = { @@ -655,7 +678,7 @@ async function runPublishFlow( // failed and HEAD has since moved, the remote tag is at a stale SHA and a // plain `git push --tags` would reject. Force is safe here because the local // tag was just created at the SHA we successfully published from. - if (!opts.dryRun && !opts.noPush && result.published.length > 0) { + if (!opts.dryRun && !opts.noPush && (result.published.length > 0 || result.staged.length > 0)) { const failed = new Set(result.failed.map((f) => f.name)); const pushed: string[] = []; log.step('Pushing tags...'); @@ -795,7 +818,7 @@ export async function findUnpublishedPackages( return unpublished; } -async function checkIfPublished(name: string, version: string, pkgConfig?: PackageConfig): Promise { +export async function checkIfPublished(name: string, version: string, pkgConfig?: PackageConfig): Promise { const { runAsync, runArgsAsync, tryRunArgs } = await import('../utils/shell.ts'); // 1. Custom check command (user-defined, runs in shell by design) diff --git a/packages/bumpy/src/core/github-release.ts b/packages/bumpy/src/core/github-release.ts index 2181762..f45036d 100644 --- a/packages/bumpy/src/core/github-release.ts +++ b/packages/bumpy/src/core/github-release.ts @@ -138,7 +138,7 @@ export function isGhAvailable(): boolean { const METADATA_START = ''; -export type PublishTargetStatus = 'pending' | 'success' | 'failed' | 'skipped'; +export type PublishTargetStatus = 'pending' | 'success' | 'failed' | 'skipped' | 'staged'; export interface PublishTargetState { status: PublishTargetStatus; @@ -150,6 +150,15 @@ export interface PublishTargetState { url?: string; /** Human-readable label, e.g. "GitHub Packages" for npm targets on a GHP registry. Falls back to the target key. */ label?: string; + /** + * npm staged-publish identifier (a UUID from `npm stage publish --json`). + * Present while `status: 'staged'` — the publish is staged on the registry and + * awaiting 2FA approval before it goes live. Consumed by `bumpy publish finalize` + * (and integrations like stageflight) to reconcile the release once approved. + */ + stageId?: string; + /** ISO timestamp of when the publish was staged (awaiting approval). */ + stagedAt?: string; } export interface ReleaseMetadata { @@ -206,6 +215,13 @@ export function formatPublishedToSection(targets: Record await withReleaseToken(() => runArgsAsync(['gh', 'release', 'delete', tag, '--yes'], { cwd: rootDir })); } +/** + * Find releases (draft or published) that still have at least one target in the + * `staged` state — i.e. a staged npm publish awaiting approval that hasn't been + * finalized yet. Used by `bumpy publish finalize` to discover work. + */ +export async function findStagedReleases(rootDir: string, limit = 50): Promise { + if (!isGhAvailable()) return []; + + try { + const json = await runArgsAsync(['gh', 'release', 'list', '--json', 'tagName,isDraft', '--limit', String(limit)], { + cwd: rootDir, + }); + const releases: Array<{ tagName: string; isDraft: boolean }> = JSON.parse(json); + + const staged: DraftReleaseInfo[] = []; + for (const r of releases) { + const info = await findReleaseByTag(r.tagName, rootDir); + if (info?.metadata && Object.values(info.metadata.targets).some((t) => t.status === 'staged')) { + staged.push(info); + } + } + return staged; + } catch { + return []; + } +} + /** Find draft releases for a package (by name prefix) that are older than the current version */ export async function findStaleDraftReleases( packageName: string, diff --git a/packages/bumpy/src/core/publish-pipeline.ts b/packages/bumpy/src/core/publish-pipeline.ts index 49eefc2..a757d7e 100644 --- a/packages/bumpy/src/core/publish-pipeline.ts +++ b/packages/bumpy/src/core/publish-pipeline.ts @@ -15,14 +15,42 @@ export interface PublishOptions { tag?: string; // npm dist-tag (e.g., "next", "beta") /** Skip creating git tags (snapshot releases are ephemeral and never tagged) */ noTag?: boolean; + /** + * Force live publishing even when `npmStaged` is configured. Used for snapshots, + * which are ephemeral dist-tag previews that must be immediately installable and + * so are never routed through the staged-approval flow. + */ + noStage?: boolean; } export interface PublishResult { published: { name: string; version: string }[]; + /** + * Packages staged (via `npm stage publish`) but not yet live — awaiting 2FA + * approval on the registry. Kept separate from `published` so the GitHub + * release can be marked "staged" (draft) rather than finalized as published. + */ + staged: { name: string; version: string; stageId?: string }[]; skipped: { name: string; reason: string }[]; failed: { name: string; error: string }[]; } +/** + * Parse the stage id (a UUID) from `npm stage publish --json` output. + * npm returns `{ pkg: { name, version, stageId } }`; tolerate minor shape + * variations (array wrapper, top-level stageId) and return undefined if absent. + */ +export function parseStageId(output: string): string | undefined { + try { + const parsed = JSON.parse(output); + const entry = Array.isArray(parsed) ? parsed[0] : parsed; + const stageId = entry?.pkg?.stageId ?? entry?.stageId; + return typeof stageId === 'string' && stageId ? stageId : undefined; + } catch { + return undefined; + } +} + /** * Detect which CI OIDC provider is available for npm trusted publishing. * Returns the provider name or null if none detected. @@ -157,7 +185,7 @@ export async function publishPackages( catalogs: CatalogMap = new Map(), detectedPm: PackageManager = 'npm', ): Promise { - const result: PublishResult = { published: [], skipped: [], failed: [] }; + const result: PublishResult = { published: [], staged: [], skipped: [], failed: [] }; const publishConfig = config.publish; // Set up npm authentication before publishing @@ -168,7 +196,7 @@ export async function publishPackages( throw new Error('provenance requires publishManager "npm" — provenance attestation is an npm-specific feature'); } - if (publishConfig.npmStaged) { + if (publishConfig.npmStaged && !opts.noStage) { if (publishConfig.publishManager !== 'npm') { throw new Error('npmStaged requires publishManager "npm" — staged publishing is an npm-specific feature'); } @@ -231,7 +259,13 @@ export async function publishPackages( await resolveProtocolsInPlace(pkg, packages, releasePlan, catalogs); } + // Staged publishing only applies to the standard npm flow (not custom commands), + // and never to snapshots (opts.noStage). + const isStaged = + publishConfig.npmStaged && publishConfig.publishManager === 'npm' && !pkgConfig.publishCommand && !opts.noStage; + // 3. Publish + let publishOutput: string | undefined; if (pkgConfig.publishCommand) { // Custom publish command(s) const commands = Array.isArray(pkgConfig.publishCommand) @@ -251,10 +285,10 @@ export async function publishPackages( } else if (!pkgConfig.skipNpmPublish) { // Standard publish flow if (publishConfig.protocolResolution === 'pack') { - await packThenPublish(pkg, pkgConfig, config, packManager, opts); + publishOutput = await packThenPublish(pkg, pkgConfig, config, packManager, opts); } else { // "in-place" already resolved above; "none" skips resolution - await npmPublishDirect(pkg, pkgConfig, config, opts); + publishOutput = await npmPublishDirect(pkg, pkgConfig, config, opts); } } else { result.skipped.push({ name: release.name, reason: 'skipNpmPublish' }); @@ -265,8 +299,15 @@ export async function publishPackages( // 3. Git tag createGitTag(release, rootDir, opts); - result.published.push({ name: release.name, version: release.newVersion }); - log.success(` Published ${release.name}@${release.newVersion}`); + if (isStaged) { + // Not live yet — staged on the registry, awaiting 2FA approval. + const stageId = publishOutput ? parseStageId(publishOutput) : undefined; + result.staged.push({ name: release.name, version: release.newVersion, stageId }); + log.success(` Staged ${release.name}@${release.newVersion} — awaiting approval`); + } else { + result.published.push({ name: release.name, version: release.newVersion }); + log.success(` Published ${release.name}@${release.newVersion}`); + } } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); log.error(` Failed to publish ${release.name}: ${errMsg}`); @@ -287,14 +328,14 @@ async function packThenPublish( config: BumpyConfig, packManager: PackageManager, opts: PublishOptions, -): Promise { +): Promise { const packArgs = getPackArgs(packManager); log.dim(` Packing with: ${packArgs.join(' ')}`); if (opts.dryRun) { const publishArgs = buildPublishArgs(pkg, pkgConfig, config, opts, ''); log.dim(` Would publish with: ${publishArgs.join(' ')}`); - return; + return undefined; } // Pack and capture the tarball filename @@ -305,7 +346,7 @@ async function packThenPublish( // Publish the tarball const publishArgs = buildPublishArgs(pkg, pkgConfig, config, opts, tarball); log.dim(` Publishing: ${publishArgs.join(' ')}`); - await runArgsAsync(publishArgs, { cwd: pkg.dir }); + return await runArgsAsync(publishArgs, { cwd: pkg.dir }); } finally { // Clean up tarball try { @@ -316,18 +357,17 @@ async function packThenPublish( } } -/** Publish directly from the package directory (no tarball) */ +/** Publish directly from the package directory (no tarball). Returns the publish stdout. */ async function npmPublishDirect( pkg: WorkspacePackage, pkgConfig: WorkspacePackage['bumpy'] & {}, config: BumpyConfig, opts: PublishOptions, -): Promise { +): Promise { const args = buildPublishArgs(pkg, pkgConfig, config, opts); log.dim(` Running: ${args.join(' ')}`); - if (!opts.dryRun) { - await runArgsAsync(args, { cwd: pkg.dir }); - } + if (opts.dryRun) return undefined; + return await runArgsAsync(args, { cwd: pkg.dir }); } function getPackArgs(pm: PackageManager): string[] { @@ -355,8 +395,11 @@ function buildPublishArgs( const args: string[] = []; // Base command - if (config.publish.npmStaged && publishManager === 'npm') { - args.push('npm', 'stage', 'publish'); + const isStaged = config.publish.npmStaged && publishManager === 'npm' && !opts.noStage; + if (isStaged) { + // `--json` yields `{ pkg: { name, version, stageId } }` so we can record the + // stage id on the GitHub release for later finalization. + args.push('npm', 'stage', 'publish', '--json'); } else if (publishManager === 'yarn') { args.push('yarn', 'npm', 'publish'); } else { diff --git a/packages/bumpy/test/core/publish-pipeline.test.ts b/packages/bumpy/test/core/publish-pipeline.test.ts index 4bfb7e9..ba15ccf 100644 --- a/packages/bumpy/test/core/publish-pipeline.test.ts +++ b/packages/bumpy/test/core/publish-pipeline.test.ts @@ -6,7 +6,7 @@ import { writeJson, readJson, ensureDir, writeText } from '../../src/utils/fs.ts import { makePkg, gitInDir } from '../helpers.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 { publishPackages, parseStageId } from '../../src/core/publish-pipeline.ts'; import type { WorkspacePackage, ReleasePlan, BumpyConfig } from '../../src/types.ts'; import { DEFAULT_CONFIG, DEFAULT_PUBLISH_CONFIG } from '../../src/types.ts'; @@ -278,9 +278,13 @@ describe('publishPackages', () => { 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 + // Mock npm --version (for staged validation) and the publish command. + // `npm stage publish --json` returns the staged package + its stage id. addMockRule({ match: 'npm --version', response: '11.15.0' }); - addMockRule({ match: 'npm stage publish', response: '' }); + addMockRule({ + match: 'npm stage publish', + response: JSON.stringify({ pkg: { name: 'staged-pkg', version: '1.0.1', stageId: 'stage-uuid-123' } }), + }); const packages = new Map(); packages.set('staged-pkg', makePkg('staged-pkg', '1.0.0', { dir: pkgDir })); @@ -306,8 +310,82 @@ describe('publishPackages', () => { const result = await publishPackages(plan, packages, depGraph, STAGED_CONFIG, tmpDir, {}); - expect(result.published).toHaveLength(1); + // A staged publish is NOT live — it lands in `staged`, not `published`, so the + // GitHub release stays a draft until finalized. + expect(result.published).toHaveLength(0); + expect(result.staged).toHaveLength(1); + expect(result.staged[0]!.name).toBe('staged-pkg'); + expect(result.staged[0]!.version).toBe('1.0.1'); + expect(result.staged[0]!.stageId).toBe('stage-uuid-123'); + const publishCalls = getCallsMatching('npm stage publish'); expect(publishCalls.length).toBeGreaterThanOrEqual(1); + // `--json` is required to capture the stage id. + expect(publishCalls.some((c) => c.command.includes('--json'))).toBe(true); + }); + + test('noStage forces a live publish even when npmStaged is configured (snapshots)', async () => { + const pkgDir = resolve(tmpDir, 'packages/snap-pkg'); + await ensureDir(pkgDir); + await writeJson(resolve(pkgDir, 'package.json'), { name: 'snap-pkg', version: '1.0.0' }); + await setupGitRepo(); + + addMockRule({ match: 'npm --version', response: '11.15.0' }); + addMockRule({ match: 'npm publish', response: '' }); + + const packages = new Map(); + packages.set('snap-pkg', makePkg('snap-pkg', '1.0.0', { dir: pkgDir })); + + const depGraph = new DependencyGraph(packages); + const plan: ReleasePlan = { + bumpFiles: [], + warnings: [], + releases: [ + { + name: 'snap-pkg', + type: 'patch', + oldVersion: '1.0.0', + newVersion: '1.0.1-snap.0', + bumpFiles: [], + isDependencyBump: false, + isCascadeBump: false, + isGroupBump: false, + bumpSources: [], + }, + ], + }; + + const result = await publishPackages(plan, packages, depGraph, STAGED_CONFIG, tmpDir, { + noStage: true, + noTag: true, + }); + + expect(result.staged).toHaveLength(0); + expect(result.published).toHaveLength(1); + // Live publish path — never `stage publish`. + expect(getCallsMatching('npm stage publish')).toHaveLength(0); + expect(getCallsMatching('npm publish').length).toBeGreaterThanOrEqual(1); + }); +}); + +describe('parseStageId', () => { + test('parses the stage id from npm stage publish --json output', () => { + const out = JSON.stringify({ pkg: { name: 'foo', version: '1.2.3', stageId: 'abc-123' } }); + expect(parseStageId(out)).toBe('abc-123'); + }); + + test('tolerates an array wrapper', () => { + const out = JSON.stringify([{ pkg: { stageId: 'in-array' } }]); + expect(parseStageId(out)).toBe('in-array'); + }); + + test('tolerates a top-level stageId', () => { + expect(parseStageId(JSON.stringify({ stageId: 'top-level' }))).toBe('top-level'); + }); + + test('returns undefined for empty or non-JSON output', () => { + expect(parseStageId('')).toBeUndefined(); + expect(parseStageId('not json')).toBeUndefined(); + expect(parseStageId(JSON.stringify({ pkg: {} }))).toBeUndefined(); }); }); diff --git a/packages/bumpy/test/core/publish-recovery.test.ts b/packages/bumpy/test/core/publish-recovery.test.ts index 9045aa7..5b06aa9 100644 --- a/packages/bumpy/test/core/publish-recovery.test.ts +++ b/packages/bumpy/test/core/publish-recovery.test.ts @@ -100,6 +100,12 @@ describe('formatPublishedToSection', () => { const result = formatPublishedToSection(targets); expect(result).toContain('- ⏳ npm'); }); + + test('formats staged targets awaiting approval', () => { + const targets = { npm: { status: 'staged' as const, stageId: 'uuid-1', stagedAt: '2026-01-01T00:00:00Z' } }; + const result = formatPublishedToSection(targets); + expect(result).toContain('- 🟡 npm — staged, awaiting approval'); + }); }); describe('composeReleaseBody', () => { From 48635cafc553733bfd63ca63e19bbcb31da4ff6d Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Thu, 2 Jul 2026 15:15:16 -0700 Subject: [PATCH 2/9] =?UTF-8?q?docs:=20clarify=20staged=20finalize=20lifec?= =?UTF-8?q?ycle=20=E2=80=94=20npm=20approve=20vs=20bumpy=20finalize,=20man?= =?UTF-8?q?ual=20+=20cron=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/configuration.md | 2 +- docs/github-actions.md | 64 +++++++++++++++++++++++++++++------------- 2 files changed, 46 insertions(+), 20 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 9f2f1d8..8fe1817 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -117,7 +117,7 @@ Requirements: } ``` -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). Once the version is approved and goes live, run `bumpy publish finalize` to flip it to published and link to the live package. Set up a workflow to run finalize automatically — see [Staged publishing (finalize workflow)](github-actions.md#staged-publishing-finalize-workflow). +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 ` — 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. ### Version PR config diff --git a/docs/github-actions.md b/docs/github-actions.md index be873b8..27adaaa 100644 --- a/docs/github-actions.md +++ b/docs/github-actions.md @@ -306,28 +306,50 @@ jobs: `bumpy ci release --auto-publish` collapses version + publish into a single run, skipping the Version Packages PR. This forfeits the preview/review gate on version bumps — every merge to main with a bump file ships immediately. It's also incompatible with the [split-job pattern](#release-workflow-recommended-split-jobs) above, since both paths run in one command. Prefer the default flow. See [the CLI reference](cli.md#bumpy-ci-release) if you still need it. -## Staged publishing (finalize workflow) +## Staged publishing (finalizing a release) -With [`npmStaged`](configuration.md#staged-publishing) enabled, your release job runs `npm stage publish` instead of `npm publish`. The package is **staged** on npmjs.com, not live — it needs a 2FA approval before anyone can install it. bumpy reflects this honestly: +With [`npmStaged`](configuration.md#staged-publishing) enabled, your release job runs `npm stage publish` instead of `npm publish`. The package is **staged** on npmjs.com, not live — a human still has to approve it with 2FA before anyone can install it. bumpy reflects that honestly instead of pretending the release shipped: - The publish target is marked **🟡 staged, awaiting approval** (not ✅ published). - The GitHub release stays a **draft** — so the `release: published` event does _not_ fire yet, and downstream release automation doesn't run against a package that isn't out. -- The stage id is recorded in the release metadata for approval tooling to consume. +- The npm stage id is recorded in the release metadata. -Once the staged version is approved and goes live, run `bumpy publish finalize` to reconcile: it checks the registry, flips the target to ✅ with the live package URL, and publishes the GitHub release (which _then_ fires `release: published`). It's 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. +### The lifecycle + +A staged release goes live in three steps. **The two responsibilities are split:** npm owns approval, bumpy owns the GitHub release. + +1. **CI stages it.** You merge the Version Packages PR, the release job runs `npm stage publish`, and the draft GitHub release appears marked 🟡 staged. +2. **You approve it on npm.** This is npm's 2FA gate — `bumpy publish finalize` does _not_ do this for you. List what's pending and approve it: + ```bash + npm stage list # find the staged version + its + npm stage approve # provide 2FA — this publishes it to the registry + ``` + (You can also approve from the package's page on npmjs.com. The stage id is also stored in the draft release's metadata.) +3. **You finalize the GitHub release.** Once the version is live, `bumpy publish finalize` reconciles GitHub: it checks the registry, flips the target to ✅ with the live package URL, and publishes the release (which _then_ fires `release: published`). It's idempotent — a version that's still staged is left untouched — so it's always safe to run. + +### Option A: finalize manually + +The simplest setup is **no extra workflow at all**. After approving, run finalize from your machine (or wherever you have `gh` + `npm`): + +```bash +npm stage approve # step 2 — approve on npm +bumpy publish finalize # step 3 — update the GitHub release (reconciles all staged) +``` + +`bumpy publish finalize` with no argument reconciles every staged release; pass `name@version` to target one. It only reads the registry and edits the GitHub release — **no publish credentials needed**. + +### Option B: finalize automatically (scheduled) + +If you'd rather not run finalize by hand, add a workflow that reconciles on a schedule (and can also be triggered manually from the Actions tab). You still approve on npm in step 2 — this just handles step 3 for you, so a release goes from "approved" to "published on GitHub" without you touching it. ```yaml # .github/workflows/bumpy-finalize.yml name: Finalize on: - # 1. Event-driven — your approval tooling fires this after approving. - # client_payload.tag pins one release; omit it to reconcile everything. - repository_dispatch: - types: [bumpy-finalize] - # 2. Scheduled reconcile — self-heals even if approval happened in the npm UI. + # Scheduled reconcile — picks up releases you've approved on npm. schedule: - - cron: '17 * * * *' - # 3. Manual — run from the Actions tab, optionally targeting one release. + - cron: '17 * * * *' # hourly; tune to taste + # Manual — run from the Actions tab, optionally targeting one release. workflow_dispatch: inputs: tag: @@ -353,22 +375,26 @@ jobs: with: node-version: latest - run: bun install - - run: bunx @varlock/bumpy publish finalize ${{ github.event.client_payload.tag || inputs.tag }} + - run: bunx @varlock/bumpy publish finalize ${{ inputs.tag }} env: GH_TOKEN: ${{ github.token }} # PAT/App token so finalizing fires downstream `release: published` workflows BUMPY_GH_TOKEN: ${{ secrets.BUMPY_GH_TOKEN }} ``` -Note that this job needs **no publish credentials** — no `id-token`, no `NPM_TOKEN`. It only reads the registry and edits the GitHub release, so it's safe to run on the low-privilege default token (plus `BUMPY_GH_TOKEN` if you want the finalized release to trigger downstream workflows). +This job needs **no publish credentials** — no `id-token`, no `NPM_TOKEN`. It only reads the registry and edits the GitHub release, so the low-privilege default token is enough (plus `BUMPY_GH_TOKEN` if you want the finalized release to trigger downstream workflows). + +### Option C: finalize instantly (event-driven) + +If you approve through automated tooling (a service that batch-approves staged publishes), have it fire a `repository_dispatch` the moment it approves, so the GitHub release finalizes with no cron lag. Add this trigger to the workflow above: -The three triggers are complementary — wire up whichever fit: +```yaml +on: + repository_dispatch: + types: [bumpy-finalize] # client_payload.tag pins one release; omit to reconcile all +``` -| Trigger | When it fires | Best for | -| --------------------- | ---------------------------------------------------- | ------------------------------------------------------------ | -| `repository_dispatch` | Your approval tooling sends a `bumpy-finalize` event | Instant finalize the moment a batch is approved | -| `schedule` | On the cron interval | A safety net so nothing stays staged if a dispatch is missed | -| `workflow_dispatch` | You click "Run workflow" | Approving in the npm UI, or one-off recovery | +and include the payload tag in the run step: `publish finalize ${{ github.event.client_payload.tag || inputs.tag }}`. > **Dispatch payload:** send `{"event_type": "bumpy-finalize", "client_payload": {"tag": "my-pkg@1.2.3"}}` to the [repository dispatch API](https://docs.github.com/en/rest/repos/repos#create-a-repository-dispatch-event). Omit `tag` to reconcile every staged release in one run. From 5a8f125789ab020ac9c810d13d8e47d2ce754786 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Thu, 2 Jul 2026 16:13:50 -0700 Subject: [PATCH 3/9] docs: frame finalize dispatch as a payload-free 'go reconcile' nudge (batches are the norm) --- docs/github-actions.md | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/docs/github-actions.md b/docs/github-actions.md index 27adaaa..bc521c0 100644 --- a/docs/github-actions.md +++ b/docs/github-actions.md @@ -386,17 +386,30 @@ This job needs **no publish credentials** — no `id-token`, no `NPM_TOKEN`. It ### Option C: finalize instantly (event-driven) -If you approve through automated tooling (a service that batch-approves staged publishes), have it fire a `repository_dispatch` the moment it approves, so the GitHub release finalizes with no cron lag. Add this trigger to the workflow above: +If you approve through automated tooling (a service that approves staged publishes), have it fire a `repository_dispatch` the moment it approves, so the GitHub release finalizes with no cron lag. Add this trigger to the workflow above: ```yaml on: repository_dispatch: - types: [bumpy-finalize] # client_payload.tag pins one release; omit to reconcile all + types: [bumpy-finalize] ``` -and include the payload tag in the run step: `publish finalize ${{ github.event.client_payload.tag || inputs.tag }}`. +Think of the dispatch as a **"something got approved — go reconcile" nudge, not a "finalize this one thing" command.** It carries no payload: the workflow above already runs `publish finalize` with no argument, which reconciles _every_ staged release that's now live. That's exactly what you want for a monorepo, where one release stages many packages together — the approver fires a single ping and the whole batch finalizes: -> **Dispatch payload:** send `{"event_type": "bumpy-finalize", "client_payload": {"tag": "my-pkg@1.2.3"}}` to the [repository dispatch API](https://docs.github.com/en/rest/repos/repos#create-a-repository-dispatch-event). Omit `tag` to reconcile every staged release in one run. +```bash +# after approving the batch on npm — one ping, no payload +gh api repos/OWNER/REPO/dispatches -f event_type=bumpy-finalize +``` + +Because finalize decides what to publish by probing the registry (not from the payload), this stays correct even when unrelated versions are staged — it only finalizes the ones that actually went live, and it's idempotent, so firing it more than once is harmless. + +> **Targeting one release (optional).** In the rare case you want to finalize a single release and leave others staged, pass its tag in the payload and thread it into the run step — `publish finalize ${{ github.event.client_payload.tag }}`: +> +> ```bash +> gh api repos/OWNER/REPO/dispatches -f event_type=bumpy-finalize -F 'client_payload[tag]=my-pkg@1.2.3' +> ``` +> +> For most repos you won't need this — the no-payload nudge above is the norm. ## Advanced: per-package conditional builds From 7b1e0f7399517edc2ccae27d23276366205233df Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Thu, 2 Jul 2026 16:41:36 -0700 Subject: [PATCH 4/9] docs+code: note staged is npm-only + per-package assumption (per-target when multi-target lands) --- docs/configuration.md | 2 ++ packages/bumpy/src/commands/publish.ts | 5 +++++ packages/bumpy/src/core/publish-pipeline.ts | 10 +++++++++- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/configuration.md b/docs/configuration.md index 8fe1817..b1693ea 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -108,6 +108,8 @@ Requirements: - npm >= 11.15.0 - The package must already exist on the npm registry (first publish cannot be staged) +Staging is an **npm-registry feature** — it only applies to packages published through the standard npm flow. Packages that publish via a `publishCommand` (jsr, cargo, anything custom) have no staging equivalent, so they publish live as usual. In a mixed release, npm packages stage while custom-target packages go live immediately; each finalizes independently. Staged tracking also relies on **GitHub releases** (the staged state and finalize step live in the draft release), so it needs `gh` available — there's no staged flow without a GitHub release to track it on. + ```json { "publish": { diff --git a/packages/bumpy/src/commands/publish.ts b/packages/bumpy/src/commands/publish.ts index 566306c..f327d0c 100644 --- a/packages/bumpy/src/commands/publish.ts +++ b/packages/bumpy/src/commands/publish.ts @@ -602,6 +602,11 @@ async function runPublishFlow( const { registry, repoSlug } = registryByPkg.get(release.name) || {}; let changed = false; + // `published`/`staged`/`failed` are per-package (see PublishResult), so every target + // of the package takes the same status. That's correct while each package has exactly + // one target (see publishTargetsByPkg above). If a package ever publishes to a mix of + // stageable (npm) and live (jsr/cargo) targets in one run, this loop must switch to a + // per-target result so it can mark "npm staged, jsr success" independently. for (const targetName of targets) { // Skip already-succeeded targets if (info.metadata.targets[targetName]?.status === 'success') continue; diff --git a/packages/bumpy/src/core/publish-pipeline.ts b/packages/bumpy/src/core/publish-pipeline.ts index a757d7e..4222a13 100644 --- a/packages/bumpy/src/core/publish-pipeline.ts +++ b/packages/bumpy/src/core/publish-pipeline.ts @@ -260,7 +260,15 @@ export async function publishPackages( } // Staged publishing only applies to the standard npm flow (not custom commands), - // and never to snapshots (opts.noStage). + // and never to snapshots (opts.noStage). Staging is an npm-registry feature — no + // other publish target (jsr, cargo, custom commands) has an equivalent. + // + // NOTE: this is a per-*package* decision because bumpy currently gives each package + // exactly one publish target (see publishTargetsByPkg in commands/publish.ts). If a + // future multi-target model lets one package publish to npm (stageable) AND another + // target (live) in the same run, this — and PublishResult below — must become + // per-target: a package could be "npm staged, jsr live" at once, which a per-package + // staged/published split can't express. const isStaged = publishConfig.npmStaged && publishConfig.publishManager === 'npm' && !pkgConfig.publishCommand && !opts.noStage; From 642de20813f9bf66475948bb7c56c1b2cbdc7b38 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Thu, 2 Jul 2026 17:41:01 -0700 Subject: [PATCH 5/9] fix: freeze the version tag once a target is staged (not only on success) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A staged tarball is already committed to the registry from the tagged SHA, so a publish re-run while awaiting approval must not move the tag to HEAD — that would point the release at a different commit than the artifact was built from. --- packages/bumpy/src/commands/publish.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/bumpy/src/commands/publish.ts b/packages/bumpy/src/commands/publish.ts index f327d0c..8a77ffc 100644 --- a/packages/bumpy/src/commands/publish.ts +++ b/packages/bumpy/src/commands/publish.ts @@ -500,13 +500,19 @@ async function runPublishFlow( } } - // Handle tag movement: if no targets succeeded yet, move tag to HEAD + // Handle tag movement: if nothing has been shipped yet, move tag to HEAD. + // "Shipped" includes staged targets: a staged tarball is already committed to the + // registry from the tagged SHA, so the tag must freeze there — moving it to HEAD on a + // re-run (while still awaiting approval) would point the release at a different commit + // than the artifact was built from. for (const release of toPublish) { const info = releaseMetadataByPkg.get(release.name); if (!info) continue; - const anySucceeded = Object.values(info.metadata.targets).some((t) => t.status === 'success'); - if (!anySucceeded) { + const anyShipped = Object.values(info.metadata.targets).some( + (t) => t.status === 'success' || t.status === 'staged', + ); + if (!anyShipped) { // Safe to move tag to HEAD const tag = info.tag; const headSha = getHeadSha(rootDir); @@ -525,7 +531,7 @@ async function runPublishFlow( if (headSha && tagSha && headSha !== tagSha) { const count = tryRunArgs(['git', 'rev-list', '--count', `${tag}..HEAD`], { cwd: rootDir }); log.warn( - ` HEAD is ${count} commit(s) ahead of version tag ${tag} — some targets already published from tagged commit`, + ` HEAD is ${count} commit(s) ahead of version tag ${tag} — some targets already published or staged from tagged commit`, ); } } From 48e5aa89c94be4dc0a657cb59bdb963ae160496c Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Thu, 2 Jul 2026 17:42:28 -0700 Subject: [PATCH 6/9] docs(code): clarify tag freezes on shipped (success/staged), not on failed (fix-forward keeps tracking HEAD) --- packages/bumpy/src/commands/publish.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/bumpy/src/commands/publish.ts b/packages/bumpy/src/commands/publish.ts index 8a77ffc..7810b58 100644 --- a/packages/bumpy/src/commands/publish.ts +++ b/packages/bumpy/src/commands/publish.ts @@ -501,10 +501,17 @@ async function runPublishFlow( } // Handle tag movement: if nothing has been shipped yet, move tag to HEAD. - // "Shipped" includes staged targets: a staged tarball is already committed to the - // registry from the tagged SHA, so the tag must freeze there — moving it to HEAD on a - // re-run (while still awaiting approval) would point the release at a different commit - // than the artifact was built from. + // + // "Shipped" = a build reached the registry (success OR staged) — NOT merely "the run + // did something". A *failed* target ships nothing, so it deliberately does NOT freeze + // the tag: the fix-forward workflow (push more commits to get a failed publish through) + // relies on the tag tracking HEAD until a build actually lands, so the final tag sits + // on the commit that worked. + // + // A *staged* target does freeze it: the tarball is already committed to the registry + // from the tagged SHA (you can only approve/reject it, not fix-and-repush), so moving + // the tag to HEAD on a re-run while awaiting approval would point the release at a + // different commit than the artifact was built from. for (const release of toPublish) { const info = releaseMetadataByPkg.get(release.name); if (!info) continue; From 7f7b235bb26fed6dadbf18c2e198050f6d9cc8b6 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Thu, 2 Jul 2026 18:14:20 -0700 Subject: [PATCH 7/9] feat: add `bumpy publish reopen` for rejected staged publishes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `: 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). --- docs/cli.md | 18 ++++++- docs/configuration.md | 2 + docs/github-actions.md | 13 +++++ packages/bumpy/src/cli.ts | 17 +++++++ packages/bumpy/src/commands/reopen.ts | 63 +++++++++++++++++++++++++ packages/bumpy/test/core/reopen.test.ts | 63 +++++++++++++++++++++++++ 6 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 packages/bumpy/src/commands/reopen.ts create mode 100644 packages/bumpy/test/core/reopen.test.ts diff --git a/docs/cli.md b/docs/cli.md index 39427b7..14ae5b2 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -128,7 +128,23 @@ bumpy publish finalize --dry-run # show what would be finalized | `[name@version]` | Finalize only this release; omit to reconcile all staged releases | | `--dry-run` | Report what would be finalized without editing any releases | -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. +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. + +## `bumpy publish reopen` + +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 ` to tell it. + +```bash +npm stage reject # reject on npm +bumpy publish reopen @myorg/pkg@1.2.3 # then reopen the release +``` + +| Argument / Flag | Description | +| --------------- | ---------------------------------------------------- | +| `name@version` | The rejected release to reopen (required) | +| `--dry-run` | Report what would change without editing the release | + +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. ## `bumpy check` diff --git a/docs/configuration.md b/docs/configuration.md index b1693ea..8cd037e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -121,6 +121,8 @@ Staging is an **npm-registry feature** — it only applies to packages published 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 ` — 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. +If you instead **reject** a stage on npm (`npm stage reject `), run **`bumpy publish reopen `** — 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. + ### Version PR config The `versionPr` object customizes the PR that `bumpy ci release` creates: diff --git a/docs/github-actions.md b/docs/github-actions.md index bc521c0..76f5b5b 100644 --- a/docs/github-actions.md +++ b/docs/github-actions.md @@ -411,6 +411,19 @@ Because finalize decides what to publish by probing the registry (not from the p > > For most repos you won't need this — the no-payload nudge above is the norm. +### If a staged publish is rejected + +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: + +```bash +npm stage reject # reject on npm +bumpy publish reopen my-pkg@1.2.3 # tell bumpy — reopens the release for re-publish +``` + +`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.) + +If you approve/reject through tooling, have it run `bumpy publish reopen ` (e.g. via a `repository_dispatch`) at rejection time, the mirror of the finalize nudge. + ## Advanced: per-package conditional builds 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: diff --git a/packages/bumpy/src/cli.ts b/packages/bumpy/src/cli.ts index 643036d..4edc3ec 100644 --- a/packages/bumpy/src/cli.ts +++ b/packages/bumpy/src/cli.ts @@ -222,6 +222,21 @@ async function main() { break; } + // `bumpy publish reopen ` — after `npm stage reject`, flip a rejected + // staged release back to failed so the next publish re-stages it. + if (args[1] === 'reopen') { + const reopenFlags = parseFlags(args.slice(2)); + const tagArg = args[2] && !args[2].startsWith('--') ? args[2] : undefined; + const tag = (reopenFlags.tag as string | undefined) ?? tagArg; + if (!tag) { + log.error('`bumpy publish reopen` requires a release: `bumpy publish reopen `'); + process.exit(1); + } + const { reopenCommand } = await import('./commands/reopen.ts'); + await reopenCommand(rootDir, { tag, dryRun: reopenFlags['dry-run'] === true }); + break; + } + const { publishCommand } = await import('./commands/publish.ts'); if (flags.snapshot === true) { log.error('--snapshot requires a name, e.g. `bumpy publish --snapshot pr-123`.'); @@ -292,6 +307,8 @@ function printHelp() { (--snapshot : transient preview publish to a throwaway dist-tag) publish finalize Finalize staged releases that have been approved and gone live ([name@version]: finalize one release; otherwise reconcile all staged) + publish reopen Reopen a staged release rejected on npm so it re-stages on next publish + (name@version required; run after "npm stage reject") ci check PR check — report pending releases, comment on PR ci comment Post a pre-rendered comment (workflow_run half of the fork-comment split) ci plan Report what ci release would do (JSON + GitHub Actions outputs) diff --git a/packages/bumpy/src/commands/reopen.ts b/packages/bumpy/src/commands/reopen.ts new file mode 100644 index 0000000..3cfe35e --- /dev/null +++ b/packages/bumpy/src/commands/reopen.ts @@ -0,0 +1,63 @@ +import { log, colorize } from '../utils/logger.ts'; +import { isGhAvailable, findReleaseByTag, updateReleaseBody, updateReleaseBodyStatus } from '../core/github-release.ts'; + +export interface ReopenCommandOptions { + /** The rejected release to reopen (`name@version`). */ + tag: string; + dryRun?: boolean; +} + +/** + * Reopen a staged release whose staged publish was rejected on npm. + * + * npm exposes no public "rejected" signal (a rejected stage looks identical to a still-pending + * one — both are simply not live), and the finalize job is intentionally credential-free, so + * bumpy can't auto-detect a rejection. Instead the maintainer (or their approval tooling) runs + * this after `npm stage reject `. + * + * It flips the staged target(s) back to `failed`, which reuses bumpy's existing fix-forward + * path: the stale 🟡 clears, the version tag un-freezes (a `failed` target isn't "shipped"), + * and the next `bumpy publish` re-stages the same version. To abandon the version instead, just + * don't reopen — the next version bump supersedes the draft. + */ +export async function reopenCommand(rootDir: string, opts: ReopenCommandOptions): Promise { + if (!isGhAvailable()) { + log.error('gh CLI not found — cannot reopen a staged release.'); + process.exit(1); + } + + const info = await findReleaseByTag(opts.tag, rootDir); + if (!info) { + log.error(`No GitHub release found for ${opts.tag}.`); + process.exit(1); + } + const meta = info.metadata; + if (!meta) { + log.error(`${opts.tag} has no bumpy metadata — nothing to reopen.`); + process.exit(1); + } + + const stagedTargets = Object.entries(meta.targets).filter(([, s]) => s.status === 'staged'); + if (stagedTargets.length === 0) { + log.info(`${opts.tag} has no staged targets — nothing to reopen.`); + return; + } + + for (const [targetName, state] of stagedTargets) { + meta.targets[targetName] = { + status: 'failed', + error: 'staged publish was rejected — will re-stage on next publish', + lastAttempt: new Date().toISOString(), + ...(state.label ? { label: state.label } : {}), + }; + } + + if (opts.dryRun) { + log.dim(` Would reopen ${opts.tag} — ${stagedTargets.length} staged target(s) → failed`); + return; + } + + const updatedBody = updateReleaseBodyStatus(info.body, meta); + await updateReleaseBody(opts.tag, updatedBody, rootDir); + log.success(` Reopened ${colorize(opts.tag, 'cyan')} — will re-stage on the next publish run`); +} diff --git a/packages/bumpy/test/core/reopen.test.ts b/packages/bumpy/test/core/reopen.test.ts new file mode 100644 index 0000000..ff8bc02 --- /dev/null +++ b/packages/bumpy/test/core/reopen.test.ts @@ -0,0 +1,63 @@ +import { test, expect, describe, beforeEach, afterEach } from 'bun:test'; +import { installShellMock, uninstallShellMock, getCallsMatching, addMockRule } from '../helpers-shell-mock.ts'; +import { composeReleaseBody, parseReleaseMetadata, type ReleaseMetadata } from '../../src/core/github-release.ts'; +import { reopenCommand } from '../../src/commands/reopen.ts'; + +/** Mock `gh release view --json ...` to return a release with the given body. */ +function mockReleaseView(body: string, isDraft = true) { + addMockRule({ + match: /^gh release view/, + response: JSON.stringify({ tagName: 'pkg-a@1.2.3', name: 'pkg-a v1.2.3', body, isDraft }), + }); +} + +describe('reopenCommand', () => { + beforeEach(() => installShellMock()); + afterEach(() => uninstallShellMock()); + + const stagedMeta: ReleaseMetadata = { + version: '1.2.3', + targets: { npm: { status: 'staged', stageId: 'uuid-1', stagedAt: '2026-01-01T00:00:00Z' } }, + }; + + test('flips a staged target to failed and edits the release body', async () => { + mockReleaseView(composeReleaseBody('- A change', stagedMeta)); + addMockRule({ match: /^gh release edit/, response: '' }); + + await reopenCommand('/tmp/x', { tag: 'pkg-a@1.2.3' }); + + const editCalls = getCallsMatching('gh release edit'); + expect(editCalls.length).toBe(1); + + // The edited body's metadata should now show the target as failed (rejected). + const notesIdx = editCalls[0]!.args.indexOf('--notes'); + const newBody = editCalls[0]!.args[notesIdx + 1]!; + const meta = parseReleaseMetadata(newBody)!; + expect(meta.targets.npm!.status).toBe('failed'); + expect(meta.targets.npm!.error).toContain('rejected'); + // No longer staged — the stale 🟡 marker is gone. + expect(newBody).not.toContain('🟡'); + }); + + test('dry-run does not edit the release', async () => { + mockReleaseView(composeReleaseBody('- A change', stagedMeta)); + addMockRule({ match: /^gh release edit/, response: '' }); + + await reopenCommand('/tmp/x', { tag: 'pkg-a@1.2.3', dryRun: true }); + + expect(getCallsMatching('gh release edit')).toHaveLength(0); + }); + + test('no-ops when the release has no staged targets', async () => { + const liveMeta: ReleaseMetadata = { + version: '1.2.3', + targets: { npm: { status: 'success', url: 'https://npmjs.com/package/pkg-a/v/1.2.3' } }, + }; + mockReleaseView(composeReleaseBody('- A change', liveMeta)); + addMockRule({ match: /^gh release edit/, response: '' }); + + await reopenCommand('/tmp/x', { tag: 'pkg-a@1.2.3' }); + + expect(getCallsMatching('gh release edit')).toHaveLength(0); + }); +}); From fbc8dfebb0c7dba3e7299e7506ae02178168f8cb Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 3 Jul 2026 13:52:02 -0700 Subject: [PATCH 8/9] feat: point users at `publish reopen` when a publish re-run skips a staged package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the discoverability gap for non-stageflight users: after rejecting on npm and re-running publish, the skip is the wall they hit — now it names the reopen escape hatch inline. --- packages/bumpy/src/commands/publish.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/bumpy/src/commands/publish.ts b/packages/bumpy/src/commands/publish.ts index 7810b58..bde1983 100644 --- a/packages/bumpy/src/commands/publish.ts +++ b/packages/bumpy/src/commands/publish.ts @@ -564,7 +564,16 @@ async function runPublishFlow( } if (alreadyPublished.length > 0) { for (const name of alreadyPublished) { - log.dim(` Skipping ${name} — all targets already published or staged (per draft release metadata)`); + const info = releaseMetadataByPkg.get(name)!; + const hasStaged = Object.values(info.metadata.targets).some((t) => t.status === 'staged'); + if (hasStaged) { + // A staged package is intentionally skipped (don't re-stage). But if the user rejected + // it on npm, this skip is the wall they hit — point them at the reopen escape hatch. + log.dim(` Skipping ${name} — staged, awaiting approval`); + log.dim(` (rejected it on npm? run \`bumpy publish reopen ${info.tag}\` to re-stage)`); + } else { + log.dim(` Skipping ${name} — all targets already published (per draft release metadata)`); + } } toPublish = toPublish.filter((r) => !alreadyPublished.includes(r.name)); releasePlan.releases = toPublish; From 33a0c4bbc9ba743e6daf061c95216fab5c1eee67 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 3 Jul 2026 13:57:54 -0700 Subject: [PATCH 9/9] =?UTF-8?q?docs:=20make=20the=20rejected-stage=20escap?= =?UTF-8?q?e=20hatch=20clear=20=E2=80=94=20standalone=20(no=20stageflight)?= =?UTF-8?q?,=20+=20gh=20release=20delete=20+=20abandon=20options?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/cli.md | 4 +++- docs/github-actions.md | 12 +++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 14ae5b2..54e2ed2 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -144,7 +144,9 @@ bumpy publish reopen @myorg/pkg@1.2.3 # then reopen the release | `name@version` | The rejected release to reopen (required) | | `--dry-run` | Report what would change without editing the release | -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. +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. It's a fully manual escape hatch — no stageflight, tooling, or npm credentials needed (it only edits the GitHub release). + +Alternatives: `gh release delete ` then re-publish re-stages from scratch (loses the draft's edits); or to _abandon_ the version, don't reopen at all — ship a different version and the draft is superseded. See [If a staged publish is rejected](github-actions.md#if-a-staged-publish-is-rejected). ## `bumpy check` diff --git a/docs/github-actions.md b/docs/github-actions.md index 76f5b5b..dbf665b 100644 --- a/docs/github-actions.md +++ b/docs/github-actions.md @@ -413,16 +413,22 @@ Because finalize decides what to publish by probing the registry (not from the p ### If a staged publish is rejected -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: +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 — **this is a plain manual step; no CI, tooling, or stageflight required:** ```bash npm stage reject # reject on npm bumpy publish reopen my-pkg@1.2.3 # tell bumpy — reopens the release for re-publish ``` -`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.) +`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** (whether that publish runs on your machine or in CI). Push your fix and re-publish. It needs no npm credentials — it only edits the GitHub release. -If you approve/reject through tooling, have it run `bumpy publish reopen ` (e.g. via a `repository_dispatch`) at rejection time, the mirror of the finalize nudge. +You have three ways out of a rejected stage: + +- **Redo it** → `bumpy publish reopen `, then re-publish. Keeps the release notes/changelog; re-stages the same version. +- **Start clean** → `gh release delete `, then re-publish. The next `bumpy publish` finds no draft and re-stages from scratch. (The nuclear option — you lose the draft's edits.) +- **Abandon it** → do nothing. Ship a different version instead and the stale draft gets superseded automatically. + +If you approve/reject through tooling, have it run `bumpy publish reopen ` (e.g. via a `repository_dispatch`) at rejection time — the mirror of the finalize nudge. But that's purely an automation convenience on top of the manual command above; the command is the baseline. ## Advanced: per-package conditional builds