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..6bfff62 100644 --- a/packages/bumpy/src/commands/ci.ts +++ b/packages/bumpy/src/commands/ci.ts @@ -683,6 +683,97 @@ async function createVersionPr( // 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; +} + +/** + * 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. + * + * 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, + config: BumpyConfig, + bumpFiles: BumpFile[], + /** 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 && arrived.has(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 ---- @@ -921,6 +1012,11 @@ async function createChannelReleasePr( // 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 {