From 66320991a02e98edb0df42a505f3722fd2d1a3de Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 12 Jun 2026 16:31:17 -0700 Subject: [PATCH] feat: explicit promotion callout in PR check comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A promotion PR (next → main) previously got the generic 'included in the next version bump' headline, despite being the highest-stakes merge in the channel flow — it ends the cycle, consolidates the changelog, and ships to @latest. Stable-targeted PRs carrying channel-shipped bump files now get a promotion headline, and shipped files are annotated with their dist-tag in the list. --- .bumpy/promotion-pr-comment.md | 5 ++ packages/bumpy/src/commands/ci.ts | 16 +++++- .../test/core/ci-channel-comment.test.ts | 51 ++++++++++++++++--- 3 files changed, 64 insertions(+), 8 deletions(-) create mode 100644 .bumpy/promotion-pr-comment.md diff --git a/.bumpy/promotion-pr-comment.md b/.bumpy/promotion-pr-comment.md new file mode 100644 index 0000000..1fe327b --- /dev/null +++ b/.bumpy/promotion-pr-comment.md @@ -0,0 +1,5 @@ +--- +'@varlock/bumpy': patch +--- + +The PR check comment now explicitly calls out promotion PRs (channel → stable): the headline explains that merging ends the prerelease cycle and ships stable, and bump files that already shipped on a channel are annotated with their dist-tag (e.g. `next/feature.md` _(shipped on `@next`)_). diff --git a/packages/bumpy/src/commands/ci.ts b/packages/bumpy/src/commands/ci.ts index df1d17c..c9f41d8 100644 --- a/packages/bumpy/src/commands/ci.ts +++ b/packages/bumpy/src/commands/ci.ts @@ -108,9 +108,10 @@ export async function ciCheckCommand(rootDir: string, opts: CheckOptions): Promi // Skip on the version PR branch (and channel release PR branches) — they move/consume // bump files by design const prBranchName = detectPrBranch(rootDir); + const channels = resolveChannels(config); const releasePrBranches = new Set([ config.versionPr.branch, - ...[...resolveChannels(config).values()].map((c) => c.versionPr.branch), + ...[...channels.values()].map((c) => c.versionPr.branch), ]); if (prBranchName && releasePrBranches.has(prBranchName)) { log.dim(' Skipping — this is a release PR branch.'); @@ -228,6 +229,7 @@ export async function ciCheckCommand(rootDir: string, opts: CheckOptions): Promi parseErrors, emptyBumpFileIds, prChannel, + channels, ); await postOrUpdatePrComment(prNumber, comment, rootDir); } @@ -1024,6 +1026,7 @@ export function formatReleasePlanComment( parseErrors: string[] = [], emptyBumpFileIds: string[] = [], channel: ResolvedChannel | null = null, + allChannels: Map | null = null, ): string { const repo = process.env.GITHUB_REPOSITORY; const lines: string[] = []; @@ -1032,9 +1035,17 @@ export function formatReleasePlanComment( // `-.x` suffix (the exact counter is derived from the registry at publish time). const versionSuffix = channel ? `-${channel.preid}.x` : ''; + // Promotion PR: stable-targeted, carrying bump files that already shipped on a + // channel (e.g. `next` → `main`). Merging it ends the cycle and ships stable. + const promotedChannels = channel ? [] : [...new Set(bumpFiles.map((bf) => bf.channel))].filter((c) => c != null); + const channelTag = (name: string) => `\`@${allChannels?.get(name)?.tag ?? name}\``; + 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.**'; + : promotedChannels.length > 0 + ? `**This PR promotes the ${promotedChannels.map((c) => `\`${c}\``).join(', ')} prerelease cycle${promotedChannels.length > 1 ? 's' : ''} to a stable release.** ` + + `The changes below that already shipped to the ${promotedChannels.map(channelTag).join(', ')} dist-tag${promotedChannels.length > 1 ? 's' : ''} will be consolidated into the next stable version bump.` + : '**The changes in this PR will be included in the next version bump.**'; const preamble = [ `bumpy-frog`, '', @@ -1082,6 +1093,7 @@ export function formatReleasePlanComment( // Channel-dir files (pending on promotion/graduation PRs) live at `.bumpy//` const filename = bf.channel ? `${bf.channel}/${bf.id}.md` : `${bf.id}.md`; const parts: string[] = [`\`${filename}\``]; + if (bf.channel) parts.push(`_(shipped on ${channelTag(bf.channel)})_`); if (repo) { parts.push( `([view diff](https://github.com/${repo}/pull/${prNumber}/changes#diff-${sha256Hex(`.bumpy/${filename}`)}))`, diff --git a/packages/bumpy/test/core/ci-channel-comment.test.ts b/packages/bumpy/test/core/ci-channel-comment.test.ts index 7b50132..9011de1 100644 --- a/packages/bumpy/test/core/ci-channel-comment.test.ts +++ b/packages/bumpy/test/core/ci-channel-comment.test.ts @@ -3,9 +3,8 @@ 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 allChannels = resolveChannels(makeConfig({ channels: { next: { branch: 'next', preid: 'rc', tag: 'next' } } })); +const channel = allChannels.get('next')!; const plan = makeReleasePlan( [makeRelease('@myorg/core', '1.2.0', { type: 'minor', oldVersion: '1.1.0', bumpFiles: ['feat'] })], @@ -51,14 +50,54 @@ describe('formatReleasePlanComment — promotion PR (channel-dir bump files, sta [makeRelease('@myorg/core', '1.2.0', { type: 'minor', oldVersion: '1.1.0', bumpFiles: ['feat'] })], [{ ...makeBumpFile('feat', [{ name: '@myorg/core', type: 'minor' }], 'Add a feature'), channel: 'next' }], ); - const comment = formatReleasePlanComment(promotionPlan, promotionPlan.bumpFiles, '1', 'next', 'npm'); + const comment = formatReleasePlanComment( + promotionPlan, + promotionPlan.bumpFiles, + '1', + 'next', + 'npm', + [], + [], + [], + null, + allChannels, + ); + + test('headline calls out the promotion explicitly', () => { + expect(comment).toContain('promotes the `next` prerelease cycle to a stable release'); + expect(comment).toContain('already shipped to the `@next` dist-tag'); + expect(comment).not.toContain('included in the next version bump'); + }); - test('renders channel-dir bump files with their subdir path', () => { - expect(comment).toContain('`next/feat.md`'); + test('renders channel-dir bump files with their subdir path and shipped annotation', () => { + expect(comment).toContain('`next/feat.md` _(shipped on `@next`)_'); }); test('shows the stable plan (no channel banner, no preid suffix)', () => { expect(comment).toContain('1.1.0 → **1.2.0**'); expect(comment).not.toContain('prerelease channel'); }); + + test('mixed promotion: root bump files get no shipped annotation', () => { + const mixedPlan = makeReleasePlan(promotionPlan.releases, [ + ...promotionPlan.bumpFiles, + makeBumpFile('new-fix', [{ name: '@myorg/core', type: 'patch' }], 'A fix that never shipped as an rc'), + ]); + const mixed = formatReleasePlanComment( + mixedPlan, + mixedPlan.bumpFiles, + '1', + 'next', + 'npm', + [], + [], + [], + null, + allChannels, + ); + expect(mixed).toContain('promotes the `next` prerelease cycle'); + expect(mixed).toContain('`next/feat.md` _(shipped on `@next`)_'); + expect(mixed).toContain('- `new-fix.md`'); + expect(mixed).not.toContain('`new-fix.md` _(shipped'); + }); });