Skip to content

Commit 1380f72

Browse files
authored
feat: prerelease channels (branch-based, no committed prerelease versions) (#104)
## Summary Branch-based **prerelease channels** — design doc *and* full implementation. This PR started as an RFC; after a few design revisions (see commit history) it now ships the feature, tests, and docs. The TL;DR of the model: - **Branch = channel.** Nominate a `next` / `beta` / etc. branch in `.bumpy/_config.json`. Pushing to it produces `-rc.N` / `-beta.N` versions on the matching dist-tag, using the same `ci release` flow as `main`. - **Prerelease versions are never committed to git.** Every `package.json` on a channel branch keeps the last stable version, identical to `main`. Targets are derived from bump files, counters from the registry (`max published -preid.N + 1`), idempotency from npm's `gitHead` metadata. No suffix to strip at promotion, no counter state to corrupt, no version conflicts on `main` ↔ channel syncs, and abandoned cycles can't cause registry collisions. - **Bump file location is the only state.** Pending at `.bumpy/*.md`, shipped at `.bumpy/<channel>/*.md`. The "release PR" on a channel is a pure file-move PR — computed versions appear in its title and merge commit message as narrative; the registry wins at publish time. The general pending rule ("pending unless in *this* context's dir") gives alpha → beta graduation for free. - **The cycle moves as one.** Every publish recomputes the full cycle; on channels, range satisfaction is checked against the prerelease version (which never satisfies a stable range), so all dependents join at proportional bump levels and inter-cycle deps are exact-pinned in the published artifacts. The channel dist-tag always points at one coherent set. - **Promotion is just a merge.** Main treats channel-dir files as pending, so the ordinary stable flow consumes them, bumps stable-to-stable, and writes one consolidated `CHANGELOG.md` entry. Zero special promotion code. ## Why not just match changesets' pre mode? Changesets' [own docs](https://github.com/changesets/changesets/blob/main/docs/prereleases.md) describe their prerelease mode as *"very complicated"* with *"mistakes that can lead to repository and publish states that are very hard to fix."* [docs/prereleases.md](https://github.com/dmno-dev/bumpy/blob/docs/prerelease-channels/docs/prereleases.md) surveys the recurring complaints ([#239](changesets/changesets#239), [#381](changesets/changesets#381), [#729](changesets/changesets#729), [#786](changesets/changesets#786), [#960](changesets/changesets#960)) and designs them out rather than re-importing them — see the side-by-side comparison table at the bottom of the doc. ## Implementation - `src/core/channels.ts` — config resolution + validation, branch detection (prefers `GITHUB_REF_NAME` for detached CI checkouts), `--channel` override. - `src/core/prerelease.ts` — registry-floor counters, per-package published-from-`HEAD` skip (`gitHead` on npm; git tags for custom-publish packages), and the transient in-place rewrite: computed versions + exact pins are written to the working tree so pack/build see them, then restored in a `finally`. - `src/core/release-plan.ts` — new `prereleasePreid` mode: Phase A checks ranges against `<target>-<preid>.0`, producing the required wide-but-proportional cascade (channel-only; stable plans are unchanged). - Command flows: `version` on a channel only moves files; `publish` derives + rewrites + publishes to the channel dist-tag (and the stable path refuses suffixed versions when channels are configured); `ci release` publishes when the triggering push moved files into `.bumpy/<channel>/` and maintains the file-move release PR (with `versionPr.automerge` support); `status` shows the cycle with registry-derived counters; `check` skips channel branches and gains `--base`; unknown branches make `ci release` error instead of guessing. GitHub releases for prereleases are marked `--prerelease`. - Config schema (`config-schema.json`) updated; `preid` is optional in the schema to leave room for future stable/maintenance channels. ## Out of scope (deliberately) - Ephemeral per-PR/per-commit previews — [pkg.pr.new](https://pkg.pr.new)'s job; the doc draws the line explicitly. - Workflow-dispatch one-off prereleases — nearly free under this architecture (same compute-and-publish from any SHA); planned as a fast-follow. - Stable/maintenance channels (`1.x` branches) — future; config shape already accommodates. - Per-bump-file `channel:` frontmatter — not planned; channels stay branch-derived. ## Test plan - [x] 285 tests pass (27 new: channel config resolution/validation, registry-floor counter math, exact-pin rewrite + restore, channel-dir bump file reading/moves/duplicate detection, prerelease cascade behavior incl. `workspace:*` opt-out and transitivity) - [x] `tsc --noEmit`, oxlint, oxfmt clean - [x] End-to-end smoke test in a scratch workspace: bump file → channel `version` (file move only, no version writes) → `status` (correct `rc.0` counters) → `publish --dry-run` (correct versions, `--tag next`, exact pins) → merge to `main` → stable `version` (channel dir consumed, consolidated changelog, no spurious cascade on main) - [ ] Dogfood on a real channel branch in this repo once merged (first real `bumpy ci release` run on a `next` push)
1 parent 72c8ed1 commit 1380f72

28 files changed

Lines changed: 2283 additions & 60 deletions

.bumpy/_config.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,10 @@
1212
"publish": {
1313
"provenance": true,
1414
"npmStaged": true
15+
},
16+
// prerelease channel — dogfooding our own feature: pushes to `next` publish
17+
// `-rc.N` versions to the @next dist-tag (prerelease versions are never committed)
18+
"channels": {
19+
"next": { "branch": "next", "preid": "rc", "tag": "next" }
1520
}
1621
}

