diff --git a/.bumpy/deterministic-channel-pr-title.md b/.bumpy/deterministic-channel-pr-title.md new file mode 100644 index 0000000..ec6d333 --- /dev/null +++ b/.bumpy/deterministic-channel-pr-title.md @@ -0,0 +1,5 @@ +--- +'@varlock/bumpy': patch +--- + +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). diff --git a/docs/prereleases.md b/docs/prereleases.md index ad87816..4f4bf9c 100644 --- a/docs/prereleases.md +++ b/docs/prereleases.md @@ -55,7 +55,7 @@ Rough rule of thumb: - **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. +- **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. - **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. --- @@ -164,7 +164,7 @@ When a feature PR merges to `next`: 1. `bumpy ci release` runs on the `next` push. 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. +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. When a maintainer merges that PR: @@ -176,7 +176,7 @@ When a maintainer merges that PR: 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 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. +> **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. 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. @@ -252,7 +252,7 @@ Instead: - **`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. +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. --- @@ -385,7 +385,7 @@ 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 ` (): ` — the versions in the title are advisory narrative; the registry wins at publish time. +- `versionPr.title` — defaults to ` ()`. 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. - `versionPr.branch` — defaults to `-` (e.g., `bumpy/version-packages-next`). - `versionPr.automerge` — defaults to `false`. diff --git a/packages/bumpy/src/commands/ci.ts b/packages/bumpy/src/commands/ci.ts index 19835e3..dc94834 100644 --- a/packages/bumpy/src/commands/ci.ts +++ b/packages/bumpy/src/commands/ci.ts @@ -13,7 +13,7 @@ import { resolveChannels, type ResolvedChannel, } from '../core/channels.ts'; -import { buildChannelReleasePlan, formatChannelVersionSummary } from '../core/prerelease.ts'; +import { buildChannelReleasePlan, channelDisplayPlan, 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'; @@ -194,7 +194,7 @@ export async function ciCheckCommand(rootDir: string, opts: CheckOptions): Promi ); // Pretty output for logs - const releaseSuffix = prChannel ? `-${prChannel.preid}.?` : ''; + const releaseSuffix = prChannel ? `-${prChannel.preid}.x` : ''; log.bold( `${prBumpFiles.length} bump file(s) → ${plan.releases.length} package(s) to release` + `${prChannel ? ` on the "${prChannel.name}" channel (@${prChannel.tag})` : ''}\n`, @@ -804,8 +804,8 @@ async function ciChannelRelease( /** * 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. + * changelogs. The PR title/body show targets with a wildcard counter (`1.2.0-rc.x`), + * derived purely from committed state; the exact counter is assigned at publish time. */ async function createChannelReleasePr( rootDir: string, @@ -840,30 +840,15 @@ async function createChannelReleasePr( 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}.?` })), - }; - } + // Versions shown in the PR title/body/commit message are deterministic: targets + // come from committed bump files; the counter is a wildcard (`-rc.x`) because the + // real one is derived from the registry at publish time and could drift by merge. + const displayPlan = channelDisplayPlan(result.cyclePlan, channel, packages); 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 + // Commit the moves — the version summary lives 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 }); @@ -935,7 +920,7 @@ function buildChannelPrPreamble(config: BumpyConfig, channel: ResolvedChannel): 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.`, + `> 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.`, ].join('\n'); } @@ -1037,9 +1022,9 @@ export function formatReleasePlanComment( 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}.?` : ''; + // When targeting a prerelease channel, the version display carries a wildcard + // `-.x` suffix (the exact counter is derived from the registry at publish time). + const versionSuffix = channel ? `-${channel.preid}.x` : ''; 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.` @@ -1078,7 +1063,7 @@ export function formatReleasePlanComment( 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. ` + + `Prerelease versions are derived at publish time — the \`.x\` counter is filled in from the registry. ` + `Promote to a stable release by merging \`${channel.branch}\` into your base branch.`, ); lines.push(''); diff --git a/packages/bumpy/src/commands/version.ts b/packages/bumpy/src/commands/version.ts index 869e620..86222b7 100644 --- a/packages/bumpy/src/commands/version.ts +++ b/packages/bumpy/src/commands/version.ts @@ -158,7 +158,7 @@ export async function channelVersion( 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}`); + console.log(` ${r.name}: ${r.oldVersion} → ${colorize(`${r.newVersion}-${channel.preid}.x`, 'cyan')}${tag}`); } await moveBumpFilesToChannel(rootDir, pending, channel.name); diff --git a/packages/bumpy/src/core/prerelease.ts b/packages/bumpy/src/core/prerelease.ts index 1399992..8d3a828 100644 --- a/packages/bumpy/src/core/prerelease.ts +++ b/packages/bumpy/src/core/prerelease.ts @@ -230,11 +230,30 @@ export async function writeChannelVersionsInPlace( }; } +/** + * Derive display versions for a channel cycle without touching the registry: + * each target gets a wildcard counter (`1.2.0-rc.x`). Everything here comes from + * committed state (bump files + config), so PR titles/bodies and commit messages + * can never disagree with what eventually publishes. Unpublishable packages are + * dropped, mirroring the filter in `buildChannelReleasePlan`. + */ +export function channelDisplayPlan( + stablePlan: ReleasePlan, + channel: ResolvedChannel, + packages: Map, +): ReleasePlan { + const releases = stablePlan.releases + .filter((r) => { + const pkg = packages.get(r.name); + return !!pkg && !(pkg.private && !pkg.bumpy?.publishCommand); + }) + .map((r) => ({ ...r, newVersion: `${r.newVersion}-${channel.preid}.x` })); + return { ...stablePlan, releases }; +} + /** 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}`; + if (releases.length === 1) return `${releases[0]!.name}@${releases[0]!.newVersion}`; + return `${releases.length} packages`; } diff --git a/packages/bumpy/test/core/ci-channel-comment.test.ts b/packages/bumpy/test/core/ci-channel-comment.test.ts index 2d40986..b548ecd 100644 --- a/packages/bumpy/test/core/ci-channel-comment.test.ts +++ b/packages/bumpy/test/core/ci-channel-comment.test.ts @@ -35,8 +35,8 @@ describe('formatReleasePlanComment — prerelease channel', () => { expect(comment).toContain('@next'); }); - test('versions carry the derived "-rc.?" suffix', () => { - expect(comment).toContain('1.1.0 → **1.2.0-rc.?**'); + test('versions carry the wildcard "-rc.x" suffix', () => { + expect(comment).toContain('1.1.0 → **1.2.0-rc.x**'); }); test('includes a dist-tag install hint and promotion note', () => { diff --git a/packages/bumpy/test/core/prerelease.test.ts b/packages/bumpy/test/core/prerelease.test.ts index 2d7c2ea..b7f2e71 100644 --- a/packages/bumpy/test/core/prerelease.test.ts +++ b/packages/bumpy/test/core/prerelease.test.ts @@ -6,6 +6,7 @@ import { nextPrereleaseVersion, writeChannelVersionsInPlace, formatChannelVersionSummary, + channelDisplayPlan, } from '../../src/core/prerelease.ts'; import { makePkg, makeRelease, makeReleasePlan, createTempGitRepo, cleanupTempDir } from '../helpers.ts'; @@ -112,20 +113,52 @@ describe('writeChannelVersionsInPlace', () => { }); describe('formatChannelVersionSummary', () => { - test('single release', () => { - expect(formatChannelVersionSummary([makeRelease('core', '1.2.0-rc.0')])).toBe('core@1.2.0-rc.0'); + test('single release shows name@version', () => { + expect(formatChannelVersionSummary([makeRelease('core', '1.2.0-rc.x')])).toBe('core@1.2.0-rc.x'); }); - test('leads with a direct (non-cascade) release and counts the rest', () => { + test('multiple releases show a count instead of an arbitrary lead', () => { 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 }), + makeRelease('plugin', '1.0.1-rc.x', { isDependencyBump: true }), + makeRelease('core', '1.2.0-rc.x'), + makeRelease('utils', '2.0.1-rc.x', { isDependencyBump: true }), ]; - expect(formatChannelVersionSummary(releases)).toBe('core@1.2.0-rc.0 (+2 more)'); + expect(formatChannelVersionSummary(releases)).toBe('3 packages'); }); test('empty plan', () => { expect(formatChannelVersionSummary([])).toBe(''); }); }); + +describe('channelDisplayPlan', () => { + const channel = { + name: 'next', + branch: 'next', + preid: 'rc', + tag: 'next', + versionPr: { title: '🐸 Versioned prerelease (next)', branch: 'bumpy/release-next', automerge: false }, + }; + + test('appends a wildcard counter to each target', () => { + const plan = makeReleasePlan([makeRelease('core', '1.2.0', { oldVersion: '1.1.0' })]); + const packages = new Map([['core', makePkg('core', '1.1.0')]]); + const display = channelDisplayPlan(plan, channel, packages); + expect(display.releases.map((r) => r.newVersion)).toEqual(['1.2.0-rc.x']); + }); + + test('drops unpublishable packages, keeps private ones with a publishCommand', () => { + const plan = makeReleasePlan([ + makeRelease('core', '1.2.0'), + makeRelease('internal', '0.5.0'), + makeRelease('cli', '2.0.0'), + ]); + const packages = new Map([ + ['core', makePkg('core', '1.1.0')], + ['internal', makePkg('internal', '0.4.0', { private: true })], + ['cli', makePkg('cli', '1.9.0', { private: true, bumpy: { publishCommand: 'cargo publish' } })], + ]); + const display = channelDisplayPlan(plan, channel, packages); + expect(display.releases.map((r) => r.name)).toEqual(['core', 'cli']); + }); +});