Prerelease versioning lets you ship 1.2.0-rc.0, 1.2.0-beta.1, etc. before the stable 1.2.0 — for early adopters, integration testing, or staging risky changes.
Bumpy's model is branch-based: you nominate one or more long-lived branches (e.g. next, beta) in your config as prerelease channels. CI runs the same release workflow on those branches as it does on main — only the version suffix and dist-tag change. When you're ready to ship stable, you merge the channel branch into main and the ordinary stable release flow takes over.
Want a one-off, throwaway preview of a single PR or commit rather than a managed release line? That's a snapshot, not a channel — see Snapshots & PR previews (pkg.pr.new for public packages,
bumpy publish --snapshotfor private ones).
Prerelease versions are never committed to git. On a channel branch, every package.json keeps the last stable version — identical to main. Prerelease versions are computed at publish time and exist only in the npm registry and in git tags.
No pre enter / pre exit commands. No mode files. No version churn in your branches. No hidden state that can poison unrelated merges.
If you're coming from changesets, see Comparison with changesets pre mode at the bottom for a side-by-side.
Git carries inputs (bump files) and stable outputs (versions and
CHANGELOG.md, onmain). Everything prerelease — versions, counters, release notes — is derived on demand from bump files, the registry, and git tags. Bumpy never commits derived state.
This is why there's no prerelease counter to corrupt, no suffix to strip at promotion, no stale index file to mislead you, and why main ↔ channel merges don't conflict on version numbers.
Channels are designed for long-lived release lines — an ongoing next / beta / rc cycle that accumulates changes over days or weeks before promotion to stable. They're worth setting up when you expect to ship multiple prereleases through the same cycle.
For anything short-lived or ephemeral, use a snapshot instead — either pkg.pr.new (public packages) or bumpy publish --snapshot (private packages). Both are covered on the Snapshots & PR previews page.
Rough rule of thumb:
| You want… | Use |
|---|---|
| Preview a single PR for review (public packages) | pkg.pr.new |
| Preview a single PR for review (private packages) | bumpy publish --snapshot |
Per-commit canary from main |
pkg.pr.new / snapshots |
| One-off snapshot from a branch for ad-hoc testing | snapshots |
Ship a 1.2.0-rc.N line for weeks of integration testing |
Bumpy channels (this doc) |
Parallel @next + @beta lines for different audiences |
Bumpy channels (this doc) |
git (versions stay at 1.1.0 throughout) npm registry
┌──────────────────────────────────────┐
feature PR ───►│ next branch │
feature PR ───►│ ├─ release PR merge ────────────────┼──► 1.2.0-rc.0 → @next
feature PR ───►│ └─ release PR merge ────────────────┼──► 1.2.0-rc.1 → @next
└───────────────────┬──────────────────┘
│ merge (no version changes in the diff)
▼
┌──────────────────────────────────────┐
│ main branch │
│ └─ version PR (1.1.0 → 1.2.0) ──────┼──► 1.2.0 → @latest
└──────────────────────────────────────┘
- Branch = channel. The
nextbranch is thenextchannel. Pushing to it produces prerelease publishes on the@nextdist-tag. - Same flow as main. Feature PRs land bump files. A "🐸 Versioned prerelease (next)" PR accumulates the cycle. Merging it triggers a prerelease publish.
- The release PR moves files, not versions. Its diff is bump files moving into
.bumpy/next/. The cycle's target versions appear in the PR title and merge commit message with a wildcard counter (1.2.0-rc.x) — sogit logon the channel reads as a release history — but nothing version-shaped is committed. - Promotion is a merge.
next→maincarries the accumulated bump files forward (and nothing else release-related — versions never diverged). Main's ordinary stable version PR consumes them.
The only channel state is bump file location:
.bumpy/
├── _config.json
├── README.md
├── feature-y.md ← pending — will go into the next prerelease
├── another-feature.md ← pending
└── next/ ← shipped on the "next" channel
├── feature-x.md
└── earlier-fix.md
The general rule: a bump file is pending unless it's in the current context's own channel directory.
- On
next: files at.bumpy/root are pending; files in.bumpy/next/have shipped on this channel. - On
main: files anywhere (root or any channel subdir) are pending for the stable release. - On
beta, after mergingalpha→beta: files in.bumpy/alpha/are pending-for-beta — they shipped on alpha but not here. Beta's release PR moves them into.bumpy/beta/. This is how channel graduation (alpha → beta → stable) works with no extra machinery.
At any time, ls .bumpy/ tells you exactly where everything stands. No frontmatter flags, no committed mode files, no counters.
Add a channels block to .bumpy/_config.json:
Multiple channels can coexist:
{
"channels": {
"next": { "branch": "next", "preid": "rc", "tag": "next" },
"beta": { "branch": "beta", "preid": "beta", "tag": "beta" },
"alpha": { "branch": "alpha", "preid": "alpha", "tag": "alpha" },
},
}Semver orders prerelease identifiers lexically, so
alpha<beta<rcfor the same target version — graduated channels sort correctly by maturity out of the box.
git checkout -b next
git push -u origin nextIn .github/workflows/bumpy-release.yml, add the channel branches to the push trigger:
on:
push:
branches: [main, next] # add channel branches hereThat's the only workflow change. bumpy ci release reads the current branch, looks up the channel in _config.json, and behaves accordingly.
If your publish job runs in a GitHub Environment with deployment branch restrictions (the recommended hardening restricts it to
main), add each channel branch to the environment's allowed deployment branches (repo Settings → Environments → publish → Deployment branches). Otherwise the publish job can't run from the channel branch — with npm trusted publishing this means OIDC token requests are rejected and channel publishes fail.
Make sure the checkout step uses
fetch-depth: 0(the release workflow already requires this) — the channel publish trigger diffs the triggering push to detect release PR merges.
The PR check needs no changes for channels —
bumpy ci checkdetects the PR's base branch automatically, however you've wired it up (a step in your normalpull_requestworkflow, or the fork-PR comment setup).
PR authors do nothing different. They:
- Branch off
next(instead ofmain) - Make their change
- Run
bumpy addto create a bump file (always lands at.bumpy/feature-x.md, never directly in a channel subdir) - Open a PR targeting
next
Bump files don't carry channel metadata. The branch they land on determines the channel; their location tracks whether they've shipped.
Reviewing a feature PR and want to install it before merge? That's a job for a one-off preview (pkg.pr.new or a snapshot), not a channel publish. Channels only kick in once a PR has merged into the channel branch.
When a feature PR merges to next:
bumpy ci releaseruns on thenextpush.- It sees a pending bump file and creates (or updates) a release PR — titled something like "🐸 Versioned prerelease (next): 1.2.0-rc.4", targeting
next, on the branchbumpy/version-packages-next. - The PR's diff is only file moves:
.bumpy/feature-x.md→.bumpy/next/feature-x.md. The target versions (with a wildcard counter, e.g.1.2.0-rc.x) live in the PR title and body, and land in git history via the merge commit message.
When a maintainer merges that PR:
bumpy ci releaseruns again onnext, sees newly-shipped files in.bumpy/next/, computes the prerelease versions fresh (see Publish mechanics below), and publishes the full cycle to the@nextdist-tag.- Git tags (
[email protected]) are pushed; a GitHub release is created (marked as prerelease) for each package, with notes built from the cycle's bump files that touched it.
# A consumer testing the prerelease:
npm install my-package@next # gets 1.2.0-rc.0The PR title shows targets, not counters. The title and body display each version as
1.2.0-rc.x— the target comes from committed bump files (deterministic), while the.xcounter is assigned from the registry at publish time. This is why the title can never be out of sync with what publishes: everything it claims is derived from committed state. The exact counter lands in the git tag and GitHub release moments after merge. Bumpy never reads versions back out of PR titles or commit messages.
To skip the manual merge step, set versionPr.automerge: true on the channel — the release PR is created with auto-merge enabled, so each feature merge flows to a prerelease publish once checks pass. The PR (and its file-move commit) still exists, keeping the model intact; you just don't click the button.
When a new feature lands on next:
- The new bump file appears at
.bumpy/feature-y.md(root). Previously-shipped.bumpy/next/feature-x.mdstays put. bumpy ci releaseopens/updates the release PR, which movesfeature-y.mdinto.bumpy/next/.- Merge → publish computes
1.2.0-rc.1and republishes the cycle. Each package'src.1GitHub release carries notes from the cycle's bump files that touched it (feature-y.mdshows up on the packages it changed).
If a feature merges immediately after a release PR merges, both halves happen in one run: bumpy publishes the rc for the already-moved files and opens the next release PR for the new pending file. The two actions are independent.
When the prerelease has been tested and you're ready to ship the real 1.2.0:
- Merge
next→main(regular PR — review it like any other). The diff contains your features and the bump files in.bumpy/next/— and zero version changes, because versions never diverged. bumpy ci releaseruns onmainand follows its completely ordinary flow: it sees pending bump files (everything in.bumpy/next/counts as pending onmain) and opens the standard "🐸 Versioned release" PR, which:- Bumps versions stable-to-stable (
1.1.0→1.2.0— there's no suffix to strip) - Consumes all bump files from
.bumpy/next/(and any pending root files) - Writes a single consolidated
## 1.2.0entry toCHANGELOG.mdwith every change from the cycle - Deletes
.bumpy/next/
- Bumps versions stable-to-stable (
- Merge that PR → bumpy publishes
1.2.0to@latest, tagsv1.2.0, and creates a stable GitHub release.
If the channel had an open release PR at promotion time (offering one more rc), it's now obsolete — bumpy closes it automatically with an explanatory comment when it creates the stable version PR. A fresh release PR appears if new work lands on the channel branch. The same applies to a source channel's release PR after a graduation merge (e.g. alpha → beta).
There is no special promotion mode. Promotion is literally "the bump files arrive on main and the stable flow eats them."
The final stable
CHANGELOG.mdentry includes every change from the prerelease cycle — consumers of@latestsee the full picture, not just the last rc's delta. Individual rc release notes remain available on the GitHub releases page.
After promotion, the cycle is over (no pending files, no .bumpy/next/ on main). For the channel branch:
- Delete and recreate (recommended). Delete
next, recreate it frommainwhen the next cycle starts. The channel config doesn't require the branch to exist between cycles. - Force-reset and reuse.
git reset --hard main && git push --force-with-lease. Only do this if no feature PRs currently targetnext(they'd be left with garbage diffs), and note that branch protection on long-lived branches often forbids force-pushes — which is why delete-and-recreate is the default recommendation.
Sometimes a cycle gets shelved without shipping stable. Force-reset or delete the branch — that's it.
Because versions are never committed and counters come from the registry, an abandoned cycle leaves nothing behind to clean up and nothing that can collide later: the published 1.2.0-rc.N versions and their tags simply remain as history, and any future cycle targeting 1.2.0 resumes counting above them. There is no bumpy channel reset command because there is no state to reset.
How bumpy publish (and the publish half of bumpy ci release) works on a channel, with no committed versions to read:
Target — computed from the cycle's bump files. The cycle = all bump files at root + in .bumpy/<channel>/, run through the normal propagation phases. This yields each package's target stable version (e.g. core → 1.2.0, plugin → 1.0.1).
Counter — derived from the registry: for each package, find the highest published -<preid>.N for its target version; the next publish is N+1 (or .0 if none exists). This makes counters immune to branch resets, abandoned cycles, and anything else that would corrupt committed state.
Trigger — in CI, publish fires when the triggering push added files to .bumpy/<channel>/ (the push event's before..after range, falling back to the last commit). That's exactly what merging a release PR does; an ordinary feature merge never touches the channel dir, so it never causes a publish. This requires git history in the checkout — use fetch-depth: 0, which the release workflow needs anyway. Running bumpy publish manually on the channel branch always publishes the cycle (manual = explicit intent).
Idempotency & resume — re-running on the same commit is a no-op: npm records the publishing commit (gitHead) in each version's metadata, so bumpy can tell "already published from this exact SHA — skip" apart from "needs the next counter." (Packages publishing outside npm — custom commands, skipNpmPublish — use their git tags for the same check.) If a publish fails partway, re-running resumes it package by package; bumpy publish --filter remains available as a manual fallback.
Order of operations — publish packages topologically, then push tags, then create the GitHub release. Tags are the completion marker, so they go up only after the registry is fully consistent.
Where versions get written — into the published artifacts, at publish time, using the same machinery that already resolves workspace: protocols. In the default pack mode the rewrite happens in the packing step; in in-place mode bumpy transiently writes computed versions to the working tree before running build/publish lifecycle scripts, then restores.
If your build bakes in the version (reading
package.jsoninto a banner,__VERSION__replacement, etc.), the rewrite must happen before your build runs — usein-placemode or build inside the publish lifecycle. Otherwise your prerelease artifacts would report the last stable version at runtime. The tarball'spackage.jsonis always correct either way.
Channel branches never write CHANGELOG.md. Three reasons: the consolidated entry at promotion would supersede it anyway; it would be a merge-conflict magnet on every main → channel sync; and rewriting it at promotion is exactly how changesets' pre-exit ends up lossy.
Instead:
- The cycle's changelog is the bump files themselves, sitting readable in
.bumpy/next/. - Per-rc notes go to GitHub releases (marked prerelease), built per package from the cycle's bump files that touched it.
bumpy statuson a channel renders the would-be changelog for the whole cycle on demand — the answer to "what has shipped on@nextso far," including for teams not on GitHub.- The stable
CHANGELOG.mdentry is written once, at promotion, onmain— lossless, because it's built from the bump files rather than from intermediate changelogs.
There is deliberately no versions index file or per-channel README either — any committed reflection of registry state can go stale and mislead (failed publishes, retargets, resets). The release PR title and merge commit message show only what's derivable from committed state (targets with a wildcard counter, 1.2.0-rc.x); live truth is always bumpy status, the dist-tags, and the git tags.
Patches can flow to main independently while a prerelease is in flight on next:
main: 1.1.0 ──► 1.1.1 (hotfix)
╲ merge main → next
next: 1.2.0-rc.0 ──► 1.2.0-rc.1 (includes the hotfix)
After the hotfix lands on main, merge main → next to pick it up. Because versions are identical on both branches, these syncs don't conflict on package.json version lines or CHANGELOG.md — the perennial pain of long-lived release branches doesn't apply.
Retargeting is automatic. If main ships a release that overtakes the cycle's target (e.g. main ships 1.2.0 while the channel was publishing 1.2.0-rc.N), the next channel publish recomputes naturally: the workspace base is now 1.2.0, the bump files yield a target of 1.3.0, and the registry floor starts the counter at 1.3.0-rc.0. There's no committed state to fix up. (Ship an rc promptly after a retarget so the @next dist-tag doesn't linger below @latest.)
Known wart — a hotfix that rides both trains. If a bump file is authored on main, synced into next before main ships it, and then ships stable on main, the later main → next sync surfaces a rename/delete conflict on that file (deleted at root on main, moved into .bumpy/next/ on the channel). Resolve by deleting it — the change already shipped stable and is in main's changelog; keeping the channel copy would duplicate it in the consolidated entry at promotion. Authoring hotfixes on main and syncing promptly keeps this rare.
Prereleases interact with semver differently from stable releases in one key way:
A range like
"@org/core": "^1.0.0"continues to satisfy through1.1.0,1.99.0, etc. But it doesn't satisfy any prerelease —^1.0.0matches1.5.0but not1.5.0-rc.0. Semver only resolves a prerelease against a range when major.minor.patch matches exactly.
This means every prerelease of an upstream package breaks every dependent's range. A "tighter" prerelease that left dependents on stable would force users to add overrides / resolutions by hand to install anything from the cycle. So channel cascades are wide by design — the cycle is exactly the set of packages a tester can mix and match via @next.
Because nothing is committed incrementally, every publish recomputes the entire cycle from scratch — all bump files, full propagation (Phase A/B/C run unchanged, with proportional bump levels: patch for dependencies, match-the-trigger for peerDependencies — avoiding both the changesets v2 force-major problem (#960) and the v3 always-patch assumption). Every in-cycle package gets a fresh counter and republishes together, every rc.
This lockstep isn't a special rule — it falls out of "there is no incremental state." And it's what makes the coherence guarantee real:
Within the cycle, every inter-cycle dependency is exact-pinned in the published artifacts:
If
@org/[email protected]is in the same cycle as@org/[email protected], the published@org/[email protected]has"@org/core": "1.2.0-rc.2"— not"^1.2.0-rc.2".
Because the whole cycle republishes together, the @next dist-tags always point at one coherent, exact-pinned set: any combination installed via @next works against exactly the versions it was published with, and peer dependencies can never half-resolve across two different rcs. Channel-internal consistency is built into the artifacts, not relied on at install time.
Dependencies pointing outside the cycle (e.g., to a package excluded via ignore) keep their stable ranges.
workspace:^ / workspace:* on an in-cycle dep resolves to the exact prerelease version at publish time. On an out-of-cycle dep, it resolves normally (the stable range bumpy would produce on main).
If the default — "every dependent comes along" — is too wide for your monorepo, the standard bumpy controls bound the cycle:
ignore/includein_config.jsonconstrain which packages are managed at all- Per-package
managed: falseexcludes individual packages linked/fixed/cascadeTodeclarations don't narrow the cascade, but they make wider propagation more predictable when you want it
There's no channel-specific opt-out of the cascade — disabling it would produce stranded prereleases that consumers couldn't actually install together.
| Command | On main |
On next (channel branch) |
|---|---|---|
bumpy status |
shows planned stable versions | shows the cycle: pending vs shipped files, computed -rc.N (registry-derived; rc.? if offline) |
bumpy version |
bumps versions, consumes bump files, writes changelog | moves pending files into .bumpy/next/ — writes no versions, no changelog |
bumpy publish |
publishes to @latest |
computes prerelease versions, rewrites artifacts, publishes the cycle to @next, pushes tags |
bumpy ci release |
version-PR / publish on main | release-PR (file moves) / publish on next |
bumpy ci check |
(PR-level, unchanged) | (PR-level, unchanged) |
bumpy check |
compares to baseBranch |
skipped on channel/release-PR branches; use --base next on feature branches targeting a channel |
You can override the inferred channel with --channel <name> for local testing:
bumpy status --channel next # preview what next would publish
bumpy version --channel next # locally move pending files into .bumpy/next/The override is mainly for debugging; CI should rely on branch detection.
Note that bumpy status on a channel needs registry access to show exact counters. Offline, it shows the computed target with a placeholder counter (1.2.0-rc.?).
If bumpy ci release runs on a branch that isn't baseBranch and isn't in channels, it exits with a clear error rather than guessing. This prevents accidental publishes from feature branches.
If you want a workflow that runs on every branch (e.g., for CI plan output), keep bumpy ci plan outside the channel guard — plan is read-only.
The -rc.N counter is derived from the registry, never from committed state:
- If no
1.2.0-rc.*has ever been published for a package, the next version is1.2.0-rc.0. - If
1.2.0-rc.3is the highest published, the next is1.2.0-rc.4— regardless of what any branch looks like. - If a new bump file raises the target (e.g., a
majorlands when the cycle was targeting a minor), the target moves to2.0.0and the counter naturally restarts at2.0.0-rc.0(nothing published there yet). Previously-shipped files in.bumpy/next/carry forward and consolidate at the new target on promotion. - Abandoned cycles, force-resets, and re-runs can't cause version collisions — the registry floor always counts above anything already published.
Counters are per-package. A package that joins the cycle late starts at its own .0 while earlier members are at .3; from then on, lockstep republishing keeps them moving together.
This avoids the changesets #381 problem (counters requiring committed state) by construction rather than by careful bookkeeping.
{
"channels": {
"<name>": {
"branch": "next", // required — branch that triggers this channel
"preid": "rc", // version suffix, e.g. -rc.0
"tag": "next", // npm dist-tag for publish
"versionPr": {
// optional — override the channel's release PR
"title": "🐸 Versioned prerelease (next)",
"branch": "bumpy/version-packages-next",
"automerge": false, // true = enable auto-merge on the release PR
},
},
},
}Defaults applied when a field is omitted:
preid— defaults to the channel name (e.g.,next→1.2.0-next.0).tag— defaults to the channel name (so@next).versionPr.title— defaults to<base-title> (<channel>). A version summary is appended:[email protected]for a single package, or a package count for several. The.xcounter is assigned from the registry at publish time, so the title only ever claims what's derivable from committed state.versionPr.branch— defaults to<base-branch>-<channel>(e.g.,bumpy/version-packages-next).versionPr.automerge— defaults tofalse.
The directory used to hold shipped bump files matches the channel name: .bumpy/<name>/. Channel names that would collide with reserved .bumpy/ entries (anything starting with _, README.md) are rejected.
preidis optional in the schema (not just defaulted) to leave room for future stable channels — maintenance branches like1.xthat publish stable versions to a non-latestdist-tag (changesets#1235). Not part of the initial feature, but the config shape won't need a breaking change to add it.
| changesets pre mode | bumpy channels | |
|---|---|---|
| Entering | changeset pre enter beta writes .changeset/pre.json |
Push to the channel branch |
| Exiting | changeset pre exit + version + publish + delete pre.json |
Merge channel branch → main; the ordinary stable flow consumes the cycle |
| State file | .changeset/pre.json committed to repo (v3 moved versioned changesets to a .changeset/pre/ folder, but the committed mode file remains) |
None — bump file location in .bumpy/ is the only state |
| Prerelease versions in git | Committed to every package.json on every prerelease |
Never — registry and tags only; package.json stays at the last stable version |
| Wrong-branch hazard | Merging while pre mode is active accidentally turns stable releases into prereleases (#239) | Impossible — channel state lives in the branch and the file layout, not in a global mode |
| Dist-tag control | Locked to mode tag, --tag is rejected (#786) |
Per-channel tag config, independent of suffix |
| Dependent force-bumping | Hardcoded: major on peer deps in v2 (#960), always patch in v3 | Cascades at proportional levels; full cycle republishes each rc with exact-pinned inter-cycle deps — always a coherent set |
| Counter | Requires committed package.json increments (#381) |
Derived from the registry (max published + 1); immune to resets and abandoned cycles |
| Exit re-bumps everything | Yes (#729) | No — promotion is an ordinary stable bump; there's no suffix to strip because none was committed |
| First publish during pre mode | Goes to @latest on npm (the registry auto-assigns it; v3 only fixes registries that don't) |
Always goes to channel's dist-tag |
| Stable changelog after prereleases | Lossy — only the pre exit step's diff |
Lossless — consolidated entry built from every bump file in the cycle |
These are intentionally out of scope for the initial channel feature. If any of these is a blocker for you, please open an issue.
- Ephemeral / preview / canary releases — covered on the Snapshots & PR previews page (snapshot releases for private packages, pkg.pr.new for public). Bumpy channels stay scoped to managed long-running release lines. See When to use channels — and when not to above.
- Stable (maintenance) channels — long-lived branches like
1.xpublishing stable versions to a non-latestdist-tag. Future work; the config schema already leaves room (see note above). - Prerelease changelog in the published tarball — injecting the rendered cycle changelog into prerelease artifacts at publish time (derived content goes in the artifact, never in git). Possible later nice-to-have.
- Per-bump-file channel routing — declaring
channel: betainside a bump file's frontmatter. Not planned; channels stay branch-derived to keep the mental model simple.
{ "baseBranch": "main", "channels": { "next": { "branch": "next", "preid": "rc", // version suffix: 1.2.0-rc.0 "tag": "next", // npm dist-tag: published to @next }, }, }