.bumpy/prerelease-channels.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@varlock/bumpy': minor
3+
---
4+
5+
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/<channel>/`. Includes channel-aware `version` / `publish` / `status` / `ci release` flows, exact-pinned lockstep cycle publishes, and promotion-by-merge to stable.

.github/workflows/release.yaml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@
88
name: Release
99
on:
1010
push:
11-
branches: [main]
11+
branches: [main, next] # `next` = prerelease channel (dogfooding bumpy's own feature)
1212

1313
concurrency:
14-
group: bumpy-release
14+
# per-branch so a `next` prerelease run doesn't queue behind a `main` stable run
15+
group: bumpy-release-${{ github.ref }}
1516
cancel-in-progress: false
1617

1718
jobs:

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ Fixed locale fallback logic in utils.
5454
- **Flexible package management** - include/exclude any package individually via per-package config, glob patterns, or `privatePackages` setting
5555
- **Non-interactive CLI** - `bumpy add` works fully non-interactively for CI/CD and AI-assisted development
5656
- **Aggregated GitHub releases** - optionally create a single consolidated release instead of one per package
57+
- **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))
5758
- **Auto-generate from commits** - `bumpy generate` creates bump files from branch commits - works with any commit style, with enhanced detection for conventional commits
5859
- **Pluggable changelog formatters** - built-in `"default"` and `"github"` formatters, or write your own
5960
- **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
119120
- [CLI reference](https://github.com/dmno-dev/bumpy/blob/main/docs/cli.md) - every command with flags and examples
120121
- [GitHub Actions setup](https://github.com/dmno-dev/bumpy/blob/main/docs/github-actions.md) - CI workflows, token setup, trusted publishing
121122
- [Version propagation](https://github.com/dmno-dev/bumpy/blob/main/docs/version-propagation.md) - how dependency bumps cascade through your graph
123+
- [Prerelease channels](https://github.com/dmno-dev/bumpy/blob/main/docs/prereleases.md) - branch-based `@next` / `@beta` release lines
122124

123125
## Why files instead of conventional commits?
124126

@@ -133,6 +135,7 @@ Bumpy is built as a successor to [🦋changesets](https://github.com/changesets/
133135
- **Custom publish commands** - changesets is hardcoded to `npm publish`. Bumpy supports per-package custom publish for VSCode extensions, Docker images, JSR, etc.
134136
- **Flexible package management** - changesets treats all private packages the same. Bumpy lets you include/exclude any package individually.
135137
- **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.
138+
- **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.
136139
- **Automatic migration** - `bumpy init` detects `.changeset/`, renames it to `.bumpy/`, migrates config, keeps pending files, and offers to uninstall `@changesets/cli`.
137140

138141
## Development
@@ -146,7 +149,6 @@ bunx bumpy --help # invoke built cli
146149

147150
## Roadmap
148151

149-
- Prerelease mode (for now, use [pkg.pr.new](https://github.com/stackblitz-labs/pkg.pr.new) for branch preview packages)
150152
- Standalone binary for use outside of JS projects
151153
- Better support for versioning non-JS packages and usage without package.json files
152154
- Plugin system for different publish targets, and support multiple targets per package

docs/cli.md

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,12 @@ bumpy status --verbose
5353
| `--bump <types>` | Filter by bump type, e.g. `"major"` or `"minor,patch"` |
5454
| `--filter <names>` | Filter by package name or glob |
5555
| `--verbose` | Show bump file details and summaries |
56+
| `--channel <name>` | Channel override (default: inferred from the current branch) |
5657

5758
Exits with code `0` if releases are pending, `1` if none.
5859

60+
On a [prerelease channel](prereleases.md) branch, status shows the cycle instead: shipped vs pending bump files and the derived `-<preid>.N` versions (counters come from the registry; offline they render as `.?`).
61+
5962
## `bumpy version`
6063

6164
Consume all pending bump files and apply the release plan:
@@ -72,9 +75,12 @@ bumpy version
7275
bumpy version --commit
7376
```
7477

