Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .bumpy/auto-close-promoted-release-pr.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions docs/prereleases.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
96 changes: 96 additions & 0 deletions packages/bumpy/src/commands/ci.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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<string>();
for (const f of out.split('\n')) {
if (!f.endsWith('.md') || f.endsWith('README.md')) continue;
const parts = f.split('/'); // .bumpy/<channel>/<id>.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<void> {
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 ----
Expand Down Expand Up @@ -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 {
Expand Down