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..54e2ed2 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -113,6 +113,41 @@ 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 [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. 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` 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..8cd037e 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": { @@ -117,6 +119,10 @@ 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). 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 699a43a..dbf665b 100644 --- a/docs/github-actions.md +++ b/docs/github-actions.md @@ -306,6 +306,130 @@ 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 (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 — 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 npm stage id is recorded in the release metadata. + +### 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: + # Scheduled reconcile — picks up releases you've approved on npm. + schedule: + - cron: '17 * * * *' # hourly; tune to taste + # 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 ${{ inputs.tag }} + env: + GH_TOKEN: ${{ github.token }} + # PAT/App token so finalizing fires downstream `release: published` workflows + BUMPY_GH_TOKEN: ${{ secrets.BUMPY_GH_TOKEN }} +``` + +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 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] +``` + +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: + +```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. + +### 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 — **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** (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. + +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 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..4edc3ec 100644 --- a/packages/bumpy/src/cli.ts +++ b/packages/bumpy/src/cli.ts @@ -207,6 +207,36 @@ 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; + } + + // `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`.'); @@ -275,6 +305,10 @@ 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) + 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/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..bde1983 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, ); @@ -500,13 +500,26 @@ 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" = 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; - 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,27 +538,42 @@ 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`, ); } } } } - // 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)`); + 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; @@ -574,6 +602,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,10 +619,16 @@ 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) || {}; 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; @@ -603,6 +642,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 +705,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 +845,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/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/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..4222a13 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,21 @@ 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). 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; + // 3. Publish + let publishOutput: string | undefined; if (pkgConfig.publishCommand) { // Custom publish command(s) const commands = Array.isArray(pkgConfig.publishCommand) @@ -251,10 +293,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 +307,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 +336,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 +354,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 +365,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 +403,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', () => { 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); + }); +});