75-
| Flag | Description |
76-
| ---------- | --------------------------------- |
77-
| `--commit` | Commit the version changes to git |
78+
| Flag | Description |
79+
| ------------------ | ------------------------------------------------------------ |
80+
| `--commit` | Commit the version changes to git |
81+
| `--channel <name>` | Channel override (default: inferred from the current branch) |
82+
83+
On a [prerelease channel](prereleases.md) branch, `bumpy version` does something much smaller: it **moves pending bump files into `.bumpy/<channel>/`** and writes no versions and no changelogs — prerelease versions are derived at publish time and never committed.
7884

7985
## `bumpy publish`
8086

@@ -87,12 +93,15 @@ bumpy publish --tag beta
8793
bumpy publish --filter "@myorg/*"
8894
```
8995

90-
| Flag | Description |
91-
| ------------------ | --------------------------------------------------------- |
92-
| `--dry-run` | Preview what would be published without actually doing it |
93-
| `--tag <tag>` | npm dist-tag (e.g., `next`, `beta`) |
94-
| `--no-push` | Skip pushing git tags to the remote |
95-
| `--filter <names>` | Only publish matching packages (supports globs) |
96+
| Flag | Description |
97+
| ------------------ | ------------------------------------------------------------ |
98+
| `--dry-run` | Preview what would be published without actually doing it |
99+
| `--tag <tag>` | npm dist-tag (e.g., `next`, `beta`) |
100+
| `--no-push` | Skip pushing git tags to the remote |
101+
| `--filter <names>` | Only publish matching packages (supports globs) |
102+
| `--channel <name>` | Channel override (default: inferred from the current branch) |
103+
104+
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.
96105

97106
**How bumpy detects unpublished packages:**
98107

@@ -114,12 +123,15 @@ bumpy check --hook pre-commit
114123
bumpy check --hook pre-push
115124
```
116125

117-
| Flag | Description |
118-
| ------------------- | ---------------------------------------------------------- |
119-
| `--strict` | Fail if any changed package is not covered by a bump file |
120-
| `--no-fail` | Warn only, never exit non-zero (useful for advisory hooks) |
121-
| `--hook pre-commit` | Only count staged + committed bump files |
122-
| `--hook pre-push` | Only count committed bump files |
126+
| Flag | Description |
127+
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
128+
| `--strict` | Fail if any changed package is not covered by a bump file |
129+
| `--no-fail` | Warn only, never exit non-zero (useful for advisory hooks) |
130+
| `--hook pre-commit` | Only count staged + committed bump files |
131+
| `--hook pre-push` | Only count committed bump files |
132+
| `--base <branch>` | Branch to compare against (default: `baseBranch`) — use the channel branch for feature branches targeting a [channel](prereleases.md) |
133+
134+
The check is skipped automatically on channel branches and release PR branches (they move/consume bump files by design).
123135

