Skip to content

Commit 791c92b

Browse files
authored
feat: deterministic channel release PR titles (#107)
Channel release PR titles previously showed registry-derived prerelease counters, which could be out of sync with what actually publishes (the registry is re-queried at publish time and wins). They also degraded poorly in multi-package cycles: an arbitrary alphabetical lead package plus `(+N more)`. Titles, PR bodies, and merge commit messages now only claim what's derivable from committed state: - **Wildcard counter** — versions display as `1.2.0-rc.x`; the target comes from bump files (deterministic), the `.x` is assigned from the registry at publish. The title can no longer drift, by construction. - **Package count for multi-package cycles** — `🐸 Versioned release (next): 4 packages` instead of an arbitrary lead + `(+N more)`. Single-package cycles keep `name@1.2.0-rc.x`. - **No registry call in the version-PR job** — the `forDisplay` fetch (and its offline `-rc.?` fallback) is gone from the release PR path; one less network dependency and failure mode. `status` and `ci plan` keep live registry-derived counters (`.?` when offline) since they're interactive/live output. - **Consistent wildcard** — the PR check comment and channel `version` output now use `.x` too (they never queried the registry; `.?` previously implied a failed lookup rather than "assigned later"). Docs updated where they described the title as "advisory narrative; registry wins" — the new story is simpler: the title only shows deterministic state. All 292 tests pass, including new coverage for `channelDisplayPlan` (wildcard mapping + unpublishable-package filtering).
1 parent 8f2fae7 commit 791c92b

7 files changed

Lines changed: 90 additions & 48 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@varlock/bumpy': patch
3+
---
4+
5+
Channel release PR titles and bodies now show deterministic versions: targets with a wildcard counter (`1.2.0-rc.x`) derived purely from committed state, instead of registry-derived counters that could drift between PR creation and publish. Multi-package cycles show a package count in the title instead of an arbitrary lead package. The PR check comment and `version` output use the same `.x` wildcard; `status` / `ci plan` still show live registry-derived counters (`.?` when offline).

docs/prereleases.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ Rough rule of thumb:
5555

5656
- **Branch = channel.** The `next` branch is the `next` channel. Pushing to it produces prerelease publishes on the `@next` dist-tag.
5757
- **Same flow as main.** Feature PRs land bump files. A "🐸 Versioned prerelease (next)" PR accumulates the cycle. Merging it triggers a prerelease publish.
58-
- **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.
58+
- **The release PR moves files, not versions.** Its diff is bump files moving into `.bumpy/next/`. The cycle's target versions appear in the PR title and merge commit message with a wildcard counter (`1.2.0-rc.x`) — so `git log` on the channel reads as a release history — but nothing version-shaped is committed.
5959
- **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.
6060

6161
---
@@ -166,7 +166,7 @@ When a feature PR merges to `next`:
166166

167167
1. `bumpy ci release` runs on the `next` push.
168168
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`.
169-
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.
169+
3. The PR's diff is **only file moves**: `.bumpy/feature-x.md` → `.bumpy/next/feature-x.md`. The target versions (with a wildcard counter, e.g. `1.2.0-rc.x`) live in the PR title and body, and land in git history via the merge commit message.
170170

171171
When a maintainer merges that PR:
172172

@@ -178,7 +178,7 @@ When a maintainer merges that PR:
178178
npm install my-package@next # gets 1.2.0-rc.0
179179
```
180180

181-
> **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.
181+
> **The PR title shows targets, not counters.** The title and body display each version as `1.2.0-rc.x` — the target comes from committed bump files (deterministic), while the `.x` counter is assigned from the registry at publish time. This is why the title can never be out of sync with what publishes: everything it claims is derived from committed state. The exact counter lands in the git tag and GitHub release moments after merge. Bumpy never reads versions back out of PR titles or commit messages.
182182

183183
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.
184184

@@ -254,7 +254,7 @@ Instead:
254254
- **`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.
255255
- **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.
256256

257-
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.
257+
There is deliberately no versions index file or per-channel README either — any committed reflection of registry state can go stale and mislead (failed publishes, retargets, resets). The release PR title and merge commit message show only what's derivable from committed state (targets with a wildcard counter, `1.2.0-rc.x`); live truth is always `bumpy status`, the dist-tags, and the git tags.
258258

259259
---
260260

@@ -387,7 +387,7 @@ Defaults applied when a field is omitted:
387387

388388
- `preid` — defaults to the channel name (e.g., `next` → `1.2.0-next.0`).
389389
- `tag` — defaults to the channel name (so `@next`).
390-
- `versionPr.title` — defaults to `<base-title> (<channel>): <computed versions>` — the versions in the title are advisory narrative; the registry wins at publish time.
390+
- `versionPr.title` — defaults to `<base-title> (<channel>)`. A version summary is appended: `name@1.2.0-rc.x` for a single package, or a package count for several. The `.x` counter is assigned from the registry at publish time, so the title only ever claims what's derivable from committed state.
391391
- `versionPr.branch` — defaults to `<base-branch>-<channel>` (e.g., `bumpy/version-packages-next`).
392392
- `versionPr.automerge` — defaults to `false`.
393393

packages/bumpy/src/commands/ci.ts

Lines changed: 14 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
resolveChannels,
1414
type ResolvedChannel,
1515
} from '../core/channels.ts';
16-
import { buildChannelReleasePlan, formatChannelVersionSummary } from '../core/prerelease.ts';
16+
import { buildChannelReleasePlan, channelDisplayPlan, formatChannelVersionSummary } from '../core/prerelease.ts';
1717
import { runArgs, runArgsAsync, tryRunArgs } from '../utils/shell.ts';
1818
import { randomName } from '../utils/names.ts';
1919
import { detectPackageManager } from '../utils/package-manager.ts';
@@ -194,7 +194,7 @@ export async function ciCheckCommand(rootDir: string, opts: CheckOptions): Promi
194194
);
195195

196196
// Pretty output for logs
197-
const releaseSuffix = prChannel ? `-${prChannel.preid}.?` : '';
197+
const releaseSuffix = prChannel ? `-${prChannel.preid}.x` : '';
198198
log.bold(
199199
`${prBumpFiles.length} bump file(s) → ${plan.releases.length} package(s) to release` +
200200
`${prChannel ? ` on the "${prChannel.name}" channel (@${prChannel.tag})` : ''}\n`,
@@ -804,8 +804,8 @@ async function ciChannelRelease(
804804
/**
805805
* Create or update the channel's release PR. Unlike the stable version PR, its diff
806806
* is pure file moves (pending bump files → `.bumpy/<channel>/`) — no versions, no
807-
* changelogs. Computed prerelease versions appear in the PR title and body as
808-
* point-in-time narrative; the registry wins at publish time.
807+
* changelogs. The PR title/body show targets with a wildcard counter (`1.2.0-rc.x`),
808+
* derived purely from committed state; the exact counter is assigned at publish time.
809809
*/
810810
async function createChannelReleasePr(
811811
rootDir: string,
@@ -840,30 +840,15 @@ async function createChannelReleasePr(
840840
return;
841841
}
842842

843-
// Compute prerelease versions for the PR title/body. Best-effort — these are
844-
// narrative (recomputed at publish time); offline we fall back to target-".?".
845-
let displayPlan: ReleasePlan = result.cyclePlan;
846-
let displayIsExact = false;
847-
try {
848-
const built = await buildChannelReleasePlan(result.cyclePlan, channel, packages, rootDir, { forDisplay: true });
849-
if (built.plan.releases.length > 0) {
850-
displayPlan = built.plan;
851-
displayIsExact = true;
852-
}
853-
} catch {
854-
// registry unavailable — keep stable targets
855-
}
856-
if (!displayIsExact) {
857-
displayPlan = {
858-
...displayPlan,
859-
releases: displayPlan.releases.map((r) => ({ ...r, newVersion: `${r.newVersion}-${channel.preid}.?` })),
860-
};
861-
}
843+
// Versions shown in the PR title/body/commit message are deterministic: targets
844+
// come from committed bump files; the counter is a wildcard (`-rc.x`) because the
845+
// real one is derived from the registry at publish time and could drift by merge.
846+
const displayPlan = channelDisplayPlan(result.cyclePlan, channel, packages);
862847

863848
const versionSummary = formatChannelVersionSummary(displayPlan.releases);
864849
const prTitle = versionSummary ? `${channel.versionPr.title}: ${versionSummary}` : channel.versionPr.title;
865850

866-
// Commit the moves — the computed versions live in the commit message, so
851+
// Commit the moves — the version summary lives in the commit message, so
867852
// `git log` on the channel branch reads as a release history
868853
runArgs(['git', 'add', '-A', '.bumpy/'], { cwd: rootDir });
869854
const status = tryRunArgs(['git', 'status', '--porcelain'], { cwd: rootDir });
@@ -935,7 +920,7 @@ function buildChannelPrPreamble(config: BumpyConfig, channel: ResolvedChannel):
935920
config.versionPr.preamble,
936921
'',
937922
`> 🔀 **Prerelease channel \`${channel.name}\`** — merging this PR publishes the versions below to the \`@${channel.tag}\` dist-tag.`,
938-
`> 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.`,
923+
`> The diff only moves bump files into \`.bumpy/${channel.name}/\` — prerelease versions are derived at publish time and never committed. The \`.x\` counter is assigned from the registry at publish time.`,
939924
].join('\n');
940925
}
941926

