From d85bcad8a2de1aca85afc2730e8304ae973f49f0 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Mon, 8 Jun 2026 23:48:41 -0700 Subject: [PATCH 1/6] docs: add proposed prerelease channels design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Draft design doc for branch-based prerelease support. Models channels as long-lived branches (next, beta, etc.) with bump files tracked by directory location — pending at .bumpy/*, shipped at .bumpy//, consolidated on promotion to main. Documents the comparison with changesets' pre mode and what's deliberately out of scope. Not yet implemented — sharing for user feedback before building. --- docs/prereleases.md | 335 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 335 insertions(+) create mode 100644 docs/prereleases.md diff --git a/docs/prereleases.md b/docs/prereleases.md new file mode 100644 index 0000000..2270c2e --- /dev/null +++ b/docs/prereleases.md @@ -0,0 +1,335 @@ +# Prerelease Channels + +> ⚠️ **Proposed design — not yet implemented.** This document describes the planned prerelease feature. Feedback welcome before we build it. + +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 bumpy automatically strips the suffix. + +No `pre enter` / `pre exit` commands. No mode files in your repo. No hidden state that can poison unrelated merges. + +> If you're coming from changesets, see [Comparison with changesets pre mode](#comparison-with-changesets-pre-mode) at the bottom for a side-by-side. + +--- + +## Mental model + +``` + ┌─────────────────────────────────────────────┐ + │ │ + feature PR ───►│ next branch ──► 1.2.0-rc.0 ──► 1.2.0-rc.1 │ ── merge ──► + feature PR ───►│ │ │ + feature PR ───►│ │ │ + └─────────────────────────────────────────────┘ │ + ▼ + ┌──────────────────────┐ + │ main branch │ + │ ──► 1.2.0 │ + └──────────────────────┘ +``` + +- **Branch = channel.** The `next` branch is the `next` channel. Pushing to it produces prerelease versions on the `@next` dist-tag. +- **Same flow as main.** Feature PRs land bump files. A "🐸 Versioned release (next)" PR accumulates the planned bump. Merging it triggers a prerelease publish. +- **Promotion is a merge.** `next` → `main` carries the prerelease versions and accumulated bump files forward; bumpy strips the suffix on main, consumes the bump files, and publishes stable. + +--- + +## How shipped vs pending bump files are tracked + +A bump file's location tells you where it stands in the release lifecycle: + +``` +.bumpy/ +├── _config.json +├── README.md +├── feature-y.md ← pending — will trigger the next prerelease +├── another-feature.md ← pending +└── next/ ← shipped on the "next" channel + ├── feature-x.md + └── earlier-fix.md +``` + +- **`.bumpy/*.md`** — pending. Has not yet been included in any release. +- **`.bumpy//*.md`** — shipped on ``, awaiting promotion to stable. The bump file itself is not modified; only its location changes. + +On promotion (merge to main + main's version PR), files in both root and channel subdirs are consumed into a single consolidated stable changelog entry, then deleted. + +This means at any time you can `ls .bumpy/` to see exactly what's pending vs shipped. No frontmatter flags, no committed mode files, no `git tag` archaeology. + +--- + +## Setup + +### 1. Declare the channel in config + +Add a `channels` block to `.bumpy/_config.json`: + +```jsonc +{ + "baseBranch": "main", + "channels": { + "next": { + "branch": "next", + "preid": "rc", // version suffix: 1.2.0-rc.0 + "tag": "next", // npm dist-tag: published to @next + }, + }, +} +``` + +Multiple channels can coexist: + +```jsonc +{ + "channels": { + "next": { "branch": "next", "preid": "rc", "tag": "next" }, + "beta": { "branch": "beta", "preid": "beta", "tag": "beta" }, + "alpha": { "branch": "alpha", "preid": "alpha", "tag": "alpha" }, + }, +} +``` + +### 2. Create the branch + +```bash +git checkout -b next +git push -u origin next +``` + +### 3. Add the branch to your release workflow + +In `.github/workflows/bumpy-release.yml`, add the channel branches to the `push` trigger: + +```yaml +on: + push: + branches: [main, next] # add channel branches here +``` + +That's the only workflow change. `bumpy ci release` reads the current branch, looks up the channel in `_config.json`, and behaves accordingly. + +> The PR check workflow (`bumpy-check.yaml`) needs no changes — it runs on `pull_request_target` and handles any base branch. + +--- + +## Day-to-day workflow + +### Authoring a prerelease feature + +PR authors do nothing different. They: + +1. Branch off `next` (instead of `main`) +2. Make their change +3. Run `bumpy add` to create a bump file (always lands at `.bumpy/feature-x.md`, never directly in a channel subdir) +4. 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. + +### Versioning a prerelease + +When a feature PR merges to `next`: + +1. `bumpy ci release` runs on the `next` push. +2. It sees a bump file at `.bumpy/feature-x.md` (pending — not yet in `.bumpy/next/`) and creates (or updates) a **"🐸 Versioned release (next)"** PR — targeting `next`, on the branch `bumpy/version-packages-next`. +3. The PR's diff includes: + - `package.json` versions bumped with the `-rc.N` suffix + - `.bumpy/feature-x.md` **moved** to `.bumpy/next/feature-x.md` + +When a maintainer merges that PR: + +4. `bumpy ci release` runs again on `next`, detects no pending bump files (everything is in `.bumpy/next/`), sees unpublished packages at `1.2.0-rc.0`, and publishes them to the `@next` dist-tag. +5. Git tags `v1.2.0-rc.0` are pushed; a GitHub release is created (marked as prerelease) with notes drawn from the bump files that _just moved_ into `.bumpy/next/`. + +```bash +# A consumer testing the prerelease: +npm install my-package@next # gets 1.2.0-rc.0 +``` + +### A second prerelease + +When a new feature lands on `next`: + +- The new bump file appears at `.bumpy/feature-y.md` (root). Previously-shipped `.bumpy/next/feature-x.md` stays put. +- `bumpy ci release` sees the pending file → opens the version PR. +- The PR bumps `1.2.0-rc.0` → `1.2.0-rc.1`, moves `feature-y.md` into `.bumpy/next/`. +- Merge → publish → GitHub release for `1.2.0-rc.1` includes only `feature-y.md` (the just-moved file). + +### Promotion to stable + +When the prerelease has been tested and you're ready to ship the real `1.2.0`: + +1. **Merge `next` → `main`** (regular PR — review it like any other). +2. `main` now has package.json versions like `1.2.0-rc.5` _and_ all the accumulated bump files in `.bumpy/next/`. +3. `bumpy ci release` runs on `main`. It sees: + - Prerelease versions in `package.json` + - Bump files in `.bumpy/next/` (from the channel) + - No pending files at `.bumpy/` root +4. It opens a **"🐸 Versioned release"** PR that: + - Strips the prerelease suffix (`1.2.0-rc.5` → `1.2.0`) + - Consumes **all** bump files from `.bumpy/next/` + - Writes a single consolidated `## 1.2.0` entry to `CHANGELOG.md` with every change from the cycle + - Deletes `.bumpy/next/` (and any pending files at root, if any) +5. Merge that PR → bumpy publishes `1.2.0` to `@latest`, tags `v1.2.0`, and creates a stable GitHub release. + +> The final stable `CHANGELOG.md` entry includes every change from the prerelease cycle — consumers of `@latest` see the full picture, not just the `rc.5 → 1.2.0` step. Individual rc release notes remain available on the GitHub releases page. + +### Continuing after promotion + +After promotion, `next` is empty (no pending files, no `.bumpy/next/` subdir). You can either: + +- **Reset and reuse it.** `git reset --hard main && git push --force-with-lease`. The next feature PR targeting `next` starts a new cycle (`1.3.0-rc.0`, etc.). +- **Delete and recreate later.** If your team only opens a prerelease cycle occasionally, delete the branch and recreate it when you need the next one. + +Either is supported — the channel config doesn't require the branch to exist between cycles. + +### Abandoning a prerelease cycle + +Sometimes a prerelease cycle gets shelved without ever shipping stable. To reset: + +- Delete `.bumpy/next/` in a PR to `next`, optionally also resetting package.json versions back to their pre-cycle state. +- Or simply force-reset the branch to a known-good commit. + +There is no `bumpy channel reset` command — the state lives in your branch, so plain git commands handle it. + +--- + +## Hotfixes during a prerelease + +Patches can flow to `main` independently while a prerelease is in flight on `next`: + +``` + main: 1.1.0 ──► 1.1.1 (hotfix) + ╲ + next: 1.2.0-rc.0 ──► 1.2.0-rc.1 (after rebasing main into next) +``` + +After the hotfix lands on `main`, rebase or merge `main` → `next` to pick it up. The next prerelease version will reflect the combined state. + +> If `main` ships a bump that's higher than the prerelease's current target (e.g., `main` ships `1.2.0`, prerelease was targeting `1.2.0-rc.x`), bumpy automatically retargets the prerelease at `1.3.0-rc.0` on the next merge — the prerelease never accidentally publishes a version lower than what's already on `@latest`. + +--- + +## Dependency propagation in prerelease channels + +By default, **dependency cascade is suppressed** on prerelease channels. + +Background: prerelease versions like `1.2.0-rc.0` don't satisfy semver ranges like `^1.1.0`, so naive propagation would force-bump every dependent in your monorepo on every prerelease — see [changesets#960](https://github.com/changesets/changesets/issues/960). Bumpy avoids this by default. Dependent packages keep their stable versions in the prerelease workspace; the cascade applies normally when you promote to stable. + +If you genuinely want prerelease propagation (e.g., you're shipping prereleases of an entire dependency tree together), opt in per-channel: + +```jsonc +{ + "channels": { + "next": { + "branch": "next", + "preid": "rc", + "tag": "next", + "propagation": "stable", // "suppress" (default) | "stable" + }, + }, +} +``` + +`fixed` and `linked` groups still bump together as they normally would — group cohesion is preserved across channels. + +--- + +## CLI behavior on a channel branch + +The commands behave the same as on `main`, with channel-derived suffixes and tags: + +| Command | On `main` | On `next` (channel branch) | +| ------------------ | ---------------------------------------- | --------------------------------------------------------- | +| `bumpy status` | shows planned stable versions | shows planned `-rc.N` versions (pending files only) | +| `bumpy version` | bumps to stable, consumes all bump files | bumps to `-rc.N`, moves pending files into `.bumpy/next/` | +| `bumpy publish` | publishes to `@latest` | publishes to `@next` | +| `bumpy ci release` | version-PR / publish on main | version-PR / publish on `next` | +| `bumpy ci check` | (PR-level, unchanged) | (PR-level, unchanged) | +| `bumpy check` | compares to `baseBranch` | compares to the channel branch | + +You can override the inferred channel with `--channel ` for local testing: + +```bash +bumpy status --channel next # preview what next would publish +bumpy version --channel next # locally bump to -rc.N and move pending files +``` + +The override is mainly for debugging; CI should rely on branch detection. + +--- + +## What if no channel matches? + +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. + +--- + +## Counter behavior + +The `-rc.N` counter is computed from the workspace's current state, not from cumulative metadata: + +- If no package is currently on a prerelease, the next version is `-rc.0`. +- If a package is at `1.2.0-rc.3` and a new pending bump file lands, the next version is `1.2.0-rc.4`. +- If a new pending bump file would raise the _target_ (e.g., a `major` lands when current target was `minor`), the counter resets: `1.2.0-rc.3` → `2.0.0-rc.0`. Previously-shipped files in `.bumpy/next/` carry forward — they'll consolidate at the new target on promotion. + +This matches user intuition (the counter resets when the underlying target moves) and avoids the changesets [#381](https://github.com/changesets/changesets/issues/381) problem where prerelease counters require committed state to function. + +--- + +## Configuration reference + +```jsonc +{ + "channels": { + "": { + "branch": "next", // required — branch that triggers this channel + "preid": "rc", // version suffix, e.g. -rc.0 + "tag": "next", // npm dist-tag for publish + "propagation": "suppress", // optional: "suppress" (default) | "stable" + "versionPr": { + // optional — override the channel's version PR + "title": "🐸 Versioned prerelease (next)", + "branch": "bumpy/version-packages-next", + }, + }, + }, +} +``` + +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 ` ()`. +- `versionPr.branch` — defaults to `-` (e.g., `bumpy/version-packages-next`). + +The directory used to hold shipped bump files matches the channel name: `.bumpy//`. + +--- + +## Comparison with changesets pre mode + +| | 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; bumpy strips suffix and consolidates | +| State file | `.changeset/pre.json` committed to repo | None — file location in `.bumpy/` is the state | +| Wrong-branch hazard | Merging while pre mode is active accidentally turns stable releases into prereleases ([#239](https://github.com/changesets/changesets/issues/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](https://github.com/changesets/changesets/issues/786)) | Per-channel `tag` config, independent of suffix | +| Dependent force-bumping | Always on, can't be disabled ([#960](https://github.com/changesets/changesets/issues/960)) | Suppressed by default, opt-in via `propagation: "stable"` | +| Counter | Requires committed `package.json` increments ([#381](https://github.com/changesets/changesets/issues/381)) | Derived from current state; resets cleanly when target moves | +| Exit re-bumps everything | Yes ([#729](https://github.com/changesets/changesets/issues/729)) | No — promotion strips suffixes and consumes pre-shipped bump files into one consolidated entry | +| First publish during pre mode | Silently goes to `@latest` | 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 | + +--- + +## What's not (yet) supported + +These are intentionally out of scope for the initial channel feature. If any of these is a blocker for you, please open an issue. + +- **Per-PR snapshot previews** (`0.0.0-pr-123-` for a single PR) — planned as a separate `bumpy publish --snapshot` flag, not via channels. +- **Workflow-dispatch one-off prereleases** — planned as a complement to channels, for teams that want occasional prereleases without a dedicated branch. +- **Per-bump-file channel routing** — declaring `channel: beta` inside a bump file's frontmatter. Not planned; channels stay branch-derived to keep the mental model simple. From 119fb7be192ab2f5c53f8bf5b5c8dce612076917 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Tue, 9 Jun 2026 00:00:48 -0700 Subject: [PATCH 2/6] docs: recommend pkg.pr.new for per-PR previews Channels are for long-lived release lines (next/beta/rc), not per-PR previews. Add a "when to use this" section and point users at pkg.pr.new for ephemeral PR packages. --- docs/prereleases.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/prereleases.md b/docs/prereleases.md index 2270c2e..438cdf3 100644 --- a/docs/prereleases.md +++ b/docs/prereleases.md @@ -10,6 +10,22 @@ No `pre enter` / `pre exit` commands. No mode files in your repo. No hidden stat > If you're coming from changesets, see [Comparison with changesets pre mode](#comparison-with-changesets-pre-mode) at the bottom for a side-by-side. +## When to use channels — and when not to + +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 carry per-cycle state in your branch and your `.bumpy//` directory, and they're worth setting up when you expect to ship multiple prereleases through the same cycle. + +**For per-PR preview releases, use [pkg.pr.new](https://pkg.pr.new) instead.** + +pkg.pr.new publishes an ephemeral package from any open PR, gives you an install URL pinned to the PR's commit, and disappears when the PR closes. It's purpose-built for "let me try this PR before merging" workflows — no version planning, no branch discipline, no consumed bump files. Bumpy channels would be the wrong tool for that job: you'd be polluting your channel branch with throwaway state for every PR. + +Rough rule of thumb: + +| You want… | Use | +| --------------------------------------------------------- | ------------------------------------- | +| Preview a single PR for review | [pkg.pr.new](https://pkg.pr.new) | +| Ship a `1.2.0-rc.N` line for weeks of integration testing | Bumpy channels (this doc) | +| One-off canary from `main` | (Planned: `bumpy publish --snapshot`) | + --- ## Mental model @@ -330,6 +346,7 @@ The directory used to hold shipped bump files matches the channel name: `.bumpy/ These are intentionally out of scope for the initial channel feature. If any of these is a blocker for you, please open an issue. -- **Per-PR snapshot previews** (`0.0.0-pr-123-` for a single PR) — planned as a separate `bumpy publish --snapshot` flag, not via channels. +- **Per-PR preview releases** — use [pkg.pr.new](https://pkg.pr.new) instead. It's purpose-built for ephemeral per-PR packages and pairs well with bumpy. See [When to use channels — and when not to](#when-to-use-channels--and-when-not-to) above. +- **One-off snapshot publishes from `main`** (`0.0.0-snapshot-`) — planned as a separate `bumpy publish --snapshot` flag, not via channels. - **Workflow-dispatch one-off prereleases** — planned as a complement to channels, for teams that want occasional prereleases without a dedicated branch. - **Per-bump-file channel routing** — declaring `channel: beta` inside a bump file's frontmatter. Not planned; channels stay branch-derived to keep the mental model simple. From 2d0cde0ad3064afb62a59deed2667e9a38c51db0 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Tue, 9 Jun 2026 13:17:50 -0700 Subject: [PATCH 3/6] docs: drop auto-cascade in prerelease channels; require explicit coordination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite the dependency section around exact-pin-within-cycle, with cycle membership coming from explicit declarations (bump files, linked/fixed, cascadeTo) rather than automatic propagation. Drop the propagation config knob — matches bumpy's existing "explicit > implicit" stance for stable releases. Add a "Coordinating multi-package prereleases" section with the stranded-prerelease failure mode and the four ways to fix it. Update the changesets comparison table to reflect the new approach. --- docs/prereleases.md | 88 +++++++++++++++++++++++++++++---------------- 1 file changed, 58 insertions(+), 30 deletions(-) diff --git a/docs/prereleases.md b/docs/prereleases.md index 438cdf3..594f352 100644 --- a/docs/prereleases.md +++ b/docs/prereleases.md @@ -225,28 +225,57 @@ After the hotfix lands on `main`, rebase or merge `main` → `next` to pick it u --- -## Dependency propagation in prerelease channels +## Dependency handling in prerelease channels -By default, **dependency cascade is suppressed** on prerelease channels. +Bumpy never automatically cascades a prerelease through your dependency graph. You decide which packages belong in each cycle through explicit declarations — bump files, `linked` / `fixed` groups, or `cascadeTo` rules. Whatever ends up in the cycle is then exact-pinned together. -Background: prerelease versions like `1.2.0-rc.0` don't satisfy semver ranges like `^1.1.0`, so naive propagation would force-bump every dependent in your monorepo on every prerelease — see [changesets#960](https://github.com/changesets/changesets/issues/960). Bumpy avoids this by default. Dependent packages keep their stable versions in the prerelease workspace; the cascade applies normally when you promote to stable. +### The exact-pin rule -If you genuinely want prerelease propagation (e.g., you're shipping prereleases of an entire dependency tree together), opt in per-channel: +Within a prerelease cycle, any inter-cycle dependency is **exact-pinned** at publish time: -```jsonc -{ - "channels": { - "next": { - "branch": "next", - "preid": "rc", - "tag": "next", - "propagation": "stable", // "suppress" (default) | "stable" - }, - }, -} -``` +> If `@org/plugin@1.1.0-rc.0` is in the same cycle as `@org/core@2.0.0-rc.0`, the published `@org/plugin@1.1.0-rc.0` has `"@org/core": "2.0.0-rc.0"` — not `"^2.0.0-rc.0"`. + +This guarantees that any package from the cycle, installed via `@next`, works with the other packages it was published against. Channel-internal consistency is built into the artifact, not relied on at install time. + +Dependencies pointing **outside** the cycle keep their stable ranges. Their existing `@latest` install continues to work; nothing in their published `package.json` points at a prerelease. + +### `workspace:` protocol resolution + +`workspace:^` / `workspace:*` on an in-cycle dep resolves to the exact prerelease version. On an out-of-cycle dep, it resolves normally (the stable range bumpy would produce on `main`). + +### Why no automatic cascade + +Changesets force-bumps every dependent whose range gets broken by a prerelease — including dependents that didn't otherwise need to change. This is the source of many of its prerelease pain points ([#960](https://github.com/changesets/changesets/issues/960), [#1228](https://github.com/changesets/changesets/issues/1228), [#1287](https://github.com/changesets/changesets/issues/1287)). + +Bumpy already takes the "explicit propagation" stance for stable releases (`updateInternalDependencies: "out-of-range"` is the default). Channels apply the same principle: bumpy does what you asked it to do, nothing more. The trade-off — that you can ship a stranded prerelease if you forget to coordinate — is addressed by the next section. + +--- + +## Coordinating multi-package prereleases + +If you prerelease an upstream package without bringing its dependents along, those dependents won't be able to consume the prerelease. + +Concrete failure mode: + +- `@org/core@1.0.0` and `@org/plugin@1.0.0` (both on `@latest`) +- `@org/plugin`'s package.json: `"@org/core": "^1.0.0"` +- You author one bump file: `@org/core` → major +- Cycle ships `@org/core@2.0.0-rc.0` to `@next`. `@org/plugin` stays at `1.0.0`. + +A tester running `npm install @org/core@next @org/plugin` hits a peer dep mismatch (or npm hoists two copies of core). The prerelease is "stranded" — usable on its own but not in combination with the rest of the ecosystem. + +To make the prerelease usable, declare the relationship so `@org/plugin` joins the cycle. Pick whichever fits your situation: + +| Declaration | When to use | +| ----------------------------------------------- | ------------------------------------------------------- | +| **Multi-package bump file** | Ad-hoc — this specific PR ships both packages together | +| **`linked` group** in config | Two packages should always share the highest bump level | +| **`fixed` group** in config | Two packages should always share an exact version | +| **`cascadeTo: ["@org/plugin"]`** on `@org/core` | Any change to core should always bump plugin | + +If you genuinely want a stranded prerelease (the package has no in-monorepo dependents that need it), no declaration is needed — bumpy will ship it as written. -`fixed` and `linked` groups still bump together as they normally would — group cohesion is preserved across channels. +> See [docs/version-propagation.md](./version-propagation.md) for the full propagation model and how `linked` / `fixed` / `cascadeTo` interact. --- @@ -303,7 +332,6 @@ This matches user intuition (the counter resets when the underlying target moves "branch": "next", // required — branch that triggers this channel "preid": "rc", // version suffix, e.g. -rc.0 "tag": "next", // npm dist-tag for publish - "propagation": "suppress", // optional: "suppress" (default) | "stable" "versionPr": { // optional — override the channel's version PR "title": "🐸 Versioned prerelease (next)", @@ -327,18 +355,18 @@ The directory used to hold shipped bump files matches the channel name: `.bumpy/ ## Comparison with changesets pre mode -| | 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; bumpy strips suffix and consolidates | -| State file | `.changeset/pre.json` committed to repo | None — file location in `.bumpy/` is the state | -| Wrong-branch hazard | Merging while pre mode is active accidentally turns stable releases into prereleases ([#239](https://github.com/changesets/changesets/issues/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](https://github.com/changesets/changesets/issues/786)) | Per-channel `tag` config, independent of suffix | -| Dependent force-bumping | Always on, can't be disabled ([#960](https://github.com/changesets/changesets/issues/960)) | Suppressed by default, opt-in via `propagation: "stable"` | -| Counter | Requires committed `package.json` increments ([#381](https://github.com/changesets/changesets/issues/381)) | Derived from current state; resets cleanly when target moves | -| Exit re-bumps everything | Yes ([#729](https://github.com/changesets/changesets/issues/729)) | No — promotion strips suffixes and consumes pre-shipped bump files into one consolidated entry | -| First publish during pre mode | Silently goes to `@latest` | 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 | +| | 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; bumpy strips suffix and consolidates | +| State file | `.changeset/pre.json` committed to repo | None — file location in `.bumpy/` is the state | +| Wrong-branch hazard | Merging while pre mode is active accidentally turns stable releases into prereleases ([#239](https://github.com/changesets/changesets/issues/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](https://github.com/changesets/changesets/issues/786)) | Per-channel `tag` config, independent of suffix | +| Dependent force-bumping | Always on, can't be disabled ([#960](https://github.com/changesets/changesets/issues/960)) | Never automatic — cycle membership is declared (bump files, `linked`/`fixed`, `cascadeTo`); inter-cycle deps are exact-pinned at publish | +| Counter | Requires committed `package.json` increments ([#381](https://github.com/changesets/changesets/issues/381)) | Derived from current state; resets cleanly when target moves | +| Exit re-bumps everything | Yes ([#729](https://github.com/changesets/changesets/issues/729)) | No — promotion strips suffixes and consumes pre-shipped bump files into one consolidated entry | +| First publish during pre mode | Silently goes to `@latest` | 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 | --- From 0e00f60e2d8011177ca6b05177d5176cc87d4003 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Tue, 9 Jun 2026 13:20:52 -0700 Subject: [PATCH 4/6] docs: walk back no-cascade; for prereleases, propagation is required MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-thinking: semver only resolves a prerelease against a range when major.minor.patch matches exactly. That means every prerelease of an upstream breaks every dependent's range — not occasionally, but always. "Suppressing" cascade would just produce prereleases that consumers can't install together without manual overrides. So the right model is: Phase A/B/C run unchanged on channels, producing a wide cascade by nature of how prerelease semver matches. The user complaints behind changesets #960 are about bump-level policy (peer deps jumping to major), not about whether to cascade — and bumpy's proportional rules already address that. Drop the "Coordinating multi-package prereleases" stranded-failure section (no longer applies). Replace with a "Limiting cascade scope" note pointing at the existing ignore/include/managed controls. Update the changesets comparison row to reflect the actual difference. --- docs/prereleases.md | 78 ++++++++++++++++++++------------------------- 1 file changed, 35 insertions(+), 43 deletions(-) diff --git a/docs/prereleases.md b/docs/prereleases.md index 594f352..81b76e5 100644 --- a/docs/prereleases.md +++ b/docs/prereleases.md @@ -227,55 +227,47 @@ After the hotfix lands on `main`, rebase or merge `main` → `next` to pick it u ## Dependency handling in prerelease channels -Bumpy never automatically cascades a prerelease through your dependency graph. You decide which packages belong in each cycle through explicit declarations — bump files, `linked` / `fixed` groups, or `cascadeTo` rules. Whatever ends up in the cycle is then exact-pinned together. +Prereleases interact with semver differently from stable releases in one key way: -### The exact-pin rule - -Within a prerelease cycle, any inter-cycle dependency is **exact-pinned** at publish time: +> A range like `"@org/core": "^1.0.0"` continues to satisfy through `1.1.0`, `1.99.0`, etc. **But it doesn't satisfy any prerelease** — `^1.0.0` matches `1.5.0` but not `1.5.0-rc.0`. Semver only resolves a prerelease against a range when major.minor.patch matches exactly. -> If `@org/plugin@1.1.0-rc.0` is in the same cycle as `@org/core@2.0.0-rc.0`, the published `@org/plugin@1.1.0-rc.0` has `"@org/core": "2.0.0-rc.0"` — not `"^2.0.0-rc.0"`. +This means **every prerelease of an upstream package breaks every dependent's range.** For stable releases, bumpy's `updateInternalDependencies: "out-of-range"` default rarely fires (ranges stay satisfied). On a channel, that same rule causes a wide cascade — broken ranges are fixed by pulling dependents into the cycle. -This guarantees that any package from the cycle, installed via `@next`, works with the other packages it was published against. Channel-internal consistency is built into the artifact, not relied on at install time. +That's intentional: without it, dependent packages can't actually consume the new upstream prerelease (their published ranges don't match it). A "tighter" prerelease that left dependents on stable would force users to add `overrides` / `resolutions` by hand to install anything from the cycle. -Dependencies pointing **outside** the cycle keep their stable ranges. Their existing `@latest` install continues to work; nothing in their published `package.json` points at a prerelease. +### How bumpy's propagation applies on a channel -### `workspace:` protocol resolution - -`workspace:^` / `workspace:*` on an in-cycle dep resolves to the exact prerelease version. On an out-of-cycle dep, it resolves normally (the stable range bumpy would produce on `main`). +The phases ([docs/version-propagation.md](./version-propagation.md)) run unchanged: -### Why no automatic cascade +- **Phase A** detects broken ranges and bumps dependents at **proportional levels** — `patch` for `dependencies`, match-the-trigger for `peerDependencies`, etc. Prerelease cascades are wide but version movement stays sane (no 1.0.0 jumps from 0.x packages, which is the actual complaint behind changesets [#960](https://github.com/changesets/changesets/issues/960)). +- **Phase B** keeps `linked` / `fixed` groups in lockstep. +- **Phase C** applies `cascadeTo` and `dependencyBumpRules` as usual. -Changesets force-bumps every dependent whose range gets broken by a prerelease — including dependents that didn't otherwise need to change. This is the source of many of its prerelease pain points ([#960](https://github.com/changesets/changesets/issues/960), [#1228](https://github.com/changesets/changesets/issues/1228), [#1287](https://github.com/changesets/changesets/issues/1287)). +So a single bump file on `@org/core` in a 50-package monorepo can produce up to 50 prereleased packages. Wide, but required — the cycle is exactly the set of packages a tester can mix and match via `@next`. -Bumpy already takes the "explicit propagation" stance for stable releases (`updateInternalDependencies: "out-of-range"` is the default). Channels apply the same principle: bumpy does what you asked it to do, nothing more. The trade-off — that you can ship a stranded prerelease if you forget to coordinate — is addressed by the next section. +### The exact-pin rule ---- +Within the cycle, every inter-cycle dependency is **exact-pinned** at publish time: -## Coordinating multi-package prereleases +> If `@org/plugin@1.0.1-rc.0` is in the same cycle as `@org/core@2.0.0-rc.0`, the published `@org/plugin@1.0.1-rc.0` has `"@org/core": "2.0.0-rc.0"` — not `"^2.0.0-rc.0"`. -If you prerelease an upstream package without bringing its dependents along, those dependents won't be able to consume the prerelease. +This guarantees that any combination of packages from the cycle, installed via `@next`, works against the exact set it was published with. Channel-internal consistency is built into the artifact, not relied on at install time. -Concrete failure mode: +Dependencies pointing **outside** the cycle (e.g., to a package excluded via `ignore`) keep their stable ranges. -- `@org/core@1.0.0` and `@org/plugin@1.0.0` (both on `@latest`) -- `@org/plugin`'s package.json: `"@org/core": "^1.0.0"` -- You author one bump file: `@org/core` → major -- Cycle ships `@org/core@2.0.0-rc.0` to `@next`. `@org/plugin` stays at `1.0.0`. +### `workspace:` protocol resolution -A tester running `npm install @org/core@next @org/plugin` hits a peer dep mismatch (or npm hoists two copies of core). The prerelease is "stranded" — usable on its own but not in combination with the rest of the ecosystem. +`workspace:^` / `workspace:*` on an in-cycle dep resolves to the exact prerelease version. On an out-of-cycle dep, it resolves normally (the stable range bumpy would produce on `main`). -To make the prerelease usable, declare the relationship so `@org/plugin` joins the cycle. Pick whichever fits your situation: +### Limiting cascade scope -| Declaration | When to use | -| ----------------------------------------------- | ------------------------------------------------------- | -| **Multi-package bump file** | Ad-hoc — this specific PR ships both packages together | -| **`linked` group** in config | Two packages should always share the highest bump level | -| **`fixed` group** in config | Two packages should always share an exact version | -| **`cascadeTo: ["@org/plugin"]`** on `@org/core` | Any change to core should always bump plugin | +If the default — "every dependent comes along" — is too wide for your monorepo, the standard bumpy controls bound the cycle: -If you genuinely want a stranded prerelease (the package has no in-monorepo dependents that need it), no declaration is needed — bumpy will ship it as written. +- `ignore` / `include` in `_config.json` constrain which packages are managed at all +- Per-package `managed: false` excludes individual packages +- `linked` / `fixed` / `cascadeTo` declarations don't _narrow_ the cascade, but they make wider propagation more predictable when you want it -> See [docs/version-propagation.md](./version-propagation.md) for the full propagation model and how `linked` / `fixed` / `cascadeTo` interact. +There's no channel-specific opt-out for Phase A's range-fixing — disabling it would produce stranded prereleases that consumers couldn't actually use together. --- @@ -355,18 +347,18 @@ The directory used to hold shipped bump files matches the channel name: `.bumpy/ ## Comparison with changesets pre mode -| | 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; bumpy strips suffix and consolidates | -| State file | `.changeset/pre.json` committed to repo | None — file location in `.bumpy/` is the state | -| Wrong-branch hazard | Merging while pre mode is active accidentally turns stable releases into prereleases ([#239](https://github.com/changesets/changesets/issues/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](https://github.com/changesets/changesets/issues/786)) | Per-channel `tag` config, independent of suffix | -| Dependent force-bumping | Always on, can't be disabled ([#960](https://github.com/changesets/changesets/issues/960)) | Never automatic — cycle membership is declared (bump files, `linked`/`fixed`, `cascadeTo`); inter-cycle deps are exact-pinned at publish | -| Counter | Requires committed `package.json` increments ([#381](https://github.com/changesets/changesets/issues/381)) | Derived from current state; resets cleanly when target moves | -| Exit re-bumps everything | Yes ([#729](https://github.com/changesets/changesets/issues/729)) | No — promotion strips suffixes and consumes pre-shipped bump files into one consolidated entry | -| First publish during pre mode | Silently goes to `@latest` | 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 | +| | 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; bumpy strips suffix and consolidates | +| State file | `.changeset/pre.json` committed to repo | None — file location in `.bumpy/` is the state | +| Wrong-branch hazard | Merging while pre mode is active accidentally turns stable releases into prereleases ([#239](https://github.com/changesets/changesets/issues/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](https://github.com/changesets/changesets/issues/786)) | Per-channel `tag` config, independent of suffix | +| Dependent force-bumping | Cascades to **major** on peer deps by default ([#960](https://github.com/changesets/changesets/issues/960)) | Cascades at **proportional levels** (patch on deps, match-trigger on peer deps); inter-cycle deps exact-pinned at publish | +| Counter | Requires committed `package.json` increments ([#381](https://github.com/changesets/changesets/issues/381)) | Derived from current state; resets cleanly when target moves | +| Exit re-bumps everything | Yes ([#729](https://github.com/changesets/changesets/issues/729)) | No — promotion strips suffixes and consumes pre-shipped bump files into one consolidated entry | +| First publish during pre mode | Silently goes to `@latest` | 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 | --- From af89e4463fb323ee0e63c5d4a68ce40dc15aa32f Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Tue, 9 Jun 2026 17:23:54 -0700 Subject: [PATCH 5/6] docs: drop planned --snapshot flag; pkg.pr.new owns ephemeral publishing After reviewing realistic prerelease workflows: between bumpy channels (managed long-running release lines) and pkg.pr.new (ephemeral previews, canaries, branch snapshots), real-world use cases are covered. No need for an in-bumpy snapshot mechanism that would compete with the recommended tool. Expand the rule-of-thumb table to cover per-commit canaries and ad-hoc branch snapshots. Reinforce the pkg.pr.new pointer in the day-to-day workflow section so readers see it at the natural moment ("I want to install this PR before merging"). --- docs/prereleases.md | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/docs/prereleases.md b/docs/prereleases.md index 81b76e5..af832b5 100644 --- a/docs/prereleases.md +++ b/docs/prereleases.md @@ -14,17 +14,19 @@ No `pre enter` / `pre exit` commands. No mode files in your repo. No hidden stat 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 carry per-cycle state in your branch and your `.bumpy//` directory, and they're worth setting up when you expect to ship multiple prereleases through the same cycle. -**For per-PR preview releases, use [pkg.pr.new](https://pkg.pr.new) instead.** +**For anything short-lived or ephemeral, use [pkg.pr.new](https://pkg.pr.new) instead.** -pkg.pr.new publishes an ephemeral package from any open PR, gives you an install URL pinned to the PR's commit, and disappears when the PR closes. It's purpose-built for "let me try this PR before merging" workflows — no version planning, no branch discipline, no consumed bump files. Bumpy channels would be the wrong tool for that job: you'd be polluting your channel branch with throwaway state for every PR. +pkg.pr.new publishes throwaway packages from any PR, commit, or branch — no version planning, no branch discipline, no bump files. It pairs naturally with bumpy: bumpy owns the managed release lines, pkg.pr.new owns the ephemeral previews. Between the two, most teams need nothing else. Rough rule of thumb: -| You want… | Use | -| --------------------------------------------------------- | ------------------------------------- | -| Preview a single PR for review | [pkg.pr.new](https://pkg.pr.new) | -| Ship a `1.2.0-rc.N` line for weeks of integration testing | Bumpy channels (this doc) | -| One-off canary from `main` | (Planned: `bumpy publish --snapshot`) | +| You want… | Use | +| --------------------------------------------------------- | -------------------------------- | +| Preview a single PR for review | [pkg.pr.new](https://pkg.pr.new) | +| Per-commit canary from `main` | [pkg.pr.new](https://pkg.pr.new) | +| One-off snapshot from a branch for ad-hoc testing | [pkg.pr.new](https://pkg.pr.new) | +| 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) | --- @@ -141,6 +143,8 @@ PR authors do nothing different. They: 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 [pkg.pr.new](https://pkg.pr.new)'s job, not a channel publish. Channels only kick in once a PR has merged into the channel branch. + ### Versioning a prerelease When a feature PR merges to `next`: @@ -366,7 +370,6 @@ The directory used to hold shipped bump files matches the channel name: `.bumpy/ These are intentionally out of scope for the initial channel feature. If any of these is a blocker for you, please open an issue. -- **Per-PR preview releases** — use [pkg.pr.new](https://pkg.pr.new) instead. It's purpose-built for ephemeral per-PR packages and pairs well with bumpy. See [When to use channels — and when not to](#when-to-use-channels--and-when-not-to) above. -- **One-off snapshot publishes from `main`** (`0.0.0-snapshot-`) — planned as a separate `bumpy publish --snapshot` flag, not via channels. +- **Ephemeral / preview / canary releases** — use [pkg.pr.new](https://pkg.pr.new) instead. It owns short-lived publishing (per-PR, per-commit, per-branch); bumpy channels are deliberately scoped to managed long-running release lines. See [When to use channels — and when not to](#when-to-use-channels--and-when-not-to) above. - **Workflow-dispatch one-off prereleases** — planned as a complement to channels, for teams that want occasional prereleases without a dedicated branch. - **Per-bump-file channel routing** — declaring `channel: beta` inside a bump file's frontmatter. Not planned; channels stay branch-derived to keep the mental model simple. From 2ce14e0696b08a3d50ed2e91417ea393a6877205 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Tue, 9 Jun 2026 23:25:26 -0700 Subject: [PATCH 6/6] docs: never commit prerelease versions; derive from registry, bump files, and tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major revision of the channels design around one principle: git carries inputs (bump files) and stable outputs only. Prerelease versions exist solely in the registry and git tags. - package.json stays at the last stable version on channel branches - release PR becomes a pure file-move PR; computed versions go in the PR title and merge commit message (advisory; registry wins at publish) - counters derived from registry floor (max published + 1) — immune to abandoned cycles, force-resets, and re-runs - every publish recomputes the full cycle; lockstep republish + exact pins make the @next set coherent by construction - promotion is now just a merge; no suffix stripping, no rc versions ever on main, no version conflicts on main <-> channel syncs - generalized pending rule enables channel graduation (alpha -> beta) - no CHANGELOG.md on channels; GitHub releases + render-on-demand via bumpy status; consolidated stable entry written once at promotion - publish mechanics specified: trigger via channel-dir diff since last tag, tag-on-SHA idempotency, gitHead-based partial-failure resume - document build-time version baking caveat (rewrite before build) - reserve schema room for future stable/maintenance channels --- docs/prereleases.md | 259 ++++++++++++++++++++++++++------------------ 1 file changed, 154 insertions(+), 105 deletions(-) diff --git a/docs/prereleases.md b/docs/prereleases.md index af832b5..9c3cbb7 100644 --- a/docs/prereleases.md +++ b/docs/prereleases.md @@ -4,15 +4,23 @@ 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 bumpy automatically strips the suffix. +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. -No `pre enter` / `pre exit` commands. No mode files in your repo. No hidden state that can poison unrelated merges. +**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](#comparison-with-changesets-pre-mode) at the bottom for a side-by-side. +## The one rule everything follows from + +> **Git carries inputs (bump files) and stable outputs (versions and `CHANGELOG.md`, on `main`). 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. + ## When to use channels — and when not to -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 carry per-cycle state in your branch and your `.bumpy//` directory, and they're worth setting up when you expect to ship multiple prereleases through the same cycle. +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 [pkg.pr.new](https://pkg.pr.new) instead.** @@ -33,46 +41,49 @@ Rough rule of thumb: ## Mental model ``` - ┌─────────────────────────────────────────────┐ - │ │ - feature PR ───►│ next branch ──► 1.2.0-rc.0 ──► 1.2.0-rc.1 │ ── merge ──► - feature PR ───►│ │ │ - feature PR ───►│ │ │ - └─────────────────────────────────────────────┘ │ - ▼ - ┌──────────────────────┐ - │ main branch │ - │ ──► 1.2.0 │ - └──────────────────────┘ + 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 `next` branch is the `next` channel. Pushing to it produces prerelease versions on the `@next` dist-tag. -- **Same flow as main.** Feature PRs land bump files. A "🐸 Versioned release (next)" PR accumulates the planned bump. Merging it triggers a prerelease publish. -- **Promotion is a merge.** `next` → `main` carries the prerelease versions and accumulated bump files forward; bumpy strips the suffix on main, consumes the bump files, and publishes stable. +- **Branch = channel.** The `next` branch is the `next` channel. Pushing to it produces prerelease publishes on the `@next` dist-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 computed versions appear in the PR title and merge commit message — so `git log` on the channel reads as a release history — but nothing version-shaped is committed. +- **Promotion is a merge.** `next` → `main` carries the accumulated bump files forward (and nothing else release-related — versions never diverged). Main's ordinary stable version PR consumes them. --- -## How shipped vs pending bump files are tracked +## How state is tracked -A bump file's location tells you where it stands in the release lifecycle: +The **only** channel state is bump file location: ``` .bumpy/ ├── _config.json ├── README.md -├── feature-y.md ← pending — will trigger the next prerelease +├── 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 ``` -- **`.bumpy/*.md`** — pending. Has not yet been included in any release. -- **`.bumpy//*.md`** — shipped on ``, awaiting promotion to stable. The bump file itself is not modified; only its location changes. +The general rule: **a bump file is pending unless it's in the current context's own channel directory.** -On promotion (merge to main + main's version PR), files in both root and channel subdirs are consumed into a single consolidated stable changelog entry, then deleted. +- 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 merging `alpha` → `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. -This means at any time you can `ls .bumpy/` to see exactly what's pending vs shipped. No frontmatter flags, no committed mode files, no `git tag` archaeology. +At any time, `ls .bumpy/` tells you exactly where everything stands. No frontmatter flags, no committed mode files, no counters. --- @@ -107,6 +118,8 @@ Multiple channels can coexist: } ``` +> Semver orders prerelease identifiers lexically, so `alpha` < `beta` < `rc` for the same target version — graduated channels sort correctly by maturity out of the box. + ### 2. Create the branch ```bash @@ -150,66 +163,96 @@ Bump files don't carry channel metadata. The branch they land on determines the When a feature PR merges to `next`: 1. `bumpy ci release` runs on the `next` push. -2. It sees a bump file at `.bumpy/feature-x.md` (pending — not yet in `.bumpy/next/`) and creates (or updates) a **"🐸 Versioned release (next)"** PR — targeting `next`, on the branch `bumpy/version-packages-next`. -3. The PR's diff includes: - - `package.json` versions bumped with the `-rc.N` suffix - - `.bumpy/feature-x.md` **moved** to `.bumpy/next/feature-x.md` +2. 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 branch `bumpy/version-packages-next`. +3. The PR's diff is **only file moves**: `.bumpy/feature-x.md` → `.bumpy/next/feature-x.md`. The computed versions live in the PR title and body, and land in git history via the merge commit message. When a maintainer merges that PR: -4. `bumpy ci release` runs again on `next`, detects no pending bump files (everything is in `.bumpy/next/`), sees unpublished packages at `1.2.0-rc.0`, and publishes them to the `@next` dist-tag. -5. Git tags `v1.2.0-rc.0` are pushed; a GitHub release is created (marked as prerelease) with notes drawn from the bump files that _just moved_ into `.bumpy/next/`. +4. `bumpy ci release` runs again on `next`, sees newly-shipped files in `.bumpy/next/`, computes the prerelease versions fresh (see [Publish mechanics](#publish-mechanics) below), and publishes the full cycle to the `@next` dist-tag. +5. Git tags (`v1.2.0-rc.0`) are pushed; a GitHub release is created (marked as prerelease) with notes drawn from the just-moved bump files. ```bash # A consumer testing the prerelease: npm install my-package@next # gets 1.2.0-rc.0 ``` +> **The PR title is narrative, not state.** Versions are recomputed at publish time and the registry always wins. If reality moved between PR creation and merge (e.g. `main` shipped a stable release that retargets the cycle), publish uses the recomputed versions and the GitHub release notes say so explicitly: "retargeted from 1.2.0-rc.4 → 1.3.0-rc.0 because 1.2.0 shipped stable." 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. + ### A second prerelease When a new feature lands on `next`: - The new bump file appears at `.bumpy/feature-y.md` (root). Previously-shipped `.bumpy/next/feature-x.md` stays put. -- `bumpy ci release` sees the pending file → opens the version PR. -- The PR bumps `1.2.0-rc.0` → `1.2.0-rc.1`, moves `feature-y.md` into `.bumpy/next/`. -- Merge → publish → GitHub release for `1.2.0-rc.1` includes only `feature-y.md` (the just-moved file). +- `bumpy ci release` opens/updates the release PR, which moves `feature-y.md` into `.bumpy/next/`. +- Merge → publish computes `1.2.0-rc.1` and republishes the cycle. The GitHub release for `rc.1` highlights `feature-y.md` (the just-moved file), with the full cycle listed in a collapsed section. + +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. ### Promotion to stable When the prerelease has been tested and you're ready to ship the real `1.2.0`: -1. **Merge `next` → `main`** (regular PR — review it like any other). -2. `main` now has package.json versions like `1.2.0-rc.5` _and_ all the accumulated bump files in `.bumpy/next/`. -3. `bumpy ci release` runs on `main`. It sees: - - Prerelease versions in `package.json` - - Bump files in `.bumpy/next/` (from the channel) - - No pending files at `.bumpy/` root -4. It opens a **"🐸 Versioned release"** PR that: - - Strips the prerelease suffix (`1.2.0-rc.5` → `1.2.0`) - - Consumes **all** bump files from `.bumpy/next/` +1. **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. +2. `bumpy ci release` runs on `main` and follows its completely ordinary flow: it sees pending bump files (everything in `.bumpy/next/` counts as pending on `main`) 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.0` entry to `CHANGELOG.md` with every change from the cycle - - Deletes `.bumpy/next/` (and any pending files at root, if any) -5. Merge that PR → bumpy publishes `1.2.0` to `@latest`, tags `v1.2.0`, and creates a stable GitHub release. + - Deletes `.bumpy/next/` +3. Merge that PR → bumpy publishes `1.2.0` to `@latest`, tags `v1.2.0`, and creates a stable GitHub release. -> The final stable `CHANGELOG.md` entry includes every change from the prerelease cycle — consumers of `@latest` see the full picture, not just the `rc.5 → 1.2.0` step. Individual rc release notes remain available on the GitHub releases page. +There is no special promotion mode. Promotion is literally "the bump files arrive on `main` and the stable flow eats them." -### Continuing after promotion +> The final stable `CHANGELOG.md` entry includes every change from the prerelease cycle — consumers of `@latest` see the full picture, not just the last rc's delta. Individual rc release notes remain available on the GitHub releases page. -After promotion, `next` is empty (no pending files, no `.bumpy/next/` subdir). You can either: +### Continuing after promotion -- **Reset and reuse it.** `git reset --hard main && git push --force-with-lease`. The next feature PR targeting `next` starts a new cycle (`1.3.0-rc.0`, etc.). -- **Delete and recreate later.** If your team only opens a prerelease cycle occasionally, delete the branch and recreate it when you need the next one. +After promotion, the cycle is over (no pending files, no `.bumpy/next/` on `main`). For the channel branch: -Either is supported — the channel config doesn't require the branch to exist between cycles. +- **Delete and recreate (recommended).** Delete `next`, recreate it from `main` when 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 target `next` (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. ### Abandoning a prerelease cycle -Sometimes a prerelease cycle gets shelved without ever shipping stable. To reset: +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. + +--- + +## Publish mechanics + +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//`, run through the normal [propagation phases](./version-propagation.md). 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 `-.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. -- Delete `.bumpy/next/` in a PR to `next`, optionally also resetting package.json versions back to their pre-cycle state. -- Or simply force-reset the branch to a known-good commit. +**Trigger** — publish fires when files were added to `.bumpy//` since the last release tag reachable from `HEAD` (or when the directory is non-empty and no release tag exists yet). The same diff that triggers the publish defines the "what's new" section of the release notes. A push that doesn't move bump files (an ordinary feature merge) never causes a publish. -There is no `bumpy channel reset` command — the state lives in your branch, so plain git commands handle it. +**Idempotency & resume** — after a successful publish, the pushed tags mark `HEAD` as released; re-running on the same SHA is a no-op. If a publish fails partway, re-running resumes it: 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." `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.json` into a banner, `__VERSION__` replacement, etc.), the rewrite must happen before your build runs — use `in-place` mode or build inside the publish lifecycle. Otherwise your prerelease artifacts would report the last _stable_ version at runtime. The tarball's `package.json` is always correct either way. + +--- + +## Changelogs and release notes + +**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 from the just-moved files. +- **`bumpy status`** on a channel renders the would-be changelog for the whole cycle on demand — the answer to "what has shipped on `@next` so far," including for teams not on GitHub. +- **The stable `CHANGELOG.md` entry** is written once, at promotion, on `main` — 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 computed versions appear in the release PR title and merge commit message, which are understood as point-in-time narrative; live truth is always `bumpy status`, the dist-tags, and the git tags. --- @@ -219,13 +262,15 @@ Patches can flow to `main` independently while a prerelease is in flight on `nex ``` main: 1.1.0 ──► 1.1.1 (hotfix) - ╲ - next: 1.2.0-rc.0 ──► 1.2.0-rc.1 (after rebasing main into next) + ╲ merge main → next + next: 1.2.0-rc.0 ──► 1.2.0-rc.1 (includes the hotfix) ``` -After the hotfix lands on `main`, rebase or merge `main` → `next` to pick it up. The next prerelease version will reflect the combined state. +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`.) -> If `main` ships a bump that's higher than the prerelease's current target (e.g., `main` ships `1.2.0`, prerelease was targeting `1.2.0-rc.x`), bumpy automatically retargets the prerelease at `1.3.0-rc.0` on the next merge — the prerelease never accidentally publishes a version lower than what's already on `@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. --- @@ -235,33 +280,27 @@ Prereleases interact with semver differently from stable releases in one key way > A range like `"@org/core": "^1.0.0"` continues to satisfy through `1.1.0`, `1.99.0`, etc. **But it doesn't satisfy any prerelease** — `^1.0.0` matches `1.5.0` but not `1.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.** For stable releases, bumpy's `updateInternalDependencies: "out-of-range"` default rarely fires (ranges stay satisfied). On a channel, that same rule causes a wide cascade — broken ranges are fixed by pulling dependents into the cycle. - -That's intentional: without it, dependent packages can't actually consume the new upstream prerelease (their published ranges don't match it). A "tighter" prerelease that left dependents on stable would force users to add `overrides` / `resolutions` by hand to install anything from the cycle. - -### How bumpy's propagation applies on a channel +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`. -The phases ([docs/version-propagation.md](./version-propagation.md)) run unchanged: +### The cycle moves as one -- **Phase A** detects broken ranges and bumps dependents at **proportional levels** — `patch` for `dependencies`, match-the-trigger for `peerDependencies`, etc. Prerelease cascades are wide but version movement stays sane (no 1.0.0 jumps from 0.x packages, which is the actual complaint behind changesets [#960](https://github.com/changesets/changesets/issues/960)). -- **Phase B** keeps `linked` / `fixed` groups in lockstep. -- **Phase C** applies `cascadeTo` and `dependencyBumpRules` as usual. +Because nothing is committed incrementally, **every publish recomputes the entire cycle from scratch** — all bump files, full propagation ([Phase A/B/C](./version-propagation.md) run unchanged, with proportional bump levels: `patch` for `dependencies`, match-the-trigger for `peerDependencies`, avoiding the changesets [#960](https://github.com/changesets/changesets/issues/960) force-major problem). Every in-cycle package gets a fresh counter and republishes together, every rc. -So a single bump file on `@org/core` in a 50-package monorepo can produce up to 50 prereleased packages. Wide, but required — the cycle is exactly the set of packages a tester can mix and match via `@next`. +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: ### The exact-pin rule -Within the cycle, every inter-cycle dependency is **exact-pinned** at publish time: +Within the cycle, every inter-cycle dependency is **exact-pinned** in the published artifacts: -> If `@org/plugin@1.0.1-rc.0` is in the same cycle as `@org/core@2.0.0-rc.0`, the published `@org/plugin@1.0.1-rc.0` has `"@org/core": "2.0.0-rc.0"` — not `"^2.0.0-rc.0"`. +> If `@org/plugin@1.0.1-rc.2` is in the same cycle as `@org/core@1.2.0-rc.2`, the published `@org/plugin@1.0.1-rc.2` has `"@org/core": "1.2.0-rc.2"` — not `"^1.2.0-rc.2"`. -This guarantees that any combination of packages from the cycle, installed via `@next`, works against the exact set it was published with. Channel-internal consistency is built into the artifact, not relied on at install time. +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:` protocol resolution -`workspace:^` / `workspace:*` on an in-cycle dep resolves to the exact prerelease version. On an out-of-cycle dep, it resolves normally (the stable range bumpy would produce on `main`). +`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`). ### Limiting cascade scope @@ -271,32 +310,32 @@ If the default — "every dependent comes along" — is too wide for your monore - Per-package `managed: false` excludes individual packages - `linked` / `fixed` / `cascadeTo` declarations don't _narrow_ the cascade, but they make wider propagation more predictable when you want it -There's no channel-specific opt-out for Phase A's range-fixing — disabling it would produce stranded prereleases that consumers couldn't actually use together. +There's no channel-specific opt-out of the cascade — disabling it would produce stranded prereleases that consumers couldn't actually install together. --- ## CLI behavior on a channel branch -The commands behave the same as on `main`, with channel-derived suffixes and tags: - -| Command | On `main` | On `next` (channel branch) | -| ------------------ | ---------------------------------------- | --------------------------------------------------------- | -| `bumpy status` | shows planned stable versions | shows planned `-rc.N` versions (pending files only) | -| `bumpy version` | bumps to stable, consumes all bump files | bumps to `-rc.N`, moves pending files into `.bumpy/next/` | -| `bumpy publish` | publishes to `@latest` | publishes to `@next` | -| `bumpy ci release` | version-PR / publish on main | version-PR / publish on `next` | -| `bumpy ci check` | (PR-level, unchanged) | (PR-level, unchanged) | -| `bumpy check` | compares to `baseBranch` | compares to the channel branch | +| 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` | compares to the channel branch | You can override the inferred channel with `--channel ` for local testing: ```bash bumpy status --channel next # preview what next would publish -bumpy version --channel next # locally bump to -rc.N and move pending files +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.?`). + --- ## What if no channel matches? @@ -309,13 +348,16 @@ If you want a workflow that runs on every branch (e.g., for CI plan output), kee ## Counter behavior -The `-rc.N` counter is computed from the workspace's current state, not from cumulative metadata: +The `-rc.N` counter is derived from the **registry**, never from committed state: -- If no package is currently on a prerelease, the next version is `-rc.0`. -- If a package is at `1.2.0-rc.3` and a new pending bump file lands, the next version is `1.2.0-rc.4`. -- If a new pending bump file would raise the _target_ (e.g., a `major` lands when current target was `minor`), the counter resets: `1.2.0-rc.3` → `2.0.0-rc.0`. Previously-shipped files in `.bumpy/next/` carry forward — they'll consolidate at the new target on promotion. +- If no `1.2.0-rc.*` has ever been published for a package, the next version is `1.2.0-rc.0`. +- If `1.2.0-rc.3` is the highest published, the next is `1.2.0-rc.4` — regardless of what any branch looks like. +- If a new bump file raises the _target_ (e.g., a `major` lands when the cycle was targeting a minor), the target moves to `2.0.0` and the counter naturally restarts at `2.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. -This matches user intuition (the counter resets when the underlying target moves) and avoids the changesets [#381](https://github.com/changesets/changesets/issues/381) problem where prerelease counters require committed state to function. +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](https://github.com/changesets/changesets/issues/381) problem (counters requiring committed state) by construction rather than by careful bookkeeping. --- @@ -329,9 +371,10 @@ This matches user intuition (the counter resets when the underlying target moves "preid": "rc", // version suffix, e.g. -rc.0 "tag": "next", // npm dist-tag for publish "versionPr": { - // optional — override the channel's version PR + // 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 }, }, }, @@ -342,27 +385,31 @@ 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 ` ()`. +- `versionPr.title` — defaults to ` (): ` — the versions in the title are advisory narrative; the registry wins at publish time. - `versionPr.branch` — defaults to `-` (e.g., `bumpy/version-packages-next`). +- `versionPr.automerge` — defaults to `false`. + +The directory used to hold shipped bump files matches the channel name: `.bumpy//`. Channel names that would collide with reserved `.bumpy/` entries (anything starting with `_`, `README.md`) are rejected. -The directory used to hold shipped bump files matches the channel name: `.bumpy//`. +> `preid` is optional in the schema (not just defaulted) to leave room for future **stable channels** — maintenance branches like `1.x` that publish stable versions to a non-`latest` dist-tag ([changesets#1235](https://github.com/changesets/changesets/discussions/1235)). Not part of the initial feature, but the config shape won't need a breaking change to add it. --- ## Comparison with changesets pre mode -| | 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; bumpy strips suffix and consolidates | -| State file | `.changeset/pre.json` committed to repo | None — file location in `.bumpy/` is the state | -| Wrong-branch hazard | Merging while pre mode is active accidentally turns stable releases into prereleases ([#239](https://github.com/changesets/changesets/issues/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](https://github.com/changesets/changesets/issues/786)) | Per-channel `tag` config, independent of suffix | -| Dependent force-bumping | Cascades to **major** on peer deps by default ([#960](https://github.com/changesets/changesets/issues/960)) | Cascades at **proportional levels** (patch on deps, match-trigger on peer deps); inter-cycle deps exact-pinned at publish | -| Counter | Requires committed `package.json` increments ([#381](https://github.com/changesets/changesets/issues/381)) | Derived from current state; resets cleanly when target moves | -| Exit re-bumps everything | Yes ([#729](https://github.com/changesets/changesets/issues/729)) | No — promotion strips suffixes and consumes pre-shipped bump files into one consolidated entry | -| First publish during pre mode | Silently goes to `@latest` | 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 | +| | 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 | 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](https://github.com/changesets/changesets/issues/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](https://github.com/changesets/changesets/issues/786)) | Per-channel `tag` config, independent of suffix | +| Dependent force-bumping | Cascades to **major** on peer deps by default ([#960](https://github.com/changesets/changesets/issues/960)) | 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](https://github.com/changesets/changesets/issues/381)) | Derived from the registry (max published + 1); immune to resets and abandoned cycles | +| Exit re-bumps everything | Yes ([#729](https://github.com/changesets/changesets/issues/729)) | No — promotion is an ordinary stable bump; there's no suffix to strip because none was committed | +| First publish during pre mode | Silently goes to `@latest` | 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 | --- @@ -371,5 +418,7 @@ The directory used to hold shipped bump files matches the channel name: `.bumpy/ 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** — use [pkg.pr.new](https://pkg.pr.new) instead. It owns short-lived publishing (per-PR, per-commit, per-branch); bumpy channels are deliberately scoped to managed long-running release lines. See [When to use channels — and when not to](#when-to-use-channels--and-when-not-to) above. -- **Workflow-dispatch one-off prereleases** — planned as a complement to channels, for teams that want occasional prereleases without a dedicated branch. +- **Workflow-dispatch one-off prereleases** — planned. The no-commit architecture makes this nearly free: a one-off is the same compute-and-publish step run from any SHA with an explicit preid and dist-tag, no branch state required. It will likely follow shortly after channels. +- **Stable (maintenance) channels** — long-lived branches like `1.x` publishing stable versions to a non-`latest` dist-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: beta` inside a bump file's frontmatter. Not planned; channels stay branch-derived to keep the mental model simple.