From d85bcad8a2de1aca85afc2730e8304ae973f49f0 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Mon, 8 Jun 2026 23:48:41 -0700 Subject: [PATCH 01/11] 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 02/11] 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 03/11] 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 04/11] 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 05/11] 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 06/11] 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. From e02189f608f065e830fa2ef48751aa93fd0d4a4f Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Thu, 11 Jun 2026 22:44:48 -0700 Subject: [PATCH 07/11] feat: implement prerelease channels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Branch-based prerelease lines where prerelease versions are never committed to git — derived at publish time from bump files (targets), the registry (counters), and git tags/gitHead (idempotency). Core: - channels config block (+ JSON schema): branch, preid, tag, versionPr with automerge; names validated as .bumpy/ subdirectory-safe - bump file location is the only channel state: pending at .bumpy/ root (or other channels' dirs), shipped in .bumpy//; the general pending rule gives alpha -> beta graduation for free - release plan gains a prerelease mode: range satisfaction is checked against -.0, so every dependent joins the cycle at proportional bump levels (prereleases never satisfy stable ranges) - prerelease versions: registry-floor counters (max published + 1), per-package gitHead skip for re-runs/resume, exact-pinned in-cycle deps written transiently into the working tree and restored after Commands: - version: on a channel, only moves pending files into .bumpy// - publish: channel flow computes + rewrites + publishes to the channel dist-tag; stable flow refuses suffixed versions when channels exist - ci release: channel branches get publish-on-move-detection (push range diff) + file-move release PRs with computed versions in the title/commit message; unknown branches are refused - ci plan/check, status, check: channel-aware (status shows the cycle; check skips channel branches and gains --base) - promotion needs no special mode: main consumes channel dirs as pending, bumps stable-to-stable, writes the consolidated changelog - GitHub releases for prerelease versions are marked --prerelease Docs updated to match (banner removed, trigger/notes/check rows aligned); changesets comparison moved to Implemented. --- .bumpy/prerelease-channels.md | 5 + docs/cli.md | 44 +- docs/configuration.md | 24 ++ docs/differences-from-changesets.md | 21 +- docs/prereleases.md | 18 +- packages/bumpy/config-schema.json | 43 ++ packages/bumpy/src/cli.ts | 13 + packages/bumpy/src/commands/check.ts | 20 +- packages/bumpy/src/commands/ci.ts | 383 +++++++++++++++++- packages/bumpy/src/commands/publish.ts | 150 ++++++- packages/bumpy/src/commands/status.ts | 140 ++++++- packages/bumpy/src/commands/version.ts | 98 ++++- packages/bumpy/src/core/apply-release-plan.ts | 17 +- packages/bumpy/src/core/bump-file.ts | 67 ++- packages/bumpy/src/core/channels.ts | 115 ++++++ packages/bumpy/src/core/config.ts | 4 + packages/bumpy/src/core/github-release.ts | 4 + packages/bumpy/src/core/prerelease.ts | 240 +++++++++++ packages/bumpy/src/core/release-plan.ts | 19 +- packages/bumpy/src/types.ts | 26 ++ .../test/core/bump-file-channels.test.ts | 99 +++++ packages/bumpy/test/core/channels.test.ts | 97 +++++ packages/bumpy/test/core/prerelease.test.ts | 131 ++++++ packages/bumpy/test/core/release-plan.test.ts | 51 +++ 24 files changed, 1769 insertions(+), 60 deletions(-) create mode 100644 .bumpy/prerelease-channels.md create mode 100644 packages/bumpy/src/core/channels.ts create mode 100644 packages/bumpy/src/core/prerelease.ts create mode 100644 packages/bumpy/test/core/bump-file-channels.test.ts create mode 100644 packages/bumpy/test/core/channels.test.ts create mode 100644 packages/bumpy/test/core/prerelease.test.ts diff --git a/.bumpy/prerelease-channels.md b/.bumpy/prerelease-channels.md new file mode 100644 index 0000000..06b8712 --- /dev/null +++ b/.bumpy/prerelease-channels.md @@ -0,0 +1,5 @@ +--- +'@varlock/bumpy': minor +--- + +Add prerelease channels — branch-based prerelease lines (e.g. `next` → `@next` dist-tag) where prerelease versions are never committed to git. Targets derive from bump files, counters from the registry; shipped bump files are tracked by moving them into `.bumpy//`. Includes channel-aware `version` / `publish` / `status` / `ci release` flows, exact-pinned lockstep cycle publishes, and promotion-by-merge to stable. diff --git a/docs/cli.md b/docs/cli.md index 19780af..51400a0 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -53,9 +53,12 @@ bumpy status --verbose | `--bump ` | Filter by bump type, e.g. `"major"` or `"minor,patch"` | | `--filter ` | Filter by package name or glob | | `--verbose` | Show bump file details and summaries | +| `--channel ` | Channel override (default: inferred from the current branch) | Exits with code `0` if releases are pending, `1` if none. +On a [prerelease channel](prereleases.md) branch, status shows the cycle instead: shipped vs pending bump files and the derived `-.N` versions (counters come from the registry; offline they render as `.?`). + ## `bumpy version` Consume all pending bump files and apply the release plan: @@ -72,9 +75,12 @@ bumpy version bumpy version --commit ``` -| Flag | Description | -| ---------- | --------------------------------- | -| `--commit` | Commit the version changes to git | +| Flag | Description | +| ------------------ | ------------------------------------------------------------ | +| `--commit` | Commit the version changes to git | +| `--channel ` | Channel override (default: inferred from the current branch) | + +On a [prerelease channel](prereleases.md) branch, `bumpy version` does something much smaller: it **moves pending bump files into `.bumpy//`** and writes no versions and no changelogs — prerelease versions are derived at publish time and never committed. ## `bumpy publish` @@ -87,12 +93,15 @@ bumpy publish --tag beta bumpy publish --filter "@myorg/*" ``` -| Flag | Description | -| ------------------ | --------------------------------------------------------- | -| `--dry-run` | Preview what would be published without actually doing it | -| `--tag ` | npm dist-tag (e.g., `next`, `beta`) | -| `--no-push` | Skip pushing git tags to the remote | -| `--filter ` | Only publish matching packages (supports globs) | +| Flag | Description | +| ------------------ | ------------------------------------------------------------ | +| `--dry-run` | Preview what would be published without actually doing it | +| `--tag ` | npm dist-tag (e.g., `next`, `beta`) | +| `--no-push` | Skip pushing git tags to the remote | +| `--filter ` | Only publish matching packages (supports globs) | +| `--channel ` | Channel override (default: inferred from the current branch) | + +On a [prerelease channel](prereleases.md) branch, publish derives prerelease versions (targets from the cycle's bump files, counters from the registry), transiently writes them into the working tree so pack/build see them, publishes the whole cycle to the channel's dist-tag with exact-pinned inter-cycle deps, then restores the files. Nothing version-shaped is ever committed. **How bumpy detects unpublished packages:** @@ -114,12 +123,15 @@ bumpy check --hook pre-commit bumpy check --hook pre-push ``` -| Flag | Description | -| ------------------- | ---------------------------------------------------------- | -| `--strict` | Fail if any changed package is not covered by a bump file | -| `--no-fail` | Warn only, never exit non-zero (useful for advisory hooks) | -| `--hook pre-commit` | Only count staged + committed bump files | -| `--hook pre-push` | Only count committed bump files | +| Flag | Description | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `--strict` | Fail if any changed package is not covered by a bump file | +| `--no-fail` | Warn only, never exit non-zero (useful for advisory hooks) | +| `--hook pre-commit` | Only count staged + committed bump files | +| `--hook pre-push` | Only count committed bump files | +| `--base ` | Branch to compare against (default: `baseBranch`) — use the channel branch for feature branches targeting a [channel](prereleases.md) | + +The check is skipped automatically on channel branches and release PR branches (they move/consume bump files by design). ### Hook context @@ -240,6 +252,8 @@ bumpy ci release --auto-publish --tag beta Requires `GH_TOKEN`. When `BUMPY_GH_TOKEN` is set, it is automatically used to push the version branch and create/edit the PR so that PR workflows trigger (see [GitHub Actions setup](github-actions.md#token-setup)). +**Channel branches:** when run on a branch configured as a [prerelease channel](prereleases.md), `ci release` switches to the channel flow — it publishes the cycle when the triggering push moved bump files into `.bumpy//` (a release PR merge), and creates/updates the file-move release PR when pending bump files exist. When channels are configured and the branch is neither `baseBranch` nor a channel branch, the command exits with an error instead of guessing. + ## `bumpy ci setup` Interactive guide to set up `BUMPY_GH_TOKEN` for CI. Walks through creating a fine-grained PAT or GitHub App token and storing it as a repository secret. diff --git a/docs/configuration.md b/docs/configuration.md index 46cc147..eb2d9ce 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -25,6 +25,7 @@ Bumpy is configured via `.bumpy/_config.json`, created by `bumpy init`. Per-pack | `versionPr` | `{ title, branch, preamble }` | see below | Customize the version PR | | `allowCustomCommands` | `boolean \| string[]` | `false` | Allow per-package custom commands from `package.json` (see below) | | `packages` | `object` | `{}` | Per-package config overrides (keyed by package name) | +| `channels` | `object` | `{}` | Prerelease channels, keyed by channel name (see below) | ### Dependency bump rules @@ -99,6 +100,29 @@ The `versionPr` object customizes the PR that `bumpy ci release` creates: | `branch` | `string` | `"bumpy/version-packages"` | Branch name for the version PR | | `preamble` | `string` | — | HTML content prepended to the PR body | +### Prerelease channels + +The `channels` object maps long-lived branches to prerelease lines. See [prereleases.md](prereleases.md) for the full workflow. + +```jsonc +{ + "channels": { + "next": { + "branch": "next", // required — branch that triggers this channel + "preid": "rc", // version suffix (default: channel name) + "tag": "next", // npm dist-tag (default: channel name) + "versionPr": { + "title": "🐸 Versioned release (next)", // default: " ()" + "branch": "bumpy/version-packages-next", // default: "-" + "automerge": false, // enable auto-merge on the release PR + }, + }, + }, +} +``` + +Channel names become `.bumpy//` subdirectories (holding bump files that shipped on the channel), so they must be filesystem-safe and can't start with `_` or collide with reserved entries. + ## Per-package config Per-package settings can be defined in two places: diff --git a/docs/differences-from-changesets.md b/docs/differences-from-changesets.md index b8ffe4f..8450a67 100644 --- a/docs/differences-from-changesets.md +++ b/docs/differences-from-changesets.md @@ -147,6 +147,18 @@ Bumpy replaces all of this with two CLI commands you run directly in standard wo - [changesets#1242](https://github.com/changesets/changesets/issues/1242) — bot/action version upgrade issues - [changesets#43](https://github.com/changesets/changesets/issues/43) — can't customize bot messages +### Prerelease channels that actually work + +Changesets' prerelease mode is described in their own docs as "very complicated" with "mistakes that can lead to repository and publish states that are very hard to fix." Key problems: global mode state poisons unrelated merges, exiting pre bumps ALL packages, counters require committed state, dist-tags can't be controlled. + +Bumpy replaces the mode with **branch-based channels** ([docs/prereleases.md](./prereleases.md)): a long-lived branch (e.g. `next`) maps to a prerelease line. Bump file location (`.bumpy//`) is the only state; prerelease versions are never committed — targets derive from bump files, counters from the registry. Promotion to stable is just a merge. + +- [changesets#729](https://github.com/changesets/changesets/issues/729) — exiting pre mode bumps all versions (14 comments) +- [changesets#786](https://github.com/changesets/changesets/issues/786) — can't control dist-tag in pre mode (13 comments) +- [changesets#635](https://github.com/changesets/changesets/issues/635) — prerelease workflow problems +- [changesets#239](https://github.com/changesets/changesets/issues/239) — prerelease mode design issues +- [changesets#381](https://github.com/changesets/changesets/issues/381) — prerelease counters require committed state + ### Local bump file verification `bumpy check` verifies that changed packages on the current branch have corresponding bump files. Compares your branch to the base branch, maps changed files to packages. By default it only fails if no bump files exist at all (matching changesets behavior). Use `--strict` to require every changed package to be covered, `--no-fail` for advisory-only mode, or `--hook pre-commit`/`--hook pre-push` to control which bump files count based on their git status. No GitHub API needed. @@ -157,15 +169,6 @@ Changesets has no built-in equivalent — users rely on the CI bot comment to ca ## Planned / Not Yet Implemented -### Prerelease mode that actually works - -Changesets' prerelease mode is described in their own docs as "very complicated" with "mistakes that can lead to repository and publish states that are very hard to fix." Key problems: no target on bump files, multi-branch corruption, exiting pre bumps ALL packages, bad interactions with linked/fixed groups. - -- [changesets#729](https://github.com/changesets/changesets/issues/729) — exiting pre mode bumps all versions (14 comments) -- [changesets#786](https://github.com/changesets/changesets/issues/786) — can't control dist-tag in pre mode (13 comments) -- [changesets#635](https://github.com/changesets/changesets/issues/635) — prerelease workflow problems -- [changesets#239](https://github.com/changesets/changesets/issues/239) — prerelease mode design issues - ### Root workspace / non-package changes Track changes to CI, tooling, and monorepo-root-level config in changelogs — not just workspace packages. diff --git a/docs/prereleases.md b/docs/prereleases.md index 9c3cbb7..ad87816 100644 --- a/docs/prereleases.md +++ b/docs/prereleases.md @@ -1,7 +1,5 @@ # 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 the ordinary stable release flow takes over. @@ -139,6 +137,8 @@ on: That's the only workflow change. `bumpy ci release` reads the current branch, looks up the channel in `_config.json`, and behaves accordingly. +> Make sure the checkout step uses `fetch-depth: 0` (the [release workflow](github-actions.md) already requires this) — the channel publish trigger diffs the triggering push to detect release PR merges. + > The PR check workflow (`bumpy-check.yaml`) needs no changes — it runs on `pull_request_target` and handles any base branch. --- @@ -169,14 +169,14 @@ When a feature PR merges to `next`: When a maintainer merges that PR: 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. +5. Git tags (`my-package@1.2.0-rc.0`) 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. ```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. +> **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 overtakes the cycle's target), publish uses the recomputed versions and warns about the retarget in its logs. 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. @@ -186,7 +186,7 @@ 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` 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. +- Merge → publish computes `1.2.0-rc.1` and republishes the cycle. Each package's `rc.1` GitHub release carries notes from the cycle's bump files that touched it (`feature-y.md` shows 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. @@ -229,9 +229,9 @@ How `bumpy publish` (and the publish half of `bumpy ci release`) works on a chan **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. -**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. +**Trigger** — in CI, publish fires when the triggering push added files to `.bumpy//` (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](github-actions.md) needs anyway. Running `bumpy publish` manually on the channel branch always publishes the cycle (manual = explicit intent). -**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. +**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. @@ -248,7 +248,7 @@ How `bumpy publish` (and the publish half of `bumpy ci release`) works on a chan 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. +- **Per-rc notes** go to GitHub releases (marked prerelease), built per package from the cycle's bump files that touched it. - **`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. @@ -323,7 +323,7 @@ There's no channel-specific opt-out of the cascade — disabling it would produc | `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 | +| `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 ` for local testing: diff --git a/packages/bumpy/config-schema.json b/packages/bumpy/config-schema.json index e1efe9f..35b774d 100644 --- a/packages/bumpy/config-schema.json +++ b/packages/bumpy/config-schema.json @@ -202,6 +202,49 @@ } }, "additionalProperties": false + }, + "channels": { + "type": "object", + "description": "Prerelease channels, keyed by channel name. Each maps a long-lived branch to a prerelease line (version suffix + npm dist-tag). Prerelease versions are derived at publish time and never committed.", + "additionalProperties": { + "type": "object", + "properties": { + "branch": { + "type": "string", + "description": "Branch that triggers this channel (required)" + }, + "preid": { + "type": "string", + "description": "Version suffix (preid), e.g. \"rc\" produces 1.2.0-rc.0. Defaults to the channel name." + }, + "tag": { + "type": "string", + "description": "npm dist-tag for publishes. Defaults to the channel name." + }, + "versionPr": { + "type": "object", + "description": "Release PR overrides for this channel", + "properties": { + "title": { + "type": "string", + "description": "Release PR title. Defaults to \" ()\"." + }, + "branch": { + "type": "string", + "description": "Release PR branch. Defaults to \"-\"." + }, + "automerge": { + "type": "boolean", + "description": "Enable auto-merge on the release PR", + "default": false + } + }, + "additionalProperties": false + } + }, + "required": ["branch"], + "additionalProperties": false + } } }, "additionalProperties": false, diff --git a/packages/bumpy/src/cli.ts b/packages/bumpy/src/cli.ts index fdd4852..4b55c24 100644 --- a/packages/bumpy/src/cli.ts +++ b/packages/bumpy/src/cli.ts @@ -60,6 +60,7 @@ async function main() { bumpType: flags.bump as string | undefined, filter: flags.filter as string | undefined, verbose: flags.verbose === true, + channel: flags.channel as string | undefined, }); break; } @@ -69,6 +70,7 @@ async function main() { const { versionCommand } = await import('./commands/version.ts'); await versionCommand(rootDir, { commit: flags.commit === true, + channel: flags.channel as string | undefined, }); break; } @@ -96,6 +98,7 @@ async function main() { strict: flags.strict === true, noFail: flags['no-fail'] === true, hook: hookValue as 'pre-commit' | 'pre-push' | undefined, + base: flags.base as string | undefined, }); break; } @@ -154,6 +157,7 @@ async function main() { tag: flags.tag as string | undefined, noPush: flags['no-push'] === true, filter: flags.filter as string | undefined, + channel: flags.channel as string | undefined, }); break; } @@ -215,8 +219,11 @@ function printHelp() { --strict Fail if any changed package is uncovered (default: only fail if no bump files at all) --no-fail Warn only, never exit 1 --hook Hook context: "pre-commit" or "pre-push" (controls which bump files count) + --base Branch to compare against (default: baseBranch; use the channel branch for channel PRs) version [--commit] Apply bump files and bump versions + (on a channel branch: moves pending bump files into .bumpy//) publish Publish versioned packages + (on a channel branch: derives prerelease versions and publishes to the channel dist-tag) ci check PR check — report pending releases, comment on PR ci plan Report what ci release would do (JSON + GitHub Actions outputs) ci release Release — create version PR or auto-publish @@ -240,12 +247,18 @@ function printHelp() { --bump Filter by bump type (e.g., "major", "minor,patch") --filter Filter by package name/glob (e.g., "@myorg/*") --verbose Show bump file details + --channel Show channel status (default: inferred from the current branch) Publish options: --dry-run Preview without publishing --tag npm dist-tag (e.g., "next", "beta") --no-push Skip pushing git tags to remote --filter Publish only matching packages (e.g., "@myorg/*") + --channel Publish a prerelease channel (default: inferred from the current branch) + + Version options: + --commit Create a git commit with the version changes + --channel Channel override (default: inferred from the current branch) CI check options: --comment Force PR comment on/off (auto-detected in CI) diff --git a/packages/bumpy/src/commands/check.ts b/packages/bumpy/src/commands/check.ts index 3a91b7b..dd15b32 100644 --- a/packages/bumpy/src/commands/check.ts +++ b/packages/bumpy/src/commands/check.ts @@ -22,6 +22,8 @@ interface CheckOptions { strict?: boolean; noFail?: boolean; hook?: HookContext; + /** Branch to compare against (default: baseBranch). Use for feature branches targeting a channel branch. */ + base?: string; } /** @@ -39,8 +41,24 @@ export async function checkCommand(rootDir: string, opts: CheckOptions = {}): Pr const config = await loadConfig(rootDir); const { packages } = await discoverWorkspace(rootDir, config); + // Channel branches and release PR branches move/consume bump files by design — + // checking them against baseBranch would produce false failures. + const { resolveChannels, detectReleaseBranch } = await import('../core/channels.ts'); + const currentBranch = detectReleaseBranch(rootDir); + if (currentBranch) { + const skipBranches = new Set([config.versionPr.branch]); + for (const channel of resolveChannels(config).values()) { + skipBranches.add(channel.branch); + skipBranches.add(channel.versionPr.branch); + } + if (skipBranches.has(currentBranch)) { + log.dim(` Skipping check — "${currentBranch}" is a channel or release PR branch.`); + return; + } + } + // Find which files have changed on this branch vs base - const baseBranch = config.baseBranch; + const baseBranch = opts.base || config.baseBranch; const changedFiles = getChangedFiles(rootDir, baseBranch); if (changedFiles.length === 0) { diff --git a/packages/bumpy/src/commands/ci.ts b/packages/bumpy/src/commands/ci.ts index b013051..73f24f0 100644 --- a/packages/bumpy/src/commands/ci.ts +++ b/packages/bumpy/src/commands/ci.ts @@ -6,6 +6,14 @@ import { DependencyGraph } from '../core/dep-graph.ts'; import { readBumpFiles, filterBranchBumpFiles, recoverDeletedBumpFiles } from '../core/bump-file.ts'; import { getChangedFiles, withGitToken } from '../core/git.ts'; import { assembleReleasePlan } from '../core/release-plan.ts'; +import { + channelNames, + detectReleaseBranch, + matchChannelByBranch, + resolveChannels, + type ResolvedChannel, +} from '../core/channels.ts'; +import { buildChannelReleasePlan, formatChannelVersionSummary } from '../core/prerelease.ts'; import { runArgs, runArgsAsync, tryRunArgs } from '../utils/shell.ts'; import { randomName } from '../utils/names.ts'; import { detectPackageManager } from '../utils/package-manager.ts'; @@ -91,10 +99,15 @@ export async function ciCheckCommand(rootDir: string, opts: CheckOptions): Promi const depGraph = new DependencyGraph(packages); const { bumpFiles: allBumpFiles, errors: parseErrors } = await readBumpFiles(rootDir); - // Skip on the version PR branch — it has no bump files by design + // Skip on the version PR branch (and channel release PR branches) — they move/consume + // bump files by design const prBranchName = detectPrBranch(rootDir); - if (prBranchName === config.versionPr.branch) { - log.dim(' Skipping — this is the version PR branch.'); + const releasePrBranches = new Set([ + config.versionPr.branch, + ...[...resolveChannels(config).values()].map((c) => c.versionPr.branch), + ]); + if (prBranchName && releasePrBranches.has(prBranchName)) { + log.dim(' Skipping — this is a release PR branch.'); return; } @@ -103,8 +116,11 @@ export async function ciCheckCommand(rootDir: string, opts: CheckOptions): Promi const prNumber = detectPrNumber(); const pm = await detectPackageManager(rootDir); - // Filter to only bump files added/modified in this PR - const changedFiles = getChangedFiles(rootDir, config.baseBranch); + // Filter to only bump files added/modified in this PR. + // For PRs targeting a channel branch, compare against that branch (GITHUB_BASE_REF), + // not baseBranch — otherwise the whole cycle's changes would show up. + const compareBranch = process.env.GITHUB_BASE_REF || config.baseBranch; + const changedFiles = getChangedFiles(rootDir, compareBranch); const { branchBumpFiles: prBumpFiles, emptyBumpFileIds } = filterBranchBumpFiles( allBumpFiles, changedFiles, @@ -255,7 +271,8 @@ export async function ciPlanCommand(rootDir: string): Promise { const config = await loadConfig(rootDir); const { packages } = await discoverWorkspace(rootDir, config); const depGraph = new DependencyGraph(packages); - const { bumpFiles, errors: parseErrors } = await readBumpFiles(rootDir); + // Channel-dir bump files count as pending on the base branch (promotion consumes them) + const { bumpFiles, errors: parseErrors } = await readBumpFiles(rootDir, { channels: channelNames(config) }); if (parseErrors.length > 0) { for (const err of parseErrors) { @@ -264,6 +281,13 @@ export async function ciPlanCommand(rootDir: string): Promise { throw new Error('Bump file parse errors must be fixed before planning.'); } + // On a channel branch, report the channel plan instead + const channel = matchChannelByBranch(config, detectReleaseBranch(rootDir)); + if (channel) { + await ciChannelPlan(rootDir, config, channel, packages, depGraph, bumpFiles); + return; + } + let output: PlanOutput; // Assemble plan from bump files (if any) @@ -390,9 +414,29 @@ interface ReleaseOptions { export async function ciReleaseCommand(rootDir: string, opts: ReleaseOptions): Promise { const config = await loadConfig(rootDir); ensureGitIdentity(rootDir, config); + + // Channel branches get the channel flow; unknown branches are refused (when channels + // are configured) so a misconfigured workflow can't publish from a feature branch. + const releaseBranch = detectReleaseBranch(rootDir); + const channel = matchChannelByBranch(config, releaseBranch); + if (channel) { + await ciChannelRelease(rootDir, config, channel, opts); + return; + } + if (Object.keys(config.channels || {}).length > 0 && releaseBranch && releaseBranch !== config.baseBranch) { + throw new Error( + `"bumpy ci release" ran on branch "${releaseBranch}", which is neither the base branch ` + + `("${config.baseBranch}") nor a configured channel branch. Refusing to release — ` + + 'add the branch to "channels" in .bumpy/_config.json or fix the workflow trigger.', + ); + } + const { packages } = await discoverWorkspace(rootDir, config); const depGraph = new DependencyGraph(packages); - const { bumpFiles, errors: releaseParseErrors } = await readBumpFiles(rootDir); + // Channel-dir bump files count as pending on the base branch — merging a channel + // into main brings its shipped files along, and the stable release consumes them + // (promotion). No special mode needed. + const { bumpFiles, errors: releaseParseErrors } = await readBumpFiles(rootDir, { channels: channelNames(config) }); if (releaseParseErrors.length > 0) { for (const err of releaseParseErrors) { @@ -617,6 +661,331 @@ async function createVersionPr( runArgs(['git', 'checkout', baseBranch], { cwd: rootDir }); } +// ---- channel (prerelease) release flow ---- + +/** Read the push event's before/after range, if running on a GitHub Actions push event */ +function getPushEventRange(): { before: string; after: string } | null { + if (process.env.GITHUB_EVENT_NAME !== 'push') return null; + const path = process.env.GITHUB_EVENT_PATH; + if (!path) return null; + try { + const payload = JSON.parse(readFileSync(path, 'utf-8')) as { before?: string; after?: string }; + // "before" is all zeros for branch-creation pushes — no usable range + if (payload.before && payload.after && !/^0+$/.test(payload.before)) { + return { before: payload.before, after: payload.after }; + } + } catch { + // fall through + } + return null; +} + +/** + * Bump file IDs added to `.bumpy//` by the push that triggered this run. + * + * This is the channel publish trigger: merging the release PR moves files into the + * channel dir; ordinary feature merges don't touch it. Re-running on the same push is + * idempotent — packages already published from this commit are skipped via the + * gitHead recorded on the registry. + */ +function detectChannelMoves(rootDir: string, channel: ResolvedChannel): string[] { + const range = getPushEventRange(); + let diffRange: string; + if (range) { + diffRange = `${range.before}..${range.after}`; + } else { + if (!tryRunArgs(['git', 'rev-parse', '--verify', 'HEAD^'], { cwd: rootDir })) { + log.warn( + 'Cannot diff against the previous commit (shallow clone?) — channel publish trigger unavailable.\n' + + ' Use `fetch-depth: 0` in your checkout step, or run `bumpy publish` manually on the channel branch.', + ); + return []; + } + diffRange = 'HEAD^..HEAD'; + } + + // --no-renames so file moves into the channel dir show up as additions + const out = tryRunArgs( + ['git', 'diff', '--name-only', '--diff-filter=A', '--no-renames', diffRange, '--', `.bumpy/${channel.name}/`], + { cwd: rootDir }, + ); + if (!out) return []; + return out + .split('\n') + .filter((f) => f.endsWith('.md') && !f.endsWith('README.md')) + .map((f) => f.split('/').pop()!.replace(/\.md$/, '')); +} + +/** + * CI release on a channel branch. Two independent steps, both of which can run + * in the same invocation: + * + * 1. **Publish** — if this push moved bump files into `.bumpy//` (a release + * PR merge), publish the cycle as prereleases. Versions are derived (targets from + * bump files, counters from the registry) and never committed. + * 2. **Release PR** — if pending bump files exist (root or other channels' dirs), + * create/update the file-move release PR. + */ +async function ciChannelRelease( + rootDir: string, + config: BumpyConfig, + channel: ResolvedChannel, + opts: ReleaseOptions, +): Promise { + log.bold(`Channel "${channel.name}" (branch "${channel.branch}")\n`); + const { packages } = await discoverWorkspace(rootDir, config); + const { bumpFiles, errors: parseErrors } = await readBumpFiles(rootDir, { channels: channelNames(config) }); + if (parseErrors.length > 0) { + for (const err of parseErrors) log.error(err); + throw new Error('Bump file parse errors must be fixed before releasing.'); + } + + const pending = bumpFiles.filter((bf) => bf.channel !== channel.name); + + if (opts.autoPublish) { + // Skip the release PR: move pending files, commit and push directly to the + // channel branch, then publish. (The push re-triggers CI; the re-run is a no-op + // thanks to the published-from-HEAD skip.) + if (pending.length > 0) { + const { channelVersion } = await import('./version.ts'); + const result = await channelVersion(rootDir, config, channel, { commit: true }); + if (result) { + runArgs(['git', 'push', '--no-verify'], { cwd: rootDir }); + } + } + const { publishCommand } = await import('./publish.ts'); + await publishCommand(rootDir, { channel: channel.name, tag: opts.tag }); + return; + } + + // Step 1: publish if this push merged a release PR (moved files into the channel dir) + const movedIds = detectChannelMoves(rootDir, channel); + const shouldPublish = movedIds.length > 0 && opts.assertMode !== 'version-pr'; + if (shouldPublish) { + log.step(`Release PR merge detected (${movedIds.map((id) => `${id}.md`).join(', ')}) — publishing prereleases...`); + const { publishCommand } = await import('./publish.ts'); + await publishCommand(rootDir, { channel: channel.name, tag: opts.tag }); + } + + if (opts.assertMode === 'publish') { + if (!shouldPublish) { + throw new Error( + 'Expected mode "publish" but this push did not move bump files into the channel dir. ' + + 'Either remove --expect-mode, or gate this step on the output of "bumpy ci plan".', + ); + } + return; + } + + // Step 2: create/update the release PR for pending bump files + if (pending.length > 0) { + await createChannelReleasePr(rootDir, config, channel, packages, opts.branch); + } else if (!shouldPublish) { + log.info(`Nothing to do on channel "${channel.name}" — no pending bump files, no release PR merge in this push.`); + } +} + +/** + * Create or update the channel's release PR. Unlike the stable version PR, its diff + * is pure file moves (pending bump files → `.bumpy//`) — no versions, no + * changelogs. Computed prerelease versions appear in the PR title and body as + * point-in-time narrative; the registry wins at publish time. + */ +async function createChannelReleasePr( + rootDir: string, + config: BumpyConfig, + channel: ResolvedChannel, + packages: Map, + branchOverride?: string, +): Promise { + const branch = validateBranchName(branchOverride || channel.versionPr.branch); + const baseBranch = validateBranchName(channel.branch); + + // Check if a release PR already exists + const existingPr = tryRunArgs(['gh', 'pr', 'list', '--head', branch, '--json', 'number', '--jq', '.[0].number'], { + cwd: rootDir, + }); + + log.step(`Creating branch ${branch}...`); + const branchExists = tryRunArgs(['git', 'rev-parse', '--verify', branch], { cwd: rootDir }) !== null; + if (branchExists) { + runArgs(['git', 'checkout', branch], { cwd: rootDir }); + runArgs(['git', 'reset', '--hard', baseBranch], { cwd: rootDir }); + } else { + runArgs(['git', 'checkout', '-b', branch], { cwd: rootDir }); + } + + // Move pending bump files into the channel dir (the entire "version" step for a channel) + const { channelVersion } = await import('./version.ts'); + const result = await channelVersion(rootDir, config, channel); + if (!result) { + log.info('No pending bump files to move.'); + runArgs(['git', 'checkout', baseBranch], { cwd: rootDir }); + return; + } + + // Compute prerelease versions for the PR title/body. Best-effort — these are + // narrative (recomputed at publish time); offline we fall back to target-".?". + let displayPlan: ReleasePlan = result.cyclePlan; + let displayIsExact = false; + try { + const built = await buildChannelReleasePlan(result.cyclePlan, channel, packages, rootDir, { forDisplay: true }); + if (built.plan.releases.length > 0) { + displayPlan = built.plan; + displayIsExact = true; + } + } catch { + // registry unavailable — keep stable targets + } + if (!displayIsExact) { + displayPlan = { + ...displayPlan, + releases: displayPlan.releases.map((r) => ({ ...r, newVersion: `${r.newVersion}-${channel.preid}.?` })), + }; + } + + const versionSummary = formatChannelVersionSummary(displayPlan.releases); + const prTitle = versionSummary ? `${channel.versionPr.title}: ${versionSummary}` : channel.versionPr.title; + + // Commit the moves — the computed versions live in the commit message, so + // `git log` on the channel branch reads as a release history + runArgs(['git', 'add', '-A', '.bumpy/'], { cwd: rootDir }); + const status = tryRunArgs(['git', 'status', '--porcelain'], { cwd: rootDir }); + if (!status) { + log.info('No changes to commit.'); + runArgs(['git', 'checkout', baseBranch], { cwd: rootDir }); + return; + } + const commitMsg = `${prTitle}\n\nShipped: ${result.movedFiles.map((bf) => `${bf.id}.md`).join(', ')}`; + runArgs(['git', 'commit', '-F', '-'], { cwd: rootDir, input: commitMsg }); + + pushWithToken(rootDir, branch, config); + + const repo = process.env.GITHUB_REPOSITORY; + const noPatWarning = !process.env.BUMPY_GH_TOKEN && !!repo; + const packageDirs = new Map([...packages.values()].map((p) => [p.name, p.relativeDir])); + const preamble = buildChannelPrPreamble(config, channel); + + let prNumber: string | null = null; + if (existingPr) { + prNumber = validatePrNumber(existingPr); + const prBody = formatVersionPrBody(displayPlan, preamble, packageDirs, repo, prNumber, noPatWarning); + log.step(`Updating existing PR #${prNumber}...`); + await withPatToken(() => + runArgsAsync(['gh', 'pr', 'edit', prNumber!, '--title', prTitle, '--body-file', '-'], { + cwd: rootDir, + input: prBody, + }), + ); + log.success(`🐸 Updated PR #${prNumber}`); + } else { + log.step('Creating release PR...'); + const prBody = formatVersionPrBody(displayPlan, preamble, packageDirs, repo, null, noPatWarning); + const createResult = await withPatToken(() => + runArgsAsync( + ['gh', 'pr', 'create', '--title', prTitle, '--body-file', '-', '--base', baseBranch, '--head', branch], + { cwd: rootDir, input: prBody }, + ), + ); + log.success(`🐸 Created PR: ${createResult}`); + + prNumber = createResult?.match(/\/pull\/(\d+)/)?.[1] ?? null; + if (repo && prNumber) { + const updatedBody = formatVersionPrBody(displayPlan, preamble, packageDirs, repo, prNumber, noPatWarning); + await withPatToken(() => + runArgsAsync(['gh', 'pr', 'edit', prNumber!, '--body-file', '-'], { + cwd: rootDir, + input: updatedBody, + }), + ); + } + + if (!process.env.BUMPY_GH_TOKEN) { + // Push again with the custom token now that the PR exists (see createVersionPr) + pushWithToken(rootDir, branch, config); + } + } + + if (channel.versionPr.automerge && prNumber) { + await enableAutoMerge(rootDir, prNumber); + } + + // Switch back to the channel branch + runArgs(['git', 'checkout', baseBranch], { cwd: rootDir }); +} + +function buildChannelPrPreamble(config: BumpyConfig, channel: ResolvedChannel): string { + return [ + config.versionPr.preamble, + '', + `> 🔀 **Prerelease channel \`${channel.name}\`** — merging this PR publishes the versions below to the \`@${channel.tag}\` dist-tag.`, + `> The diff only moves bump files into \`.bumpy/${channel.name}/\` — prerelease versions are derived at publish time and never committed. Version numbers shown here are estimates; the registry wins at publish.`, + ].join('\n'); +} + +/** Enable GitHub auto-merge on a PR, trying the available merge methods in order */ +async function enableAutoMerge(rootDir: string, prNumber: string): Promise { + const validPr = validatePrNumber(prNumber); + for (const method of ['--squash', '--merge', '--rebase']) { + try { + await withPatToken(() => runArgsAsync(['gh', 'pr', 'merge', validPr, '--auto', method], { cwd: rootDir })); + log.dim(` Auto-merge enabled (${method.slice(2)})`); + return; + } catch { + // method not allowed on this repo — try the next one + } + } + log.warn(' Failed to enable auto-merge — check repository merge settings and token permissions.'); +} + +/** Channel-aware `ci plan`: reports what `ci release` would do on this channel branch */ +async function ciChannelPlan( + rootDir: string, + config: BumpyConfig, + channel: ResolvedChannel, + packages: Map, + depGraph: DependencyGraph, + bumpFiles: BumpFile[], +): Promise { + const pending = bumpFiles.filter((bf) => bf.channel !== channel.name); + const movedIds = detectChannelMoves(rootDir, channel); + + let mode: CiPlanMode = 'nothing'; + let releases: PlannedRelease[] = []; + if (pending.length > 0 || movedIds.length > 0) { + mode = pending.length > 0 ? 'version-pr' : 'publish'; + const stablePlan = assembleReleasePlan(bumpFiles, packages, depGraph, config, { + prereleasePreid: channel.preid, + }); + try { + const built = await buildChannelReleasePlan(stablePlan, channel, packages, rootDir, { forDisplay: true }); + releases = built.plan.releases; + } catch { + releases = stablePlan.releases.map((r) => ({ ...r, newVersion: `${r.newVersion}-${channel.preid}.?` })); + } + } + + const output = { + mode, + channel: channel.name, + bumpFiles: bumpFiles.map((bf) => ({ + id: bf.id, + summary: bf.summary, + releases: bf.releases.map((r) => ({ name: r.name, type: r.type })), + shipped: bf.channel === channel.name, + })), + releases: releases.map((r) => formatPlanRelease(r, packages, config)), + packageNames: releases.map((r) => r.name), + }; + + const json = JSON.stringify(output, null, 2); + console.log(json); + writeGitHubOutput('mode', output.mode); + writeGitHubOutput('channel', channel.name); + writeGitHubOutput('packages', JSON.stringify(output.packageNames)); + writeGitHubOutput('json', JSON.stringify(output)); +} + // ---- PR comment helpers ---- const FROG_IMG_BASE = 'https://raw.githubusercontent.com/dmno-dev/bumpy/main/images'; diff --git a/packages/bumpy/src/commands/publish.ts b/packages/bumpy/src/commands/publish.ts index ab2726c..1d7f9ca 100644 --- a/packages/bumpy/src/commands/publish.ts +++ b/packages/bumpy/src/commands/publish.ts @@ -1,9 +1,14 @@ +import semver from 'semver'; import { log, colorize } from '../utils/logger.ts'; import { loadConfig } from '../core/config.ts'; import { discoverWorkspace } from '../core/workspace.ts'; import { DependencyGraph } from '../core/dep-graph.ts'; import { forcePushTag, hasUncommittedChanges, tagExists } from '../core/git.ts'; import { publishPackages, willUseOidcExclusively } from '../core/publish-pipeline.ts'; +import { readBumpFiles } from '../core/bump-file.ts'; +import { assembleReleasePlan } from '../core/release-plan.ts'; +import { channelNames, resolveActiveChannel, type ResolvedChannel } from '../core/channels.ts'; +import { buildChannelReleasePlan, writeChannelVersionsInPlace } from '../core/prerelease.ts'; import { createIndividualReleases, findReleaseByTag, @@ -26,6 +31,8 @@ import { detectWorkspaces } from '../utils/package-manager.ts'; import { CI_PLAN_CACHE_PATH } from './ci.ts'; import { runArgsAsync, tryRunArgs } from '../utils/shell.ts'; import type { BumpyConfig, PackageConfig, ReleasePlan, PlannedRelease, WorkspacePackage } from '../types.ts'; +import type { CatalogMap } from '../utils/package-manager.ts'; +import type { PackageManager } from '../types.ts'; interface PublishCommandOptions { dryRun?: boolean; @@ -33,6 +40,8 @@ interface PublishCommandOptions { noPush?: boolean; /** Filter to specific packages by name/glob (comma-separated) */ filter?: string; + /** Channel name override (otherwise inferred from the current branch) */ + channel?: string; /** Recovered bump files from a version commit — used for GitHub release body generation */ recoveredBumpFiles?: import('../types.ts').BumpFile[]; /** Package names to exclude from publishing (e.g., packages with pending non-none bumps) */ @@ -41,7 +50,13 @@ interface PublishCommandOptions { /** * Publish packages that have been versioned but not yet published. - * Detects unpublished versions by comparing package.json versions against npm registry. + * + * On the base branch: detects unpublished versions by comparing package.json versions + * against the npm registry. + * + * On a channel branch: prerelease versions are never committed, so they are computed + * here — targets from the cycle's bump files, counters from the registry — written + * transiently into the working tree, published to the channel's dist-tag, and restored. */ export async function publishCommand(rootDir: string, opts: PublishCommandOptions): Promise { const config = await loadConfig(rootDir); @@ -54,10 +69,30 @@ export async function publishCommand(rootDir: string, opts: PublishCommandOption process.exit(1); } + const channel = resolveActiveChannel(rootDir, config, opts.channel); + if (channel) { + await publishChannel(rootDir, config, packages, catalogs, detectedPm, depGraph, channel, opts); + return; + } + // Find packages that need publishing — use cached plan from `ci plan` if available, // otherwise query the registry let toPublish = await findUnpublishedWithCache(rootDir, packages, config); + // When channels are configured, prerelease versions must never reach the stable + // flow (they'd land on @latest). With the no-commit model this can't normally + // happen — committed versions are always stable — so a suffixed version here + // means something went wrong. Refuse loudly rather than publish it. + if (Object.keys(config.channels || {}).length > 0) { + const prereleases = toPublish.filter((r) => semver.prerelease(r.newVersion) !== null); + if (prereleases.length > 0) { + log.error('Refusing to publish prerelease versions outside a channel:'); + for (const r of prereleases) log.error(` • ${r.name}@${r.newVersion}`); + log.error('Prerelease versions should never be committed — see https://bumpy.varlock.dev/docs/prereleases'); + process.exit(1); + } + } + // Exclude packages with pending non-none bumps (they'll be superseded by the next version PR) if (opts.excludePackages && opts.excludePackages.size > 0) { const excluded = toPublish.filter((r) => opts.excludePackages!.has(r.name)); @@ -98,6 +133,115 @@ export async function publishCommand(rootDir: string, opts: PublishCommandOption warnings: [], }; + await runPublishFlow(rootDir, config, packages, catalogs, detectedPm, depGraph, releasePlan, { + dryRun: opts.dryRun, + tag: opts.tag, + noPush: opts.noPush, + }); +} + +/** + * Publish a prerelease cycle from a channel branch. + * + * The cycle = every bump file on the branch (pending at root or in other channels' + * dirs, plus shipped in this channel's dir). The whole cycle republishes together + * each time so the channel dist-tag always points at one coherent, exact-pinned set. + */ +async function publishChannel( + rootDir: string, + config: BumpyConfig, + packages: Map, + catalogs: CatalogMap, + detectedPm: PackageManager, + depGraph: DependencyGraph, + channel: ResolvedChannel, + opts: PublishCommandOptions, +): Promise { + const { bumpFiles, errors: parseErrors } = await readBumpFiles(rootDir, { channels: channelNames(config) }); + if (parseErrors.length > 0) { + for (const err of parseErrors) log.error(err); + process.exit(1); + } + + const shipped = bumpFiles.filter((bf) => bf.channel === channel.name); + if (shipped.length === 0) { + log.info( + `Nothing has shipped on channel "${channel.name}" yet (no bump files in .bumpy/${channel.name}/).\n` + + ` Run \`bumpy version\` on the channel branch (or merge the release PR) first.`, + ); + return; + } + + log.bold(`Channel "${channel.name}" — preid "-${channel.preid}.N", dist-tag @${channel.tag}\n`); + + // Targets from the full cycle's bump files; counters from the registry. + const stablePlan = assembleReleasePlan(bumpFiles, packages, depGraph, config, { + prereleasePreid: channel.preid, + }); + const { plan, alreadyPublished, warnings } = await buildChannelReleasePlan(stablePlan, channel, packages, rootDir); + + for (const w of warnings) log.warn(w); + for (const skip of alreadyPublished) { + log.dim(` Skipping ${skip.name}@${skip.version} — already published from this commit`); + } + + if (plan.releases.length === 0) { + log.info('All cycle packages already published from this commit.'); + return; + } + + // Filter only restricts what gets *published* — the in-place rewrite below still + // covers the whole plan so dependency pins stay consistent (used for partial-failure resume). + let toPublish = plan.releases; + if (opts.filter) { + const { matchGlob } = await import('../core/config.ts'); + const patterns = opts.filter.split(',').map((p) => p.trim()); + toPublish = toPublish.filter((r) => patterns.some((p) => matchGlob(r.name, p))); + if (toPublish.length === 0) { + log.info('No cycle packages match the filter.'); + return; + } + } + + // Transiently write computed versions + exact pins into the working tree so + // pack/build see them; always restored afterwards — prereleases never land in git. + let restore: (() => Promise) | null = null; + if (!opts.dryRun) { + restore = await writeChannelVersionsInPlace(plan, packages); + } + + try { + const publishPlan: ReleasePlan = { bumpFiles: plan.bumpFiles, releases: toPublish, warnings: [] }; + await runPublishFlow(rootDir, config, packages, catalogs, detectedPm, depGraph, publishPlan, { + dryRun: opts.dryRun, + tag: opts.tag ?? channel.tag, + noPush: opts.noPush, + }); + } finally { + if (restore) { + await restore(); + log.dim(' Restored package.json files (prerelease versions are not committed)'); + } + } +} + +/** + * The shared publish flow: OIDC checks, draft GitHub releases, topological publish, + * release metadata updates, tag pushes. Used by both the stable and channel paths. + * Mutates `releasePlan.releases` as packages are filtered out (already published, etc.). + */ +async function runPublishFlow( + rootDir: string, + config: BumpyConfig, + packages: Map, + catalogs: CatalogMap, + detectedPm: PackageManager, + depGraph: DependencyGraph, + releasePlan: ReleasePlan, + opts: { dryRun?: boolean; tag?: string; noPush?: boolean }, +): Promise { + let toPublish = releasePlan.releases; + if (opts.dryRun) { log.bold('Dry run — would publish:'); } else { @@ -188,7 +332,9 @@ export async function publishCommand(rootDir: string, opts: PublishCommandOption const headSha = getHeadSha(rootDir); try { - await createDraftRelease(tag, title, body, rootDir, headSha || undefined); + await createDraftRelease(tag, title, body, rootDir, headSha || undefined, { + prerelease: semver.prerelease(release.newVersion) !== null, + }); log.dim(` Created draft release: ${title}`); releaseMetadataByPkg.set(release.name, { tag, metadata, existingBody: body }); } catch (err) { diff --git a/packages/bumpy/src/commands/status.ts b/packages/bumpy/src/commands/status.ts index 12af0fa..8333ecd 100644 --- a/packages/bumpy/src/commands/status.ts +++ b/packages/bumpy/src/commands/status.ts @@ -5,7 +5,9 @@ import { DependencyGraph } from '../core/dep-graph.ts'; import { readBumpFiles, filterBranchBumpFiles } from '../core/bump-file.ts'; import { assembleReleasePlan } from '../core/release-plan.ts'; import { getCurrentBranch, getChangedFiles } from '../core/git.ts'; -import type { BumpyConfig, PackageConfig, PlannedRelease, WorkspacePackage } from '../types.ts'; +import { channelNames, resolveActiveChannel, type ResolvedChannel } from '../core/channels.ts'; +import { buildChannelReleasePlan } from '../core/prerelease.ts'; +import type { BumpFile, BumpyConfig, PackageConfig, PlannedRelease, WorkspacePackage } from '../types.ts'; interface StatusOptions { json?: boolean; @@ -17,13 +19,23 @@ interface StatusOptions { filter?: string; /** Show verbose output including bump file details */ verbose?: boolean; + /** Channel name override (otherwise inferred from the current branch) */ + channel?: string; } export async function statusCommand(rootDir: string, opts: StatusOptions): Promise { const config = await loadConfig(rootDir); const packages = await discoverPackages(rootDir, config); const depGraph = new DependencyGraph(packages); - const { bumpFiles, errors: parseErrors } = await readBumpFiles(rootDir); + + const channel = resolveActiveChannel(rootDir, config, opts.channel); + if (channel) { + await channelStatus(rootDir, config, channel, packages, depGraph, opts); + return; + } + + // Channel-dir bump files count as pending on the base branch (promotion) + const { bumpFiles, errors: parseErrors } = await readBumpFiles(rootDir, { channels: channelNames(config) }); if (parseErrors.length > 0) { for (const err of parseErrors) { @@ -150,6 +162,130 @@ export async function statusCommand(rootDir: string, opts: StatusOptions): Promi } } +/** + * Status on a prerelease channel: shows the cycle (shipped + pending bump files) + * and the derived prerelease versions. Counters come from the registry — when it's + * unreachable, targets render with a ".?" counter placeholder. + */ +async function channelStatus( + rootDir: string, + config: BumpyConfig, + channel: ResolvedChannel, + packages: Map, + depGraph: DependencyGraph, + opts: StatusOptions, +): Promise { + const { bumpFiles, errors: parseErrors } = await readBumpFiles(rootDir, { channels: channelNames(config) }); + for (const err of parseErrors) log.error(err); + + const shipped = bumpFiles.filter((bf) => bf.channel === channel.name); + const pending = bumpFiles.filter((bf) => bf.channel !== channel.name); + + if (bumpFiles.length === 0) { + if (opts.json) { + console.log(JSON.stringify({ channel: channel.name, bumpFiles: [], releases: [], packageNames: [] }, null, 2)); + } else if (!opts.packagesOnly) { + log.info(`No bump files in the "${channel.name}" cycle.`); + } + process.exit(1); // exit 1 = no releases pending (useful for CI) + } + + const stablePlan = assembleReleasePlan(bumpFiles, packages, depGraph, config, { + prereleasePreid: channel.preid, + }); + let releases = stablePlan.releases; + let countersExact = false; + try { + const built = await buildChannelReleasePlan(stablePlan, channel, packages, rootDir, { forDisplay: true }); + if (built.plan.releases.length > 0) { + releases = built.plan.releases; + countersExact = true; + } + } catch { + // registry unreachable — fall through to ".?" display + } + if (!countersExact) { + releases = releases.map((r) => ({ ...r, newVersion: `${r.newVersion}-${channel.preid}.?` })); + } + + if (opts.bumpType) { + const types = opts.bumpType.split(',').map((t) => t.trim()); + releases = releases.filter((r) => types.includes(r.type)); + } + if (opts.filter) { + const { matchGlob } = await import('../core/config.ts'); + const patterns = opts.filter.split(',').map((p) => p.trim()); + releases = releases.filter((r) => patterns.some((p) => matchGlob(r.name, p))); + } + + if (opts.json) { + console.log( + JSON.stringify( + { + channel: channel.name, + preid: channel.preid, + tag: channel.tag, + bumpFiles: bumpFiles.map((bf) => ({ + id: bf.id, + summary: bf.summary, + releases: bf.releases.map((r) => ({ name: r.name, type: r.type })), + shipped: bf.channel === channel.name, + })), + releases: releases.map((r) => { + const pkg = packages.get(r.name); + return { + name: r.name, + type: r.type, + oldVersion: r.oldVersion, + newVersion: r.newVersion, + dir: pkg?.relativeDir, + bumpFiles: r.bumpFiles, + isDependencyBump: r.isDependencyBump, + isCascadeBump: r.isCascadeBump, + publishTargets: getPublishTargets(pkg, pkg?.bumpy || {}, config), + }; + }), + packageNames: releases.map((r) => r.name), + }, + null, + 2, + ), + ); + return; + } + + if (opts.packagesOnly) { + for (const r of releases) console.log(r.name); + return; + } + + log.bold(`Channel "${channel.name}" — preid "-${channel.preid}.N", dist-tag @${channel.tag}\n`); + printBumpFileGroup(`Shipped on this channel (.bumpy/${channel.name}/)`, shipped); + printBumpFileGroup('Pending (next prerelease)', pending); + + log.bold(`Cycle releases${countersExact ? '' : colorize(' (registry unreachable — counters unknown)', 'dim')}`); + for (const r of releases) { + printRelease(r, packages); + } + console.log(); + + if (stablePlan.warnings.length > 0) { + for (const w of stablePlan.warnings) log.warn(w); + } +} + +function printBumpFileGroup(label: string, files: BumpFile[]): void { + log.bold(label); + if (files.length === 0) { + console.log(colorize(' (none)', 'dim')); + } + for (const bf of files) { + const summary = bf.summary ? colorize(` — ${bf.summary.split('\n')[0]}`, 'dim') : ''; + console.log(` ${colorize(`${bf.id}.md`, 'cyan')}${summary}`); + } + console.log(); +} + function printRelease(r: PlannedRelease, packages: Map) { const pkg = packages.get(r.name); const dir = pkg ? colorize(` (${pkg.relativeDir})`, 'dim') : ''; diff --git a/packages/bumpy/src/commands/version.ts b/packages/bumpy/src/commands/version.ts index a938ed5..869e620 100644 --- a/packages/bumpy/src/commands/version.ts +++ b/packages/bumpy/src/commands/version.ts @@ -2,22 +2,35 @@ import { log, colorize } from '../utils/logger.ts'; import { loadConfig } from '../core/config.ts'; import { discoverPackages } from '../core/workspace.ts'; import { DependencyGraph } from '../core/dep-graph.ts'; -import { readBumpFiles } from '../core/bump-file.ts'; +import { readBumpFiles, moveBumpFilesToChannel } from '../core/bump-file.ts'; import { assembleReleasePlan } from '../core/release-plan.ts'; import { applyReleasePlan } from '../core/apply-release-plan.ts'; +import { channelNames, resolveActiveChannel, type ResolvedChannel } from '../core/channels.ts'; import { runArgs, tryRunArgs } from '../utils/shell.ts'; import { detectWorkspaces } from '../utils/package-manager.ts'; import { resolveCommitMessage } from '../core/commit-message.ts'; +import type { BumpyConfig, BumpFile, ReleasePlan } from '../types.ts'; interface VersionOptions { commit?: boolean; + /** Channel name override (otherwise inferred from the current branch) */ + channel?: string; } export async function versionCommand(rootDir: string, opts: VersionOptions = {}): Promise { const config = await loadConfig(rootDir); + + const channel = resolveActiveChannel(rootDir, config, opts.channel); + if (channel) { + await channelVersion(rootDir, config, channel, { commit: opts.commit }); + return; + } + const packages = await discoverPackages(rootDir, config); const depGraph = new DependencyGraph(packages); - const { bumpFiles, errors: parseErrors } = await readBumpFiles(rootDir); + // Include channel subdirs — bump files that shipped as prereleases are pending + // for the stable release (promotion consumes them). + const { bumpFiles, errors: parseErrors } = await readBumpFiles(rootDir, { channels: channelNames(config) }); if (parseErrors.length > 0) { for (const err of parseErrors) { @@ -85,6 +98,87 @@ export async function versionCommand(rootDir: string, opts: VersionOptions = {}) } } +export interface ChannelVersionResult { + /** The full cycle plan (pending + shipped bump files) with stable target versions */ + cyclePlan: ReleasePlan; + /** Bump files that were moved into the channel dir by this run */ + movedFiles: BumpFile[]; +} + +/** + * "Versioning" on a prerelease channel never writes versions or changelogs — those + * are derived at publish time. It only moves pending bump files (root + other + * channels' dirs) into this channel's `.bumpy//` directory, marking them + * as shipped on this channel. + */ +export async function channelVersion( + rootDir: string, + config: BumpyConfig, + channel: ResolvedChannel, + opts: { commit?: boolean } = {}, +): Promise { + const packages = await discoverPackages(rootDir, config); + const depGraph = new DependencyGraph(packages); + const { bumpFiles, errors: parseErrors } = await readBumpFiles(rootDir, { channels: channelNames(config) }); + + if (parseErrors.length > 0) { + for (const err of parseErrors) { + log.error(err); + } + throw new Error('Bump file parse errors must be fixed before versioning.'); + } + + // A bump file is pending for this channel unless it's already in this channel's dir + const pending = bumpFiles.filter((bf) => bf.channel !== channel.name); + + if (pending.length === 0) { + log.info(`No pending bump files for channel "${channel.name}".`); + return null; + } + + // The full cycle (pending + shipped) determines the targets — show them, suffixed. + // Counters come from the registry at publish time, so they render as ".?" here. + const cyclePlan = assembleReleasePlan(bumpFiles, packages, depGraph, config, { + prereleasePreid: channel.preid, + }); + + if (cyclePlan.warnings.length > 0) { + for (const w of cyclePlan.warnings) { + log.warn(w); + } + console.log(); + } + + log.step(`Channel "${channel.name}" — moving ${pending.length} bump file(s) into .bumpy/${channel.name}/:`); + for (const bf of pending) { + const from = bf.channel ? `.bumpy/${bf.channel}/` : '.bumpy/'; + console.log(` ${from}${bf.id}.md → .bumpy/${channel.name}/${bf.id}.md`); + } + console.log(); + log.step('Cycle targets (counters are derived from the registry at publish time):'); + for (const r of cyclePlan.releases) { + const tag = r.isDependencyBump ? ' (dep)' : r.isCascadeBump ? ' (cascade)' : ''; + console.log(` ${r.name}: ${r.oldVersion} → ${colorize(`${r.newVersion}-${channel.preid}.?`, 'cyan')}${tag}`); + } + + await moveBumpFilesToChannel(rootDir, pending, channel.name); + log.success(`🐸 Moved ${pending.length} bump file(s) — no versions written (prereleases are derived, not committed)`); + + if (opts.commit) { + try { + runArgs(['git', 'add', '-A', '.bumpy/'], { cwd: rootDir }); + const summary = pending.map((bf) => `${bf.id}.md`).join(', '); + const msg = `Version prerelease (${channel.name})\n\nShipped: ${summary}`; + runArgs(['git', 'commit', '-F', '-'], { cwd: rootDir, input: msg }); + log.success('Created git commit'); + } catch (e) { + log.warn(`Git commit failed: ${e}`); + } + } + + return { cyclePlan, movedFiles: pending }; +} + /** Run the package manager's install to update the lockfile */ async function updateLockfile(rootDir: string): Promise { const { packageManager } = await detectWorkspaces(rootDir); diff --git a/packages/bumpy/src/core/apply-release-plan.ts b/packages/bumpy/src/core/apply-release-plan.ts index 5127989..19507cf 100644 --- a/packages/bumpy/src/core/apply-release-plan.ts +++ b/packages/bumpy/src/core/apply-release-plan.ts @@ -61,13 +61,28 @@ export async function applyReleasePlan( } } - // 3. Delete all bump files (including empty ones that aren't in the release plan) + // 3. Delete all bump files (including empty ones that aren't in the release plan). + // Channel subdirs (`.bumpy//`) hold bump files that shipped as prereleases; + // a stable release consumes those too (promotion), then removes the empty dirs. const bumpyDir = getBumpyDir(rootDir); const allBumpFiles = await listFiles(bumpyDir, '.md'); for (const file of allBumpFiles) { if (file === 'README.md') continue; await removeFile(resolve(bumpyDir, file)); } + const { rmdir } = await import('node:fs/promises'); + for (const channel of Object.keys(config.channels || {})) { + const channelDir = resolve(bumpyDir, channel); + const channelFiles = await listFiles(channelDir, '.md'); + for (const file of channelFiles) { + await removeFile(resolve(channelDir, file)); + } + try { + await rmdir(channelDir); + } catch { + // dir doesn't exist or has non-bump-file contents — leave it + } + } } /** Update a version range to include a new version, preserving the range prefix */ diff --git a/packages/bumpy/src/core/bump-file.ts b/packages/bumpy/src/core/bump-file.ts index e070169..a9966ae 100644 --- a/packages/bumpy/src/core/bump-file.ts +++ b/packages/bumpy/src/core/bump-file.ts @@ -31,12 +31,21 @@ export interface ReadBumpFilesResult { errors: string[]; } -/** Read all bump files from .bumpy/ directory, sorted by git creation order */ -export async function readBumpFiles(rootDir: string): Promise { +export interface ReadBumpFilesOptions { + /** + * Channel names whose `.bumpy//` subdirectories should also be read. + * Files from those dirs get their `channel` field set; root files don't. + */ + channels?: string[]; +} + +/** Read all bump files from .bumpy/ (and optionally channel subdirs), sorted by git creation order */ +export async function readBumpFiles(rootDir: string, opts: ReadBumpFilesOptions = {}): Promise { const dir = getBumpyDir(rootDir); - const files = await listFiles(dir, '.md'); const bumpFiles: BumpFile[] = []; const errors: string[] = []; + + const files = await listFiles(dir, '.md'); for (const file of files) { if (file === 'README.md') continue; const result = await parseBumpFileFromPath(resolve(dir, file)); @@ -44,6 +53,27 @@ export async function readBumpFiles(rootDir: string): Promise bf.id === result.bumpFile!.id); + if (duplicate) { + errors.push( + `Bump file "${result.bumpFile.id}" exists both ${duplicate.channel ? `in .bumpy/${duplicate.channel}/` : 'at .bumpy/ root'} and in .bumpy/${channel}/ — ` + + 'remove one copy (the change likely already shipped on one of them).', + ); + continue; + } + bumpFiles.push({ ...result.bumpFile, channel }); + } + errors.push(...result.errors); + } + } + // Sort by the commit date when each bump file was first added to git. // Falls back to filename order for uncommitted bump files. const creationOrder = getBumpFileCreationOrder(rootDir); @@ -80,7 +110,10 @@ function getBumpFileCreationOrder(rootDir: string): Map { if (/^\d+$/.test(trimmed)) { currentTimestamp = parseInt(trimmed, 10); } else if (trimmed.startsWith('.bumpy/') && trimmed.endsWith('.md')) { - const id = trimmed.replace(/^\.bumpy\//, '').replace(/\.md$/, ''); + // Use the basename as the ID — bump files keep their ID when moved into + // a channel subdir, and the move shows up as a new "A" entry, so taking + // the oldest timestamp below preserves the original creation order. + const id = fileToId(trimmed); // Only record the first (oldest) commit — git log is newest-first, // so later entries overwrite with earlier timestamps order.set(id, currentTimestamp); @@ -219,13 +252,29 @@ export function recoverDeletedBumpFiles(rootDir: string): BumpFile[] { // Read the file content from the parent commit const content = tryRunArgs(['git', 'show', `HEAD~1:${filePath}`], { cwd: rootDir }); if (!content) continue; - const id = filePath.replace(/^\.bumpy\//, '').replace(/\.md$/, ''); - const { bumpFile } = parseBumpFile(content, id); + const { bumpFile } = parseBumpFile(content, fileToId(filePath)); if (bumpFile) bumpFiles.push(bumpFile); } return bumpFiles; } +/** + * Move bump files into a channel's shipped directory (`.bumpy//`). + * This is the only thing a channel "version" does — prerelease versions are + * never written to git. Files already in the target channel dir are left alone. + */ +export async function moveBumpFilesToChannel(rootDir: string, bumpFiles: BumpFile[], channel: string): Promise { + const { rename, mkdir } = await import('node:fs/promises'); + const dir = getBumpyDir(rootDir); + const channelDir = resolve(dir, channel); + await mkdir(channelDir, { recursive: true }); + for (const bf of bumpFiles) { + if (bf.channel === channel) continue; + const from = bf.channel ? resolve(dir, bf.channel, `${bf.id}.md`) : resolve(dir, `${bf.id}.md`); + await rename(from, resolve(channelDir, `${bf.id}.md`)); + } +} + /** Delete consumed bump files */ export async function deleteBumpFiles(rootDir: string, ids: string[]): Promise { const dir = getBumpyDir(rootDir); @@ -244,11 +293,7 @@ function fileToId(filePath: string): string { * of bump files that were added/modified. Shared by `check` and `ci check`. */ export function extractBumpFileIdsFromChangedFiles(changedFiles: string[]): Set { - return new Set( - changedFiles - .filter((f) => /^\.bumpy\/.*\.md$/.test(f) && !f.endsWith('README.md')) - .map((f) => f.replace(/^\.bumpy\//, '').replace(/\.md$/, '')), - ); + return new Set(changedFiles.filter((f) => /^\.bumpy\/.*\.md$/.test(f) && !f.endsWith('README.md')).map(fileToId)); } /** diff --git a/packages/bumpy/src/core/channels.ts b/packages/bumpy/src/core/channels.ts new file mode 100644 index 0000000..31f6ae6 --- /dev/null +++ b/packages/bumpy/src/core/channels.ts @@ -0,0 +1,115 @@ +import { resolve } from 'node:path'; +import { getBumpyDir } from './config.ts'; +import { getCurrentBranch } from './git.ts'; +import type { BumpyConfig } from '../types.ts'; + +/** A channel config with all defaults applied */ +export interface ResolvedChannel { + name: string; + branch: string; + preid: string; + tag: string; + versionPr: { + title: string; + branch: string; + automerge: boolean; + }; +} + +/** Channel names that would collide with reserved `.bumpy/` entries */ +const RESERVED_CHANNEL_NAMES = new Set(['README', 'README.md']); + +/** + * Resolve all configured channels, applying defaults and validating names. + * Defaults: preid/tag = channel name; versionPr.title = " ()"; + * versionPr.branch = "-". + */ +export function resolveChannels(config: BumpyConfig): Map { + const channels = new Map(); + const seenBranches = new Map(); + + for (const [name, raw] of Object.entries(config.channels || {})) { + if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name) || name.startsWith('_') || RESERVED_CHANNEL_NAMES.has(name)) { + throw new Error( + `Invalid channel name "${name}" — channel names become .bumpy/ subdirectories and must be ` + + 'alphanumeric (plus ".", "-", "_"), not start with "_", and not collide with reserved entries.', + ); + } + if (!raw.branch || typeof raw.branch !== 'string') { + throw new Error(`Channel "${name}" is missing required "branch" field`); + } + if (raw.branch === config.baseBranch) { + throw new Error(`Channel "${name}" cannot use the base branch ("${config.baseBranch}") as its channel branch`); + } + const existing = seenBranches.get(raw.branch); + if (existing) { + throw new Error(`Channels "${existing}" and "${name}" both use branch "${raw.branch}"`); + } + seenBranches.set(raw.branch, name); + + channels.set(name, { + name, + branch: raw.branch, + preid: raw.preid ?? name, + tag: raw.tag ?? name, + versionPr: { + title: raw.versionPr?.title ?? `${config.versionPr.title} (${name})`, + branch: raw.versionPr?.branch ?? `${config.versionPr.branch}-${name}`, + automerge: raw.versionPr?.automerge ?? false, + }, + }); + } + + return channels; +} + +/** Names of all configured channels (used as `.bumpy/` subdirectory names) */ +export function channelNames(config: BumpyConfig): string[] { + return Object.keys(config.channels || {}); +} + +/** Absolute path of a channel's shipped-bump-files directory */ +export function getChannelDir(rootDir: string, channelName: string): string { + return resolve(getBumpyDir(rootDir), channelName); +} + +/** + * Detect the branch the release flow is running for. + * In GitHub Actions push events, HEAD is often detached — prefer GITHUB_REF_NAME. + */ +export function detectReleaseBranch(rootDir: string): string | null { + const refName = process.env.GITHUB_REF_NAME; + const refType = process.env.GITHUB_REF_TYPE; + if (refName && refType !== 'tag') return refName; + const branch = getCurrentBranch({ cwd: rootDir }); + if (!branch || branch === 'HEAD') return null; // detached HEAD with no env hint + return branch; +} + +/** Find the channel matching a branch name, if any */ +export function matchChannelByBranch(config: BumpyConfig, branch: string | null): ResolvedChannel | null { + if (!branch) return null; + for (const channel of resolveChannels(config).values()) { + if (channel.branch === branch) return channel; + } + return null; +} + +/** + * Resolve the active channel for a command: + * an explicit `--channel ` override wins, otherwise the current branch is matched. + * Throws if an explicit override names an unknown channel. + */ +export function resolveActiveChannel(rootDir: string, config: BumpyConfig, override?: string): ResolvedChannel | null { + if (override) { + const channel = resolveChannels(config).get(override); + if (!channel) { + const known = channelNames(config); + throw new Error( + `Unknown channel "${override}"${known.length ? ` — configured channels: ${known.join(', ')}` : ' — no channels are configured in .bumpy/_config.json'}`, + ); + } + return channel; + } + return matchChannelByBranch(config, detectReleaseBranch(rootDir)); +} diff --git a/packages/bumpy/src/core/config.ts b/packages/bumpy/src/core/config.ts index 548206e..4980c42 100644 --- a/packages/bumpy/src/core/config.ts +++ b/packages/bumpy/src/core/config.ts @@ -122,6 +122,10 @@ function mergeConfig(defaults: BumpyConfig, user: Partial): BumpyCo ...defaults.packages, ...user.packages, }, + channels: { + ...defaults.channels, + ...user.channels, + }, }; } diff --git a/packages/bumpy/src/core/github-release.ts b/packages/bumpy/src/core/github-release.ts index 26b74ff..1d68044 100644 --- a/packages/bumpy/src/core/github-release.ts +++ b/packages/bumpy/src/core/github-release.ts @@ -73,6 +73,8 @@ export async function createIndividualReleases( try { // Use --target so gh can create the tag on the remote if it wasn't pushed yet const args = ['gh', 'release', 'create', tag, '--title', title, '--notes', body]; + // Mark prerelease versions so they never show as "latest" on GitHub + if (/-/.test(release.newVersion)) args.push('--prerelease'); if (headSha) args.push('--target', headSha); await withReleaseToken(() => runArgsAsync(args, { cwd: rootDir })); log.dim(` Created GitHub release: ${title}`); @@ -288,8 +290,10 @@ export async function createDraftRelease( body: string, rootDir: string, targetSha?: string, + opts?: { prerelease?: boolean }, ): Promise { const args = ['gh', 'release', 'create', tag, '--title', title, '--notes', body, '--draft']; + if (opts?.prerelease) args.push('--prerelease'); if (targetSha) args.push('--target', targetSha); await withReleaseToken(() => runArgsAsync(args, { cwd: rootDir })); } diff --git a/packages/bumpy/src/core/prerelease.ts b/packages/bumpy/src/core/prerelease.ts new file mode 100644 index 0000000..1399992 --- /dev/null +++ b/packages/bumpy/src/core/prerelease.ts @@ -0,0 +1,240 @@ +import { resolve } from 'node:path'; +import semver from 'semver'; +import { readText, writeText, updateJsonFields, updateJsonNestedField } from '../utils/fs.ts'; +import { runArgsAsync, tryRunArgs } from '../utils/shell.ts'; +import { listTags } from './git.ts'; +import type { ResolvedChannel } from './channels.ts'; +import type { ReleasePlan, PlannedRelease, WorkspacePackage } from '../types.ts'; + +/** + * Prerelease versions are never committed to git — they are derived at publish time: + * the target (major.minor.patch) comes from the cycle's bump files via the normal + * release plan, and the counter comes from the registry: max published `-.N` + * for that target, plus one. This makes counters immune to branch resets, abandoned + * cycles, and re-runs. + */ + +/** Published prerelease state for one package at one target version */ +export interface PublishedPrereleaseState { + /** Existing counters for `-.N` on the registry (or git tags for non-npm packages) */ + counters: number[]; + /** Whether the stable target version itself is already published */ + stablePublished: boolean; +} + +/** + * Extract existing prerelease counters for a target+preid from a list of published versions. + * Only exact `-.` versions count — other preids and targets are ignored. + */ +export function extractPrereleaseCounters(versions: string[], target: string, preid: string): number[] { + const counters: number[] = []; + for (const v of versions) { + const parsed = semver.parse(v); + if (!parsed) continue; + if (`${parsed.major}.${parsed.minor}.${parsed.patch}` !== target) continue; + if (parsed.prerelease.length !== 2) continue; + if (parsed.prerelease[0] !== preid) continue; + const n = parsed.prerelease[1]; + if (typeof n === 'number') counters.push(n); + } + return counters; +} + +/** Compute the next prerelease version: `-.` */ +export function nextPrereleaseVersion(target: string, preid: string, existingCounters: number[]): string { + const next = existingCounters.length > 0 ? Math.max(...existingCounters) + 1 : 0; + return `${target}-${preid}.${next}`; +} + +/** Fetch all published versions of a package from the registry */ +async function fetchPublishedVersions(name: string, registry?: string): Promise { + const args = ['npm', 'info', name, 'versions', '--json']; + if (registry) args.push('--registry', registry); + try { + const raw = await runArgsAsync(args); + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) return parsed.filter((v) => typeof v === 'string'); + if (typeof parsed === 'string') return [parsed]; // single-version packages + return []; + } catch { + // Package doesn't exist yet (first prerelease ever) or registry unreachable + return []; + } +} + +/** Fetch the gitHead recorded for a published version (set by npm publish from a git checkout) */ +async function fetchGitHead(name: string, version: string, registry?: string): Promise { + const args = ['npm', 'info', `${name}@${version}`, 'gitHead']; + if (registry) args.push('--registry', registry); + try { + const result = await runArgsAsync(args); + const sha = result.trim(); + return /^[0-9a-f]{40}$/.test(sha) ? sha : null; + } catch { + return null; + } +} + +/** Whether a package publishes through the npm registry (vs custom command / git-tag tracking) */ +function usesNpmRegistry(pkg: WorkspacePackage): boolean { + return !pkg.bumpy?.publishCommand && !pkg.bumpy?.skipNpmPublish && !pkg.private; +} + +/** Query published prerelease state for one package at a target version */ +export async function getPublishedPrereleaseState( + pkg: WorkspacePackage, + target: string, + preid: string, + rootDir: string, +): Promise { + if (usesNpmRegistry(pkg)) { + const versions = await fetchPublishedVersions(pkg.name, pkg.bumpy?.registry); + return { + counters: extractPrereleaseCounters(versions, target, preid), + stablePublished: versions.includes(target), + }; + } + // Non-npm packages (custom publish command / skipNpmPublish) — derive from git tags, + // matching how the stable flow tracks their published-ness. + const tagVersions = listTags(`${pkg.name}@${target}-${preid}.*`, { cwd: rootDir }).map((t) => + t.slice(pkg.name.length + 1), + ); + return { + counters: extractPrereleaseCounters(tagVersions, target, preid), + stablePublished: listTags(`${pkg.name}@${target}`, { cwd: rootDir }).length > 0, + }; +} + +export interface ChannelReleasePlanResult { + /** The plan with prerelease versions applied (packages already published from HEAD removed) */ + plan: ReleasePlan; + /** Packages skipped because their latest prerelease was already published from this commit */ + alreadyPublished: Array<{ name: string; version: string }>; + warnings: string[]; +} + +/** + * Transform a stable release plan (targets computed from bump files) into a channel + * prerelease plan: each release's newVersion becomes `-.` with N + * derived from the registry floor. + * + * Idempotency: if a package's latest published prerelease for this target records a + * gitHead equal to HEAD (or, for non-npm packages, its tag points at HEAD), the package + * was already published from this exact commit — it is skipped rather than re-counted. + */ +export async function buildChannelReleasePlan( + stablePlan: ReleasePlan, + channel: ResolvedChannel, + packages: Map, + rootDir: string, + opts: { + /** + * Display mode (release PR titles/bodies, status output): compute counters but + * skip the published-from-HEAD checks — the numbers are advisory narrative and + * the registry wins at actual publish time. + */ + forDisplay?: boolean; + } = {}, +): Promise { + const headSha = tryRunArgs(['git', 'rev-parse', 'HEAD'], { cwd: rootDir }); + const warnings: string[] = [...stablePlan.warnings]; + const alreadyPublished: Array<{ name: string; version: string }> = []; + const releases: PlannedRelease[] = []; + + await Promise.all( + stablePlan.releases.map(async (release) => { + const pkg = packages.get(release.name); + if (!pkg) return; + // Unpublishable packages can't participate in a registry-consumable cycle + if (pkg.private && !pkg.bumpy?.publishCommand) return; + + const target = release.newVersion; // stable target from the bump files + const state = await getPublishedPrereleaseState(pkg, target, channel.preid, rootDir); + + if (state.stablePublished) { + warnings.push( + `${release.name}@${target} is already published as a stable release — ` + + `merge ${target}'s release into the "${channel.branch}" branch so the cycle retargets.`, + ); + } + + if (!opts.forDisplay && state.counters.length > 0 && headSha) { + const latest = `${target}-${channel.preid}.${Math.max(...state.counters)}`; + const publishedFromHead = usesNpmRegistry(pkg) + ? (await fetchGitHead(pkg.name, latest, pkg.bumpy?.registry)) === headSha + : tryRunArgs(['git', 'rev-parse', `refs/tags/${pkg.name}@${latest}`], { cwd: rootDir }) === headSha; + if (publishedFromHead) { + alreadyPublished.push({ name: release.name, version: latest }); + return; + } + } + + releases.push({ + ...release, + newVersion: nextPrereleaseVersion(target, channel.preid, state.counters), + }); + }), + ); + + releases.sort((a, b) => a.name.localeCompare(b.name)); + return { + plan: { bumpFiles: stablePlan.bumpFiles, releases, warnings }, + alreadyPublished, + warnings, + }; +} + +/** + * Transiently write computed prerelease versions (and exact pins for in-cycle deps) + * into the working tree's package.json files. Returns a restore function that puts + * the original contents back — call it in a `finally` after publishing. + * + * Versions must be on disk before build/pack so that: + * - PM pack picks up the prerelease version for the tarball + * - builds that bake in the version (banners, __VERSION__) see the right one + * + * In-cycle dependencies are pinned EXACTLY (`"1.2.0-rc.0"`, no range) so any + * combination of packages installed from the channel dist-tag resolves to the + * coherent set it was published with. + */ +export async function writeChannelVersionsInPlace( + plan: ReleasePlan, + packages: Map, +): Promise<() => Promise> { + const releaseMap = new Map(plan.releases.map((r) => [r.name, r])); + const originals = new Map(); + + for (const release of plan.releases) { + const pkg = packages.get(release.name); + if (!pkg) continue; + const pkgJsonPath = resolve(pkg.dir, 'package.json'); + originals.set(pkgJsonPath, await readText(pkgJsonPath)); + + await updateJsonFields(pkgJsonPath, { version: release.newVersion }); + + // Exact-pin in-cycle deps (dev deps excluded — not installed by consumers) + for (const depField of ['dependencies', 'peerDependencies', 'optionalDependencies'] as const) { + const deps = pkg[depField]; + for (const depName of Object.keys(deps)) { + const depRelease = releaseMap.get(depName); + if (!depRelease) continue; + await updateJsonNestedField(pkgJsonPath, depField, depName, depRelease.newVersion); + } + } + } + + return async () => { + for (const [path, content] of originals) { + await writeText(path, content); + } + }; +} + +/** One-line summary of a channel plan's versions, for PR titles and commit messages */ +export function formatChannelVersionSummary(releases: PlannedRelease[]): string { + if (releases.length === 0) return ''; + const direct = releases.filter((r) => !r.isDependencyBump && !r.isCascadeBump && !r.isGroupBump); + const lead = (direct[0] ?? releases[0])!; + const rest = releases.length - 1; + return rest > 0 ? `${lead.name}@${lead.newVersion} (+${rest} more)` : `${lead.name}@${lead.newVersion}`; +} diff --git a/packages/bumpy/src/core/release-plan.ts b/packages/bumpy/src/core/release-plan.ts index b1ad8b6..bfddb6b 100644 --- a/packages/bumpy/src/core/release-plan.ts +++ b/packages/bumpy/src/core/release-plan.ts @@ -36,11 +36,25 @@ interface PlannedBump { * Phase B — enforce fixed/linked group constraints * Phase C — apply cascades and proactive propagation rules */ +export interface AssemblePlanOptions { + /** + * Prerelease preid for channel plans (e.g. "rc"). When set, Phase A checks range + * satisfaction against `-.0` instead of the stable target. Since a + * prerelease never satisfies a normal range (`^1.0.0` doesn't match `1.5.0-rc.0`), + * every dependent of a bumped package goes out of range and joins the cycle — + * required so consumers can actually install the cycle together from the dist-tag. + * Planned versions remain stable targets; the prerelease suffix is applied later + * from the registry floor. + */ + prereleasePreid?: string; +} + export function assembleReleasePlan( bumpFiles: BumpFile[], packages: Map, depGraph: DependencyGraph, config: BumpyConfig, + opts: AssemblePlanOptions = {}, ): ReleasePlan { if (bumpFiles.length === 0) { return { bumpFiles: [], releases: [], warnings: [] }; @@ -103,6 +117,9 @@ export function assembleReleasePlan( for (const [pkgName, bump] of planned) { const pkg = packages.get(pkgName)!; const newVersion = bumpVersion(pkg.version, bump.type); + // On a channel, the published version carries a prerelease suffix — check + // range satisfaction against that, since prereleases never satisfy normal ranges + const versionForRangeCheck = opts.prereleasePreid ? `${newVersion}-${opts.prereleasePreid}.0` : newVersion; const dependents = depGraph.getDependents(pkgName); for (const dep of dependents) { @@ -111,7 +128,7 @@ export function assembleReleasePlan( // Check if new version is out of range const currentVersion = pkg.version; - if (satisfies(newVersion, dep.versionRange, currentVersion)) continue; + if (satisfies(versionForRangeCheck, dep.versionRange, currentVersion)) continue; // Determine bump level for the dependent let depBump: BumpType; diff --git a/packages/bumpy/src/types.ts b/packages/bumpy/src/types.ts index 05aaa17..8be57d0 100644 --- a/packages/bumpy/src/types.ts +++ b/packages/bumpy/src/types.ts @@ -95,9 +95,32 @@ export interface PublishConfig { npmStaged: boolean; } +export interface ChannelConfig { + /** Branch that triggers this channel (required) */ + branch: string; + /** Version suffix (preid), e.g. "rc" → 1.2.0-rc.0. Defaults to the channel name. */ + preid?: string; + /** npm dist-tag for publishes. Defaults to the channel name. */ + tag?: string; + /** Release PR overrides for this channel */ + versionPr?: { + title?: string; + branch?: string; + /** Enable auto-merge on the release PR */ + automerge?: boolean; + }; +} + export interface BumpyConfig { baseBranch: string; access: 'public' | 'restricted'; + /** + * Prerelease channels, keyed by channel name. Each maps a long-lived branch + * to a prerelease line (version suffix + npm dist-tag). Shipped bump files + * are tracked in `.bumpy//`. Prerelease versions are never committed — + * they are derived from bump files, the registry, and git tags at publish time. + */ + channels: Record; /** * Customize the commit message used when versioning. * A string starting with "./" is treated as a path to a module that exports @@ -172,6 +195,7 @@ export const DEFAULT_PUBLISH_CONFIG: PublishConfig = { export const DEFAULT_CONFIG: BumpyConfig = { baseBranch: 'main', access: 'public', + channels: {}, versionCommitMessage: undefined, changedFilePatterns: ['**'], changelog: 'default', @@ -221,6 +245,8 @@ export interface BumpFile { id: string; // filename without .md releases: BumpFileRelease[]; summary: string; // markdown body + /** Channel directory this file lives in (`.bumpy//`), if any. Undefined = `.bumpy/` root. */ + channel?: string; } // ---- Workspace ---- diff --git a/packages/bumpy/test/core/bump-file-channels.test.ts b/packages/bumpy/test/core/bump-file-channels.test.ts new file mode 100644 index 0000000..387eb3d --- /dev/null +++ b/packages/bumpy/test/core/bump-file-channels.test.ts @@ -0,0 +1,99 @@ +import { describe, test, expect } from 'bun:test'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { readBumpFiles, moveBumpFilesToChannel, recoverDeletedBumpFiles } from '../../src/core/bump-file.ts'; +import { createTempGitRepo, cleanupTempDir, gitInDir } from '../helpers.ts'; + +async function writeBumpFileAt(dir: string, relPath: string, pkgName = 'pkg-a', bump = 'minor'): Promise { + const filePath = resolve(dir, relPath); + await mkdir(resolve(filePath, '..'), { recursive: true }); + await writeFile(filePath, `---\n"${pkgName}": ${bump}\n---\n\nSome change\n`); +} + +describe('readBumpFiles with channels', () => { + test('reads root files as pending and channel files with their channel set', async () => { + const dir = await createTempGitRepo(); + try { + await writeBumpFileAt(dir, '.bumpy/pending-fix.md'); + await writeBumpFileAt(dir, '.bumpy/next/shipped-feature.md'); + await writeBumpFileAt(dir, '.bumpy/beta/other-channel.md'); + + const { bumpFiles, errors } = await readBumpFiles(dir, { channels: ['next', 'beta'] }); + expect(errors).toEqual([]); + const byId = new Map(bumpFiles.map((bf) => [bf.id, bf])); + expect(byId.get('pending-fix')?.channel).toBeUndefined(); + expect(byId.get('shipped-feature')?.channel).toBe('next'); + expect(byId.get('other-channel')?.channel).toBe('beta'); + } finally { + await cleanupTempDir(dir); + } + }); + + test('ignores channel dirs not in the channels option', async () => { + const dir = await createTempGitRepo(); + try { + await writeBumpFileAt(dir, '.bumpy/next/shipped.md'); + const { bumpFiles } = await readBumpFiles(dir); + expect(bumpFiles).toEqual([]); + } finally { + await cleanupTempDir(dir); + } + }); + + test('flags duplicate IDs across root and channel dirs as an error', async () => { + const dir = await createTempGitRepo(); + try { + await writeBumpFileAt(dir, '.bumpy/same-change.md'); + await writeBumpFileAt(dir, '.bumpy/next/same-change.md'); + const { bumpFiles, errors } = await readBumpFiles(dir, { channels: ['next'] }); + expect(errors.length).toBe(1); + expect(errors[0]).toContain('same-change'); + expect(bumpFiles.length).toBe(1); // only the first copy kept + } finally { + await cleanupTempDir(dir); + } + }); +}); + +describe('moveBumpFilesToChannel', () => { + test('moves pending files (root and other channels) into the channel dir', async () => { + const dir = await createTempGitRepo(); + try { + await writeBumpFileAt(dir, '.bumpy/from-root.md'); + await writeBumpFileAt(dir, '.bumpy/alpha/from-alpha.md'); + await writeBumpFileAt(dir, '.bumpy/beta/already-here.md'); + + const { bumpFiles } = await readBumpFiles(dir, { channels: ['alpha', 'beta'] }); + await moveBumpFilesToChannel(dir, bumpFiles, 'beta'); + + expect(existsSync(resolve(dir, '.bumpy/beta/from-root.md'))).toBe(true); + expect(existsSync(resolve(dir, '.bumpy/beta/from-alpha.md'))).toBe(true); + expect(existsSync(resolve(dir, '.bumpy/beta/already-here.md'))).toBe(true); + expect(existsSync(resolve(dir, '.bumpy/from-root.md'))).toBe(false); + expect(existsSync(resolve(dir, '.bumpy/alpha/from-alpha.md'))).toBe(false); + } finally { + await cleanupTempDir(dir); + } + }); +}); + +describe('recoverDeletedBumpFiles with channel dirs', () => { + test('recovers bump files deleted from channel subdirs in the HEAD commit', async () => { + const dir = await createTempGitRepo(); + try { + await writeBumpFileAt(dir, '.bumpy/next/shipped-feature.md', 'pkg-a', 'minor'); + await writeBumpFileAt(dir, '.bumpy/root-fix.md', 'pkg-b', 'patch'); + gitInDir(['add', '-A'], dir); + gitInDir(['commit', '-m', 'add bump files'], dir); + gitInDir(['rm', '-r', '.bumpy'], dir); + gitInDir(['commit', '-m', 'version packages'], dir); + + const recovered = recoverDeletedBumpFiles(dir); + const ids = recovered.map((bf) => bf.id).sort(); + expect(ids).toEqual(['root-fix', 'shipped-feature']); + } finally { + await cleanupTempDir(dir); + } + }); +}); diff --git a/packages/bumpy/test/core/channels.test.ts b/packages/bumpy/test/core/channels.test.ts new file mode 100644 index 0000000..1ffd8fa --- /dev/null +++ b/packages/bumpy/test/core/channels.test.ts @@ -0,0 +1,97 @@ +import { describe, test, expect } from 'bun:test'; +import { resolveChannels, matchChannelByBranch, channelNames } from '../../src/core/channels.ts'; +import { makeConfig } from '../helpers.ts'; + +describe('resolveChannels', () => { + test('applies defaults: preid/tag = channel name, versionPr derived from base config', () => { + const config = makeConfig({ channels: { next: { branch: 'next' } } }); + const channels = resolveChannels(config); + const next = channels.get('next')!; + expect(next.preid).toBe('next'); + expect(next.tag).toBe('next'); + expect(next.versionPr.title).toBe('🐸 Versioned release (next)'); + expect(next.versionPr.branch).toBe('bumpy/version-packages-next'); + expect(next.versionPr.automerge).toBe(false); + }); + + test('respects explicit overrides', () => { + const config = makeConfig({ + channels: { + next: { + branch: 'release-next', + preid: 'rc', + tag: 'canary', + versionPr: { title: 'Ship it', branch: 'my/branch', automerge: true }, + }, + }, + }); + const next = resolveChannels(config).get('next')!; + expect(next.branch).toBe('release-next'); + expect(next.preid).toBe('rc'); + expect(next.tag).toBe('canary'); + expect(next.versionPr).toEqual({ title: 'Ship it', branch: 'my/branch', automerge: true }); + }); + + test('supports multiple channels', () => { + const config = makeConfig({ + channels: { + next: { branch: 'next', preid: 'rc' }, + beta: { branch: 'beta' }, + }, + }); + const channels = resolveChannels(config); + expect(channels.size).toBe(2); + expect(channelNames(config)).toEqual(['next', 'beta']); + }); + + test('rejects missing branch', () => { + const config = makeConfig({ channels: { next: {} as never } }); + expect(() => resolveChannels(config)).toThrow(/missing required "branch"/); + }); + + test('rejects channel on the base branch', () => { + const config = makeConfig({ channels: { next: { branch: 'main' } } }); + expect(() => resolveChannels(config)).toThrow(/base branch/); + }); + + test('rejects two channels sharing a branch', () => { + const config = makeConfig({ + channels: { + next: { branch: 'next' }, + rc: { branch: 'next' }, + }, + }); + expect(() => resolveChannels(config)).toThrow(/both use branch/); + }); + + test('rejects reserved and invalid channel names', () => { + expect(() => resolveChannels(makeConfig({ channels: { _config: { branch: 'x' } } }))).toThrow(/Invalid channel/); + expect(() => resolveChannels(makeConfig({ channels: { README: { branch: 'x' } } }))).toThrow(/Invalid channel/); + expect(() => resolveChannels(makeConfig({ channels: { 'foo/bar': { branch: 'x' } } }))).toThrow(/Invalid channel/); + expect(() => resolveChannels(makeConfig({ channels: { '../up': { branch: 'x' } } }))).toThrow(/Invalid channel/); + }); +}); + +describe('matchChannelByBranch', () => { + const config = makeConfig({ + channels: { + next: { branch: 'next', preid: 'rc' }, + beta: { branch: 'release/beta' }, + }, + }); + + test('matches channel branches', () => { + expect(matchChannelByBranch(config, 'next')?.name).toBe('next'); + expect(matchChannelByBranch(config, 'release/beta')?.name).toBe('beta'); + }); + + test('returns null for non-channel branches', () => { + expect(matchChannelByBranch(config, 'main')).toBeNull(); + expect(matchChannelByBranch(config, 'feature/foo')).toBeNull(); + expect(matchChannelByBranch(config, null)).toBeNull(); + }); + + test('returns null when no channels configured', () => { + expect(matchChannelByBranch(makeConfig(), 'next')).toBeNull(); + }); +}); diff --git a/packages/bumpy/test/core/prerelease.test.ts b/packages/bumpy/test/core/prerelease.test.ts new file mode 100644 index 0000000..2d7c2ea --- /dev/null +++ b/packages/bumpy/test/core/prerelease.test.ts @@ -0,0 +1,131 @@ +import { describe, test, expect } from 'bun:test'; +import { mkdir, writeFile, readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { + extractPrereleaseCounters, + nextPrereleaseVersion, + writeChannelVersionsInPlace, + formatChannelVersionSummary, +} from '../../src/core/prerelease.ts'; +import { makePkg, makeRelease, makeReleasePlan, createTempGitRepo, cleanupTempDir } from '../helpers.ts'; + +describe('extractPrereleaseCounters', () => { + test('extracts counters for matching target + preid only', () => { + const versions = [ + '1.1.0', // stable — ignored + '1.2.0-rc.0', + '1.2.0-rc.3', + '1.2.0-beta.5', // different preid — ignored + '1.3.0-rc.1', // different target — ignored + '1.2.0-rc.x', // non-numeric counter — ignored + '1.2.0-rc.1.hotfix', // extra identifier — ignored + ]; + expect(extractPrereleaseCounters(versions, '1.2.0', 'rc').sort((a, b) => a - b)).toEqual([0, 3]); + }); + + test('returns empty for no matches', () => { + expect(extractPrereleaseCounters(['1.0.0', '2.0.0-beta.0'], '1.2.0', 'rc')).toEqual([]); + expect(extractPrereleaseCounters([], '1.2.0', 'rc')).toEqual([]); + }); +}); + +describe('nextPrereleaseVersion', () => { + test('starts at .0 when nothing published', () => { + expect(nextPrereleaseVersion('1.2.0', 'rc', [])).toBe('1.2.0-rc.0'); + }); + + test('counts above the max published counter (registry floor)', () => { + expect(nextPrereleaseVersion('1.2.0', 'rc', [0, 1, 3])).toBe('1.2.0-rc.4'); + }); + + test('counter resets implicitly when the target moves (no committed state)', () => { + // published 1.2.0-rc.0..3, then a major lands → target becomes 2.0.0 + expect(nextPrereleaseVersion('2.0.0', 'rc', extractPrereleaseCounters(['1.2.0-rc.3'], '2.0.0', 'rc'))).toBe( + '2.0.0-rc.0', + ); + }); +}); + +describe('writeChannelVersionsInPlace', () => { + test('writes prerelease versions and exact-pins in-cycle deps, then restores', async () => { + const dir = await createTempGitRepo(); + try { + const coreDir = resolve(dir, 'packages/core'); + const pluginDir = resolve(dir, 'packages/plugin'); + await mkdir(coreDir, { recursive: true }); + await mkdir(pluginDir, { recursive: true }); + + const coreJson = JSON.stringify({ name: 'core', version: '1.1.0' }, null, 2) + '\n'; + const pluginJson = + JSON.stringify( + { + name: 'plugin', + version: '1.0.0', + dependencies: { core: 'workspace:^', lodash: '^4.0.0' }, + peerDependencies: { core: '^1.0.0' }, + devDependencies: { core: 'workspace:*' }, + }, + null, + 2, + ) + '\n'; + await writeFile(resolve(coreDir, 'package.json'), coreJson); + await writeFile(resolve(pluginDir, 'package.json'), pluginJson); + + const core = makePkg('core', '1.1.0', { dir: coreDir }); + const plugin = makePkg('plugin', '1.0.0', { + dir: pluginDir, + dependencies: { core: 'workspace:^', lodash: '^4.0.0' }, + peerDependencies: { core: '^1.0.0' }, + devDependencies: { core: 'workspace:*' }, + }); + const packages = new Map([ + ['core', core], + ['plugin', plugin], + ]); + + const plan = makeReleasePlan([ + makeRelease('core', '1.2.0-rc.1', { oldVersion: '1.1.0' }), + makeRelease('plugin', '1.0.1-rc.1', { oldVersion: '1.0.0' }), + ]); + + const restore = await writeChannelVersionsInPlace(plan, packages); + + const writtenCore = JSON.parse(await readFile(resolve(coreDir, 'package.json'), 'utf-8')); + const writtenPlugin = JSON.parse(await readFile(resolve(pluginDir, 'package.json'), 'utf-8')); + expect(writtenCore.version).toBe('1.2.0-rc.1'); + expect(writtenPlugin.version).toBe('1.0.1-rc.1'); + // In-cycle deps exact-pinned (no range, no workspace: protocol) + expect(writtenPlugin.dependencies.core).toBe('1.2.0-rc.1'); + expect(writtenPlugin.peerDependencies.core).toBe('1.2.0-rc.1'); + // Out-of-cycle deps untouched + expect(writtenPlugin.dependencies.lodash).toBe('^4.0.0'); + // Dev deps untouched (not installed by consumers) + expect(writtenPlugin.devDependencies.core).toBe('workspace:*'); + + await restore(); + expect(await readFile(resolve(coreDir, 'package.json'), 'utf-8')).toBe(coreJson); + expect(await readFile(resolve(pluginDir, 'package.json'), 'utf-8')).toBe(pluginJson); + } finally { + await cleanupTempDir(dir); + } + }); +}); + +describe('formatChannelVersionSummary', () => { + test('single release', () => { + expect(formatChannelVersionSummary([makeRelease('core', '1.2.0-rc.0')])).toBe('core@1.2.0-rc.0'); + }); + + test('leads with a direct (non-cascade) release and counts the rest', () => { + const releases = [ + makeRelease('plugin', '1.0.1-rc.0', { isDependencyBump: true }), + makeRelease('core', '1.2.0-rc.0'), + makeRelease('utils', '2.0.1-rc.0', { isDependencyBump: true }), + ]; + expect(formatChannelVersionSummary(releases)).toBe('core@1.2.0-rc.0 (+2 more)'); + }); + + test('empty plan', () => { + expect(formatChannelVersionSummary([])).toBe(''); + }); +}); diff --git a/packages/bumpy/test/core/release-plan.test.ts b/packages/bumpy/test/core/release-plan.test.ts index 89c9f5f..22fc2cd 100644 --- a/packages/bumpy/test/core/release-plan.test.ts +++ b/packages/bumpy/test/core/release-plan.test.ts @@ -74,6 +74,57 @@ describe('assembleReleasePlan', () => { expect(plan.releases.find((r) => r.name === 'pkg-b')!.newVersion).toBe('2.0.1'); }); + describe('prerelease (channel) plans', () => { + test('cascades to all dependents — prereleases never satisfy stable ranges', () => { + const packages = new Map([ + ['core', makePkg('core', '1.1.0')], + // ^1.1.0 would still be satisfied by stable 1.2.0, but NOT by 1.2.0-rc.0 + ['plugin', makePkg('plugin', '1.0.0', { dependencies: { core: '^1.1.0' } })], + ['cli', makePkg('cli', '2.0.0', { peerDependencies: { core: 'workspace:^' } })], + ]); + + const bumpFiles: BumpFile[] = [{ id: 'cs1', releases: [{ name: 'core', type: 'minor' }], summary: 'Feature' }]; + const graph = new DependencyGraph(packages); + + // Stable plan: nothing cascades (ranges stay satisfied) + const stablePlan = assembleReleasePlan(bumpFiles, packages, graph, makeConfig()); + expect(stablePlan.releases.map((r) => r.name)).toEqual(['core']); + + // Channel plan: everything cascades, at proportional levels + const channelPlan = assembleReleasePlan(bumpFiles, packages, graph, makeConfig(), { prereleasePreid: 'rc' }); + const byName = new Map(channelPlan.releases.map((r) => [r.name, r])); + expect(channelPlan.releases).toHaveLength(3); + expect(byName.get('plugin')!.type).toBe('patch'); // dependencies → patch + expect(byName.get('cli')!.type).toBe('minor'); // peerDependencies → match trigger + // Planned versions stay stable targets — the -rc.N suffix comes from the registry floor later + expect(byName.get('core')!.newVersion).toBe('1.2.0'); + expect(byName.get('plugin')!.newVersion).toBe('1.0.1'); + }); + + test('workspace:* deps still never trigger propagation', () => { + const packages = new Map([ + ['core', makePkg('core', '1.1.0')], + ['plugin', makePkg('plugin', '1.0.0', { dependencies: { core: 'workspace:*' } })], + ]); + const bumpFiles: BumpFile[] = [{ id: 'cs1', releases: [{ name: 'core', type: 'minor' }], summary: 'Feature' }]; + const graph = new DependencyGraph(packages); + const plan = assembleReleasePlan(bumpFiles, packages, graph, makeConfig(), { prereleasePreid: 'rc' }); + expect(plan.releases.map((r) => r.name)).toEqual(['core']); + }); + + test('cascade is transitive through the dependency graph', () => { + const packages = new Map([ + ['core', makePkg('core', '1.0.0')], + ['mid', makePkg('mid', '1.0.0', { dependencies: { core: '^1.0.0' } })], + ['app', makePkg('app', '1.0.0', { dependencies: { mid: '^1.0.0' } })], + ]); + const bumpFiles: BumpFile[] = [{ id: 'cs1', releases: [{ name: 'core', type: 'patch' }], summary: 'Fix' }]; + const graph = new DependencyGraph(packages); + const plan = assembleReleasePlan(bumpFiles, packages, graph, makeConfig(), { prereleasePreid: 'rc' }); + expect(plan.releases.map((r) => r.name).sort()).toEqual(['app', 'core', 'mid']); + }); + }); + // ---- Phase A: out-of-range checks ---- describe('Phase A: out-of-range', () => { From cdba9eba4e7a21ffc0792dfbb8f9519587415d80 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Thu, 11 Jun 2026 23:28:53 -0700 Subject: [PATCH 08/11] docs: mark prerelease channels as shipped in README - add to Features and Documentation sections - add to changesets pain-points comparison - remove from Roadmap (now implemented) --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1216780..7ea08b1 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ Fixed locale fallback logic in utils. - **Flexible package management** - include/exclude any package individually via per-package config, glob patterns, or `privatePackages` setting - **Non-interactive CLI** - `bumpy add` works fully non-interactively for CI/CD and AI-assisted development - **Aggregated GitHub releases** - optionally create a single consolidated release instead of one per package +- **Prerelease channels** - branch-based `@next` / `@beta` release lines where prerelease versions are derived at publish time, never committed to git (see [prerelease channels docs](https://github.com/dmno-dev/bumpy/blob/main/docs/prereleases.md)) - **Auto-generate from commits** - `bumpy generate` creates bump files from branch commits - works with any commit style, with enhanced detection for conventional commits - **Pluggable changelog formatters** - built-in `"default"` and `"github"` formatters, or write your own - **Zero runtime dependencies** - dependencies are minimal and bundled at release time @@ -119,6 +120,7 @@ The skill teaches the AI to examine git changes, identify affected packages, cho - [CLI reference](https://github.com/dmno-dev/bumpy/blob/main/docs/cli.md) - every command with flags and examples - [GitHub Actions setup](https://github.com/dmno-dev/bumpy/blob/main/docs/github-actions.md) - CI workflows, token setup, trusted publishing - [Version propagation](https://github.com/dmno-dev/bumpy/blob/main/docs/version-propagation.md) - how dependency bumps cascade through your graph +- [Prerelease channels](https://github.com/dmno-dev/bumpy/blob/main/docs/prereleases.md) - branch-based `@next` / `@beta` release lines ## Why files instead of conventional commits? @@ -133,6 +135,7 @@ Bumpy is built as a successor to [🦋changesets](https://github.com/changesets/ - **Custom publish commands** - changesets is hardcoded to `npm publish`. Bumpy supports per-package custom publish for VSCode extensions, Docker images, JSR, etc. - **Flexible package management** - changesets treats all private packages the same. Bumpy lets you include/exclude any package individually. - **CI without a separate action or bot** - changesets requires installing a [GitHub App](https://github.com/apps/changeset-bot) _and_ using a [separate GitHub Action](https://github.com/changesets/action). Bumpy replaces both with two CLI commands (`bumpy ci check` + `bumpy ci release`) that run directly in your workflows - no extra repos to trust, no app installation requiring org admin approval. +- **Prerelease channels that don't corrupt state** - changesets' prerelease mode is described in [their own docs](https://github.com/changesets/changesets/blob/main/docs/prereleases.md) as "very complicated" with states "very hard to fix." Bumpy uses [branch-based channels](https://github.com/dmno-dev/bumpy/blob/main/docs/prereleases.md) where prerelease versions are never committed - no global mode file to poison unrelated releases. - **Automatic migration** - `bumpy init` detects `.changeset/`, renames it to `.bumpy/`, migrates config, keeps pending files, and offers to uninstall `@changesets/cli`. ## Development @@ -146,7 +149,6 @@ bunx bumpy --help # invoke built cli ## Roadmap -- Prerelease mode (for now, use [pkg.pr.new](https://github.com/stackblitz-labs/pkg.pr.new) for branch preview packages) - Standalone binary for use outside of JS projects - Better support for versioning non-JS packages and usage without package.json files - Plugin system for different publish targets, and support multiple targets per package From 00382953c8aa87c8d5441879b57e768cd6aecea0 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 12 Jun 2026 13:42:17 -0700 Subject: [PATCH 09/11] chore: set up 'next' prerelease channel for dogfooding - add channels.next (preid rc, @next dist-tag) to .bumpy/_config.json - add next to the release workflow push triggers - make the release concurrency group per-branch --- .bumpy/_config.json | 5 +++++ .github/workflows/release.yaml | 5 +++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.bumpy/_config.json b/.bumpy/_config.json index e6867d5..554278c 100644 --- a/.bumpy/_config.json +++ b/.bumpy/_config.json @@ -12,5 +12,10 @@ "publish": { "provenance": true, "npmStaged": true + }, + // prerelease channel — dogfooding our own feature: pushes to `next` publish + // `-rc.N` versions to the @next dist-tag (prerelease versions are never committed) + "channels": { + "next": { "branch": "next", "preid": "rc", "tag": "next" } } } diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 8c4255f..d831da3 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -8,10 +8,11 @@ name: Release on: push: - branches: [main] + branches: [main, next] # `next` = prerelease channel (dogfooding bumpy's own feature) concurrency: - group: bumpy-release + # per-branch so a `next` prerelease run doesn't queue behind a `main` stable run + group: bumpy-release-${{ github.ref }} cancel-in-progress: false jobs: From 6ca52eaac5c5bd5e47311a28068691a3fb0ae9ec Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 12 Jun 2026 13:49:58 -0700 Subject: [PATCH 10/11] feat: make ci check comment channel-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a PR targets a prerelease channel branch, the bumpy ci check comment now says so explicitly instead of implying a stable release: - headline: 'This PR targets the prerelease channel — merging ships these as a prerelease to @, not a stable release' - versions display the derived '-.?' suffix (counter comes from the registry at publish time) - a note with the dist-tag install hint and how to promote to stable - plan uses the prerelease preid so the wider channel cascade is shown Export formatReleasePlanComment and cover both stable and channel comment shapes with tests. --- packages/bumpy/src/commands/ci.ts | 48 ++++++++++++++++--- .../test/core/ci-channel-comment.test.ts | 46 ++++++++++++++++++ 2 files changed, 88 insertions(+), 6 deletions(-) create mode 100644 packages/bumpy/test/core/ci-channel-comment.test.ts diff --git a/packages/bumpy/src/commands/ci.ts b/packages/bumpy/src/commands/ci.ts index 73f24f0..19835e3 100644 --- a/packages/bumpy/src/commands/ci.ts +++ b/packages/bumpy/src/commands/ci.ts @@ -120,6 +120,9 @@ export async function ciCheckCommand(rootDir: string, opts: CheckOptions): Promi // For PRs targeting a channel branch, compare against that branch (GITHUB_BASE_REF), // not baseBranch — otherwise the whole cycle's changes would show up. const compareBranch = process.env.GITHUB_BASE_REF || config.baseBranch; + // If this PR targets a channel branch, the comment makes that explicit (prerelease, + // dist-tag) rather than implying a normal stable release. + const prChannel = matchChannelByBranch(config, process.env.GITHUB_BASE_REF || null); const changedFiles = getChangedFiles(rootDir, compareBranch); const { branchBumpFiles: prBumpFiles, emptyBumpFileIds } = filterBranchBumpFiles( allBumpFiles, @@ -180,13 +183,25 @@ export async function ciCheckCommand(rootDir: string, opts: CheckOptions): Promi return; } - const plan = assembleReleasePlan(prBumpFiles, packages, depGraph, config); + // On a channel-targeted PR, plan with the prerelease preid so the wider cascade + // (every dependent joins the cycle) is reflected accurately in the preview. + const plan = assembleReleasePlan( + prBumpFiles, + packages, + depGraph, + config, + prChannel ? { prereleasePreid: prChannel.preid } : {}, + ); // Pretty output for logs - log.bold(`${prBumpFiles.length} bump file(s) → ${plan.releases.length} package(s) to release\n`); + const releaseSuffix = prChannel ? `-${prChannel.preid}.?` : ''; + log.bold( + `${prBumpFiles.length} bump file(s) → ${plan.releases.length} package(s) to release` + + `${prChannel ? ` on the "${prChannel.name}" channel (@${prChannel.tag})` : ''}\n`, + ); for (const r of plan.releases) { const tag = r.isDependencyBump ? ' (dep)' : r.isCascadeBump ? ' (cascade)' : ''; - console.log(` ${r.name}: ${r.oldVersion} → ${colorize(r.newVersion, 'cyan')}${tag}`); + console.log(` ${r.name}: ${r.oldVersion} → ${colorize(`${r.newVersion}${releaseSuffix}`, 'cyan')}${tag}`); } if (plan.warnings.length > 0) { for (const w of plan.warnings) { @@ -206,6 +221,7 @@ export async function ciCheckCommand(rootDir: string, opts: CheckOptions): Promi plan.warnings, parseErrors, emptyBumpFileIds, + prChannel, ); await postOrUpdatePrComment(prNumber, comment, rootDir); } @@ -1007,7 +1023,7 @@ function pmRunCommand(pm: PackageManager): string { return 'npx bumpy'; } -function formatReleasePlanComment( +export function formatReleasePlanComment( plan: ReleasePlan, bumpFiles: BumpFile[], prNumber: string, @@ -1016,14 +1032,22 @@ function formatReleasePlanComment( warnings: string[] = [], parseErrors: string[] = [], emptyBumpFileIds: string[] = [], + channel: ResolvedChannel | null = null, ): string { const repo = process.env.GITHUB_REPOSITORY; const lines: string[] = []; + // When targeting a prerelease channel, the version display carries the `-.?` + // suffix (the exact counter is derived from the registry at publish time). + const versionSuffix = channel ? `-${channel.preid}.?` : ''; + + const headline = channel + ? `**This PR targets the \`${channel.name}\` prerelease channel** — merging it ships these packages as a **prerelease** to the \`@${channel.tag}\` dist-tag, not a stable release.` + : '**The changes in this PR will be included in the next version bump.**'; const preamble = [ `bumpy-frog`, '', - '**The changes in this PR will be included in the next version bump.**', + headline, '
', ].join('\n'); lines.push(preamble); @@ -1043,11 +1067,23 @@ function formatReleasePlanComment( lines.push(''); for (const r of releases) { const suffix = r.isDependencyBump ? ' _(dep)_' : r.isCascadeBump ? ' _(cascade)_' : ''; - lines.push(`- \`${r.name}\` ${r.oldVersion} → **${r.newVersion}**${suffix}`); + lines.push(`- \`${r.name}\` ${r.oldVersion} → **${r.newVersion}${versionSuffix}**${suffix}`); } lines.push(''); } + if (channel) { + const examplePkg = + plan.releases.find((r) => !r.isDependencyBump && !r.isCascadeBump)?.name ?? plan.releases[0]?.name; + const installHint = examplePkg ? ` (e.g. \`npm i ${examplePkg}@${channel.tag}\`)` : ''; + lines.push( + `> 🔀 Published to the \`@${channel.tag}\` dist-tag${installHint}. ` + + `Prerelease versions are derived at publish time — the \`.?\` counter is filled in from the registry. ` + + `Promote to a stable release by merging \`${channel.branch}\` into your base branch.`, + ); + lines.push(''); + } + // Bump file list with links lines.push(`#### Bump files in this PR`); lines.push(''); diff --git a/packages/bumpy/test/core/ci-channel-comment.test.ts b/packages/bumpy/test/core/ci-channel-comment.test.ts new file mode 100644 index 0000000..2d40986 --- /dev/null +++ b/packages/bumpy/test/core/ci-channel-comment.test.ts @@ -0,0 +1,46 @@ +import { describe, test, expect } from 'bun:test'; +import { formatReleasePlanComment } from '../../src/commands/ci.ts'; +import { resolveChannels } from '../../src/core/channels.ts'; +import { makeRelease, makeReleasePlan, makeBumpFile, makeConfig } from '../helpers.ts'; + +const channel = resolveChannels(makeConfig({ channels: { next: { branch: 'next', preid: 'rc', tag: 'next' } } })).get( + 'next', +)!; + +const plan = makeReleasePlan( + [makeRelease('@myorg/core', '1.2.0', { type: 'minor', oldVersion: '1.1.0', bumpFiles: ['feat'] })], + [makeBumpFile('feat', [{ name: '@myorg/core', type: 'minor' }], 'Add a feature')], +); + +describe('formatReleasePlanComment — stable (no channel)', () => { + const comment = formatReleasePlanComment(plan, plan.bumpFiles, '1', 'feature-branch', 'npm'); + + test('uses the normal "next version bump" headline', () => { + expect(comment).toContain('included in the next version bump'); + expect(comment).not.toContain('prerelease channel'); + }); + + test('shows plain stable versions (no preid suffix)', () => { + expect(comment).toContain('1.1.0 → **1.2.0**'); + expect(comment).not.toContain('-rc.'); + }); +}); + +describe('formatReleasePlanComment — prerelease channel', () => { + const comment = formatReleasePlanComment(plan, plan.bumpFiles, '1', 'feature-branch', 'npm', [], [], [], channel); + + test('headline makes the channel + prerelease explicit', () => { + expect(comment).toContain('`next` prerelease channel'); + expect(comment).toContain('prerelease'); + expect(comment).toContain('@next'); + }); + + test('versions carry the derived "-rc.?" suffix', () => { + expect(comment).toContain('1.1.0 → **1.2.0-rc.?**'); + }); + + test('includes a dist-tag install hint and promotion note', () => { + expect(comment).toContain('npm i @myorg/core@next'); + expect(comment).toContain('Promote to a stable release by merging `next`'); + }); +}); From 573780a6415dc213208e69060300a9afbca652cd Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 12 Jun 2026 13:55:17 -0700 Subject: [PATCH 11/11] ci: dogfood local bumpy in the check workflow for internal PRs Split the PR check into two mutually-exclusive jobs: - check-published: fork PRs run published @latest (never executes untrusted code with the pull_request_target write token) - check-local: non-fork PRs build + run this repo's local bumpy, so unreleased behavior (e.g. channel-aware comments) is dogfooded on our own PRs before it ships to @latest Lets PR #104 (internal, targets the next channel) show the real channel-aware check comment now instead of waiting for promotion. --- .github/workflows/bumpy-check.yaml | 36 ++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/.github/workflows/bumpy-check.yaml b/.github/workflows/bumpy-check.yaml index b332ecf..c3b0a15 100644 --- a/.github/workflows/bumpy-check.yaml +++ b/.github/workflows/bumpy-check.yaml @@ -4,6 +4,10 @@ # ⚠️ NOTE - DO NOT COPY THIS FILE # instead look at the recommended workflow in the docs # ➡️ https://bumpy.varlock.dev/blob/main/docs/github-actions.md ⬅️ +# +# This repo splits the check into two mutually-exclusive jobs so it can dogfood its +# OWN unreleased CLI on internal PRs while staying safe for fork PRs. A normal project +# only needs the single `bunx @varlock/bumpy@latest ci check` job (the fork-safe one). name: Bumpy Check @@ -14,17 +18,41 @@ permissions: contents: read jobs: - bumpy-check: + # Fork PRs (untrusted): run the PUBLISHED bumpy and never execute the PR's code. + # pull_request_target carries a write token + secrets, so building/running fork + # code here would be a privilege-escalation hole. `ci check` reads json/yaml only. + check-published: + if: github.event.pull_request.head.repo.full_name != github.repository runs-on: ubuntu-latest steps: - # Check out the PR head so bumpy can read the PR's bump files, config, and package.json + # Check out the PR head so bumpy can read the PR's bump files, config, and package.json. # We never execute this code! - uses: actions/checkout@v6 with: ref: ${{ github.event.pull_request.head.sha }} - uses: oven-sh/setup-bun@v2 - - # reads json/yaml files only, so it's safe to run on fork PRs - run: bunx @varlock/bumpy@latest ci check env: GH_TOKEN: ${{ github.token }} + + # Internal (non-fork) PRs: build and run THIS repo's local bumpy so we dogfood the + # unreleased CLI (e.g. channel-aware comments before they're published to @latest). + # ⚠️ DO NOT COPY — only safe because the PR head lives in this same repo, so no + # untrusted code runs with the privileged token. Forks fall through to check-published. + check-local: + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 # need history to diff bump files against the PR base branch + - uses: oven-sh/setup-bun@v2 + - run: bun install + # Build first since we run the local built version of bumpy instead of the published one + - run: bun run --filter @varlock/bumpy build + # run bun install again to make the now-built CLI available + - run: bun install + - run: bunx @varlock/bumpy ci check + env: + GH_TOKEN: ${{ github.token }}