124136
### Hook context
125137

@@ -240,6 +252,8 @@ bumpy ci release --auto-publish --tag beta
240252

241253
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)).
242254

255+
**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/<channel>/` (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.
256+
243257
## `bumpy ci setup`
244258

245259
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.

docs/configuration.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Bumpy is configured via `.bumpy/_config.json`, created by `bumpy init`. Per-pack
2525
| `versionPr` | `{ title, branch, preamble }` | see below | Customize the version PR |
2626
| `allowCustomCommands` | `boolean \| string[]` | `false` | Allow per-package custom commands from `package.json` (see below) |
2727
| `packages` | `object` | `{}` | Per-package config overrides (keyed by package name) |
28+
| `channels` | `object` | `{}` | Prerelease channels, keyed by channel name (see below) |
2829

2930
### Dependency bump rules
3031

@@ -99,6 +100,29 @@ The `versionPr` object customizes the PR that `bumpy ci release` creates:
99100
| `branch` | `string` | `"bumpy/version-packages"` | Branch name for the version PR |
100101
| `preamble` | `string` || HTML content prepended to the PR body |
101102

103+
### Prerelease channels
104+
105+
The `channels` object maps long-lived branches to prerelease lines. See [prereleases.md](prereleases.md) for the full workflow.
106+
107+
```jsonc
108+
{
109+
"channels": {
110+
"next": {
111+
"branch": "next", // required — branch that triggers this channel
112+
"preid": "rc", // version suffix (default: channel name)
113+
"tag": "next", // npm dist-tag (default: channel name)
114+
"versionPr": {
115+
"title": "🐸 Versioned release (next)", // default: "<base title> (<name>)"
116+
"branch": "bumpy/version-packages-next", // default: "<base branch>-<name>"
117+
"automerge": false, // enable auto-merge on the release PR
118+
},
119+
},
120+
},
121+
}
122+
```
123+
124+
Channel names become `.bumpy/<name>/` 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.
125+
102126
## Per-package config
103127

104128
Per-package settings can be defined in two places:

docs/differences-from-changesets.md

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,18 @@ Bumpy replaces all of this with two CLI commands you run directly in standard wo
147147
- [changesets#1242](https://github.com/changesets/changesets/issues/1242) — bot/action version upgrade issues
148148
- [changesets#43](https://github.com/changesets/changesets/issues/43) — can't customize bot messages
149149

150+
### Prerelease channels that actually work
151+
152+
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.
153+
154+
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/<channel>/`) 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.
155+
156+
- [changesets#729](https://github.com/changesets/changesets/issues/729) — exiting pre mode bumps all versions (14 comments)
157+
- [changesets#786](https://github.com/changesets/changesets/issues/786) — can't control dist-tag in pre mode (13 comments)
158+
- [changesets#635](https://github.com/changesets/changesets/issues/635) — prerelease workflow problems
159+
- [changesets#239](https://github.com/changesets/changesets/issues/239) — prerelease mode design issues
160+
- [changesets#381](https://github.com/changesets/changesets/issues/381) — prerelease counters require committed state
161+
150162
### Local bump file verification
151163

152164
`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
157169

158170
## Planned / Not Yet Implemented
159171

160-
### Prerelease mode that actually works
161-
162-
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.
163-
164-
- [changesets#729](https://github.com/changesets/changesets/issues/729) — exiting pre mode bumps all versions (14 comments)
165-
- [changesets#786](https://github.com/changesets/changesets/issues/786) — can't control dist-tag in pre mode (13 comments)
166-
- [changesets#635](https://github.com/changesets/changesets/issues/635) — prerelease workflow problems
167-
- [changesets#239](https://github.com/changesets/changesets/issues/239) — prerelease mode design issues
168-
169172
### Root workspace / non-package changes
170173

171174
Track changes to CI, tooling, and monorepo-root-level config in changelogs — not just workspace packages.

0 commit comments

Comments
 (0)