Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .bumpy/staged-finalize.md
Original file line number Diff line number Diff line change
@@ -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.
60 changes: 60 additions & 0 deletions .github/workflows/finalize.yaml
Original file line number Diff line number Diff line change
@@ -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 }}
35 changes: 35 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,41 @@ With `--snapshot <name>`, 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 <stage-id>` to tell it.

```bash
npm stage reject <stage-id> # 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 <tag>` 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.
Expand Down
6 changes: 6 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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 <stage-id>` β€” the 2FA gate), then run **`bumpy publish finalize`** to update the GitHub release (flip it to βœ… published, link the live package). You can run finalize by hand or on a schedule β€” see [Staged publishing (finalizing a release)](github-actions.md#staged-publishing-finalizing-a-release) for the full lifecycle and both setups.

If you instead **reject** a stage on npm (`npm stage reject <stage-id>`), run **`bumpy publish reopen <name@version>`** β€” bumpy can't detect a rejection on its own, and this reopens the release so the next publish re-stages the fixed build. To abandon the version entirely, don't reopen; the next version bump supersedes the draft.

### Version PR config

The `versionPr` object customizes the PR that `bumpy ci release` creates:
Expand Down
124 changes: 124 additions & 0 deletions docs/github-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <stage-id>
npm stage approve <stage-id> # 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 <stage-id> # 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 <stage-id> # 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 <tag>`, then re-publish. Keeps the release notes/changelog; re-stages the same version.
- **Start clean** β†’ `gh release delete <tag>`, 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 <tag>` (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:
Expand Down
34 changes: 34 additions & 0 deletions packages/bumpy/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name@version>` β€” 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 <name@version>`');
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`.');
Expand Down Expand Up @@ -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 <name>: 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)
Expand Down
Loading