@@ -1037,9 +1022,9 @@ export function formatReleasePlanComment(
10371022
const repo = process.env.GITHUB_REPOSITORY;
10381023
const lines: string[] = [];
10391024

1040-
// When targeting a prerelease channel, the version display carries the `-<preid>.?`
1041-
// suffix (the exact counter is derived from the registry at publish time).
1042-
const versionSuffix = channel ? `-${channel.preid}.?` : '';
1025+
// When targeting a prerelease channel, the version display carries a wildcard
1026+
// `-<preid>.x` suffix (the exact counter is derived from the registry at publish time).
1027+
const versionSuffix = channel ? `-${channel.preid}.x` : '';
10431028

10441029
const headline = channel
10451030
? `**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.`
@@ -1078,7 +1063,7 @@ export function formatReleasePlanComment(
10781063
const installHint = examplePkg ? ` (e.g. \`npm i ${examplePkg}@${channel.tag}\`)` : '';
10791064
lines.push(
10801065
`> 🔀 Published to the \`@${channel.tag}\` dist-tag${installHint}. ` +
1081-
`Prerelease versions are derived at publish time — the \`.?\` counter is filled in from the registry. ` +
1066+
`Prerelease versions are derived at publish time — the \`.x\` counter is filled in from the registry. ` +
10821067
`Promote to a stable release by merging \`${channel.branch}\` into your base branch.`,
10831068
);
10841069
lines.push('');

packages/bumpy/src/commands/version.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ export async function channelVersion(
158158
log.step('Cycle targets (counters are derived from the registry at publish time):');
159159
for (const r of cyclePlan.releases) {
160160
const tag = r.isDependencyBump ? ' (dep)' : r.isCascadeBump ? ' (cascade)' : '';
161-
console.log(` ${r.name}: ${r.oldVersion}${colorize(`${r.newVersion}-${channel.preid}.?`, 'cyan')}${tag}`);
161+
console.log(` ${r.name}: ${r.oldVersion}${colorize(`${r.newVersion}-${channel.preid}.x`, 'cyan')}${tag}`);
162162
}
163163

164164
await moveBumpFilesToChannel(rootDir, pending, channel.name);

packages/bumpy/src/core/prerelease.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -230,11 +230,30 @@ export async function writeChannelVersionsInPlace(
230230
};
231231
}
232232

233+
/**
234+
* Derive display versions for a channel cycle without touching the registry:
235+
* each target gets a wildcard counter (`1.2.0-rc.x`). Everything here comes from
236+
* committed state (bump files + config), so PR titles/bodies and commit messages
237+
* can never disagree with what eventually publishes. Unpublishable packages are
238+
* dropped, mirroring the filter in `buildChannelReleasePlan`.
239+
*/
240+
export function channelDisplayPlan(
241+
stablePlan: ReleasePlan,
242+
channel: ResolvedChannel,
243+
packages: Map<string, WorkspacePackage>,
244+
): ReleasePlan {
245+
const releases = stablePlan.releases
246+
.filter((r) => {
247+
const pkg = packages.get(r.name);
248+
return !!pkg && !(pkg.private && !pkg.bumpy?.publishCommand);
249+
})
250+
.map((r) => ({ ...r, newVersion: `${r.newVersion}-${channel.preid}.x` }));
251+
return { ...stablePlan, releases };
252+
}
253+
233254
/** One-line summary of a channel plan's versions, for PR titles and commit messages */
234255
export function formatChannelVersionSummary(releases: PlannedRelease[]): string {
235256
if (releases.length === 0) return '';
236-
const direct = releases.filter((r) => !r.isDependencyBump && !r.isCascadeBump && !r.isGroupBump);
237-
const lead = (direct[0] ?? releases[0])!;
238-
const rest = releases.length - 1;
239-
return rest > 0 ? `${lead.name}@${lead.newVersion} (+${rest} more)` : `${lead.name}@${lead.newVersion}`;
257+
if (releases.length === 1) return `${releases[0]!.name}@${releases[0]!.newVersion}`;
258+
return `${releases.length} packages`;
240259
}

packages/bumpy/test/core/ci-channel-comment.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ describe('formatReleasePlanComment — prerelease channel', () => {
3535
expect(comment).toContain('@next');
3636
});
3737

38-
test('versions carry the derived "-rc.?" suffix', () => {
39-
expect(comment).toContain('1.1.0 → **1.2.0-rc.?**');
38+
test('versions carry the wildcard "-rc.x" suffix', () => {
39+
expect(comment).toContain('1.1.0 → **1.2.0-rc.x**');
4040
});
4141

4242
test('includes a dist-tag install hint and promotion note', () => {

packages/bumpy/test/core/prerelease.test.ts

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
nextPrereleaseVersion,
77
writeChannelVersionsInPlace,
88
formatChannelVersionSummary,
9+
channelDisplayPlan,
910
} from '../../src/core/prerelease.ts';
1011
import { makePkg, makeRelease, makeReleasePlan, createTempGitRepo, cleanupTempDir } from '../helpers.ts';
1112

@@ -112,20 +113,52 @@ describe('writeChannelVersionsInPlace', () => {
112113
});
113114

114115
describe('formatChannelVersionSummary', () => {
115-
test('single release', () => {
116-
expect(formatChannelVersionSummary([makeRelease('core', '1.2.0-rc.0')])).toBe('core@1.2.0-rc.0');
116+
test('single release shows name@version', () => {
117+
expect(formatChannelVersionSummary([makeRelease('core', '1.2.0-rc.x')])).toBe('core@1.2.0-rc.x');
117118
});
118119

119-
test('leads with a direct (non-cascade) release and counts the rest', () => {
120+
test('multiple releases show a count instead of an arbitrary lead', () => {
120121
const releases = [
121-
makeRelease('plugin', '1.0.1-rc.0', { isDependencyBump: true }),
122-
makeRelease('core', '1.2.0-rc.0'),
123-
makeRelease('utils', '2.0.1-rc.0', { isDependencyBump: true }),
122+
makeRelease('plugin', '1.0.1-rc.x', { isDependencyBump: true }),
123+
makeRelease('core', '1.2.0-rc.x'),
124+
makeRelease('utils', '2.0.1-rc.x', { isDependencyBump: true }),
124125
];
125-
expect(formatChannelVersionSummary(releases)).toBe('core@1.2.0-rc.0 (+2 more)');
126+
expect(formatChannelVersionSummary(releases)).toBe('3 packages');
126127
});
127128

128129
test('empty plan', () => {
129130
expect(formatChannelVersionSummary([])).toBe('');
130131
});
131132
});
133+
134+
describe('channelDisplayPlan', () => {
135+
const channel = {
136+
name: 'next',
137+
branch: 'next',
138+
preid: 'rc',
139+
tag: 'next',
140+
versionPr: { title: '🐸 Versioned prerelease (next)', branch: 'bumpy/release-next', automerge: false },
141+
};
142+
143+
test('appends a wildcard counter to each target', () => {
144+
const plan = makeReleasePlan([makeRelease('core', '1.2.0', { oldVersion: '1.1.0' })]);
145+
const packages = new Map([['core', makePkg('core', '1.1.0')]]);
146+
const display = channelDisplayPlan(plan, channel, packages);
147+
expect(display.releases.map((r) => r.newVersion)).toEqual(['1.2.0-rc.x']);
148+
});
149+
150+
test('drops unpublishable packages, keeps private ones with a publishCommand', () => {
151+
const plan = makeReleasePlan([
152+
makeRelease('core', '1.2.0'),
153+
makeRelease('internal', '0.5.0'),
154+
makeRelease('cli', '2.0.0'),
155+
]);
156+
const packages = new Map([
157+
['core', makePkg('core', '1.1.0')],
158+
['internal', makePkg('internal', '0.4.0', { private: true })],
159+
['cli', makePkg('cli', '1.9.0', { private: true, bumpy: { publishCommand: 'cargo publish' } })],
160+
]);
161+
const display = channelDisplayPlan(plan, channel, packages);
162+
expect(display.releases.map((r) => r.name)).toEqual(['core', 'cli']);
163+
});
164+
});

0 commit comments

Comments
 (0)