From f7939b5b96704159496637b17bd04e42e0b59f98 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 12 Jun 2026 18:52:20 -0700 Subject: [PATCH 1/2] feat: auto-close promoted channel release PRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a cycle promotes (channel → main) or graduates (channel → channel), an open release PR on the source channel offers another prerelease of a cycle that already moved on. Close it with an explanatory comment when the destination's version/release PR is created; a fresh one is created if new work lands on the channel. --- .bumpy/auto-close-promoted-release-pr.md | 5 ++ docs/prereleases.md | 2 + packages/bumpy/src/commands/ci.ts | 59 ++++++++++++++++++++++++ 3 files changed, 66 insertions(+) create mode 100644 .bumpy/auto-close-promoted-release-pr.md diff --git a/.bumpy/auto-close-promoted-release-pr.md b/.bumpy/auto-close-promoted-release-pr.md new file mode 100644 index 0000000..7301e01 --- /dev/null +++ b/.bumpy/auto-close-promoted-release-pr.md @@ -0,0 +1,5 @@ +--- +'@varlock/bumpy': patch +--- + +When a prerelease cycle is promoted (channel → main) or graduated (channel → channel), any lingering release PR on the source channel is now closed automatically with an explanatory comment — merging it would have offered another prerelease of a cycle that already moved on. diff --git a/docs/prereleases.md b/docs/prereleases.md index 32bd6a8..d65d734 100644 --- a/docs/prereleases.md +++ b/docs/prereleases.md @@ -204,6 +204,8 @@ When the prerelease has been tested and you're ready to ship the real `1.2.0`: - Deletes `.bumpy/next/` 3. Merge that PR → bumpy publishes `1.2.0` to `@latest`, tags `v1.2.0`, and creates a stable GitHub release. +If the channel had an open release PR at promotion time (offering one more rc), it's now obsolete — bumpy closes it automatically with an explanatory comment when it creates the stable version PR. A fresh release PR appears if new work lands on the channel branch. The same applies to a source channel's release PR after a graduation merge (e.g. `alpha` → `beta`). + There is no special promotion mode. Promotion is literally "the bump files arrive on `main` and the stable flow eats them." > 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. diff --git a/packages/bumpy/src/commands/ci.ts b/packages/bumpy/src/commands/ci.ts index c9f41d8..093cb9d 100644 --- a/packages/bumpy/src/commands/ci.ts +++ b/packages/bumpy/src/commands/ci.ts @@ -681,10 +681,65 @@ async function createVersionPr( } } + // A promotion merge makes any open release PR on the source channel obsolete + await closePromotedChannelReleasePrs(rootDir, config, plan.bumpFiles); + // Switch back to the base branch runArgs(['git', 'checkout', baseBranch], { cwd: rootDir }); } +/** + * Close lingering channel release PRs whose cycles were promoted: once a channel's + * bump files are pending on this branch (via a promotion or graduation merge), the + * source channel's own release PR is obsolete — merging it would re-publish a cycle + * that's already moving to its next stage. A fresh release PR is created automatically + * if new work lands on the channel branch. + */ +async function closePromotedChannelReleasePrs( + rootDir: string, + config: BumpyConfig, + bumpFiles: BumpFile[], + /** The channel whose release PR is being maintained right now (never close our own) */ + currentChannel?: ResolvedChannel, +): Promise { + const promoted = [...new Set(bumpFiles.map((bf) => bf.channel))].filter( + (name): name is string => name != null && name !== currentChannel?.name, + ); + if (promoted.length === 0) return; + + const channels = resolveChannels(config); + for (const name of promoted) { + const channel = channels.get(name); + if (!channel) continue; + const pr = tryRunArgs( + ['gh', 'pr', 'list', '--head', channel.versionPr.branch, '--json', 'number', '--jq', '.[0].number'], + { cwd: rootDir }, + ); + if (!pr) continue; + const validPr = validatePrNumber(pr); + log.step(`Closing release PR #${validPr} — the "${name}" cycle's changes are pending here now...`); + try { + await withPatToken(() => + runArgsAsync( + [ + 'gh', + 'pr', + 'close', + validPr, + '--comment', + `Closing — the \`${name}\` cycle's bump files were promoted and are now pending a release here. ` + + `A new release PR will be created automatically if more changes land on \`${channel.branch}\`.`, + ], + { cwd: rootDir }, + ), + ); + log.success(`🐸 Closed obsolete release PR #${validPr}`); + } catch (e) { + log.warn(` Failed to close release PR #${validPr}: ${e}`); + } + } +} + // ---- channel (prerelease) release flow ---- /** Read the push event's before/after range, if running on a GitHub Actions push event */ @@ -919,6 +974,10 @@ async function createChannelReleasePr( await enableAutoMerge(rootDir, prNumber); } + // A graduation merge (e.g. alpha → beta) makes any open release PR on the + // source channel obsolete + await closePromotedChannelReleasePrs(rootDir, config, result.movedFiles, channel); + // Switch back to the channel branch runArgs(['git', 'checkout', baseBranch], { cwd: rootDir }); } From b13e94d79c14ae5f74ea3e399917b41477f6aa42 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 12 Jun 2026 18:58:15 -0700 Subject: [PATCH 2/2] fix: scope release-PR auto-close to the delivering push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promoted bump files stay pending on the destination branch until its version/release PR merges. Closing on every push in that window would kill the release PR of a newly restarted cycle (close → recreate ping-pong). Gate the close on the triggering push's before..after range actually adding files under .bumpy// — the same detection the channel publish trigger uses. Runs from the destination branch so the non-CI fallback (HEAD^..HEAD) diffs the triggering commit. --- packages/bumpy/src/commands/ci.ts | 53 ++++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/packages/bumpy/src/commands/ci.ts b/packages/bumpy/src/commands/ci.ts index 093cb9d..6bfff62 100644 --- a/packages/bumpy/src/commands/ci.ts +++ b/packages/bumpy/src/commands/ci.ts @@ -681,11 +681,42 @@ async function createVersionPr( } } - // A promotion merge makes any open release PR on the source channel obsolete - await closePromotedChannelReleasePrs(rootDir, config, plan.bumpFiles); - // Switch back to the base branch runArgs(['git', 'checkout', baseBranch], { cwd: rootDir }); + + // A promotion merge makes any open release PR on the source channel obsolete. + // (Runs from the base branch so the non-CI fallback diffs the triggering commit.) + await closePromotedChannelReleasePrs(rootDir, config, plan.bumpFiles); +} + +/** + * Channels whose dirs gained bump files in the triggering push — i.e. this push is + * the promotion/graduation merge that delivered them. (Same range detection as the + * channel publish trigger.) + */ +function detectArrivedChannelFiles(rootDir: string, config: BumpyConfig): Set { + const range = getPushEventRange(); + let diffRange: string; + if (range) { + diffRange = `${range.before}..${range.after}`; + } else { + if (!tryRunArgs(['git', 'rev-parse', '--verify', 'HEAD^'], { cwd: rootDir })) return new Set(); + diffRange = 'HEAD^..HEAD'; + } + // --no-renames so file moves into channel dirs show up as additions + const out = tryRunArgs( + ['git', 'diff', '--name-only', '--diff-filter=A', '--no-renames', diffRange, '--', '.bumpy/'], + { cwd: rootDir }, + ); + if (!out) return new Set(); + const knownChannels = new Set(channelNames(config)); + const arrived = new Set(); + for (const f of out.split('\n')) { + if (!f.endsWith('.md') || f.endsWith('README.md')) continue; + const parts = f.split('/'); // .bumpy//.md + if (parts.length === 3 && knownChannels.has(parts[1]!)) arrived.add(parts[1]!); + } + return arrived; } /** @@ -694,6 +725,10 @@ async function createVersionPr( * source channel's own release PR is obsolete — merging it would re-publish a cycle * that's already moving to its next stage. A fresh release PR is created automatically * if new work lands on the channel branch. + * + * Only channels whose files arrived in the TRIGGERING push are considered: the files + * stay pending here until our version/release PR merges, and re-closing on every + * later push in that window would kill the release PR of a newly restarted cycle. */ async function closePromotedChannelReleasePrs( rootDir: string, @@ -702,8 +737,9 @@ async function closePromotedChannelReleasePrs( /** The channel whose release PR is being maintained right now (never close our own) */ currentChannel?: ResolvedChannel, ): Promise { + const arrived = detectArrivedChannelFiles(rootDir, config); const promoted = [...new Set(bumpFiles.map((bf) => bf.channel))].filter( - (name): name is string => name != null && name !== currentChannel?.name, + (name): name is string => name != null && name !== currentChannel?.name && arrived.has(name), ); if (promoted.length === 0) return; @@ -974,12 +1010,13 @@ async function createChannelReleasePr( await enableAutoMerge(rootDir, prNumber); } - // A graduation merge (e.g. alpha → beta) makes any open release PR on the - // source channel obsolete - await closePromotedChannelReleasePrs(rootDir, config, result.movedFiles, channel); - // Switch back to the channel branch runArgs(['git', 'checkout', baseBranch], { cwd: rootDir }); + + // A graduation merge (e.g. alpha → beta) makes any open release PR on the + // source channel obsolete. + // (Runs from the channel branch so the non-CI fallback diffs the triggering commit.) + await closePromotedChannelReleasePrs(rootDir, config, result.movedFiles, channel); } function buildChannelPrPreamble(config: BumpyConfig, channel: ResolvedChannel): string {