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/version-pr-body-limit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@varlock/bumpy': patch
---

Degrade the version PR body when it would exceed GitHub's 65536-character limit (which previously failed the release for large multi-package releases). The body now drops inline change summaries — and hard-truncates as a last resort — instead of erroring.
112 changes: 73 additions & 39 deletions packages/bumpy/src/commands/ci.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1420,7 +1420,14 @@ function sha256Hex(input: string): string {
return createHash('sha256').update(input).digest('hex');
}

function formatVersionPrBody(
// GitHub rejects pull request bodies longer than this many characters
// (GraphQL: "Body is too long (maximum is 65536 characters)"). We leave a
// little headroom for safety since GitHub's count and JS string length can
// differ slightly for multi-byte characters.
const GH_BODY_MAX = 65_536;
const GH_BODY_SAFE = 64_000;

export function formatVersionPrBody(
plan: ReleasePlan,
preamble: string,
packageDirs: Map<string, string>,
Expand All @@ -1429,63 +1436,90 @@ function formatVersionPrBody(
showNoPatWarning = false,
): string {
const changesBaseUrl = repo && prNumber ? `https://github.com/${repo}/pull/${prNumber}/changes` : null;
const lines: string[] = [];
lines.push(preamble);
lines.push('');

const groups: Record<string, PlannedRelease[]> = { major: [], minor: [], patch: [] };
for (const r of plan.releases) {
groups[r.type]?.push(r);
}

for (const type of ['major', 'minor', 'patch'] as const) {
const releases = groups[type];
if (!releases || releases.length === 0) continue;

lines.push(bumpSectionHeader(type));
// Render the body at a given detail level. `includeSummaries: false` drops
// the per-change bullet points, leaving just the version-bump headers — a
// big size reduction for releases with many or large change summaries.
const render = (includeSummaries: boolean): string => {
const lines: string[] = [];
lines.push(preamble);
lines.push('');
for (const r of releases) {
const suffix = r.isDependencyBump ? ' _(dep)_' : r.isCascadeBump ? ' _(cascade)_' : '';
const pkgDir = packageDirs.get(r.name);
const diffLinks = pkgDir ? buildDiffLinks(pkgDir, changesBaseUrl) : '';
lines.push(`#### \`${r.name}\` ${r.oldVersion} → **${r.newVersion}**${suffix}${diffLinks}`);

if (!includeSummaries) {
lines.push(
'> ℹ️ This release contains too many changes to summarize inline. See the **Files changed** tab and each package’s `CHANGELOG.md` for details.',
);
lines.push('');
}

for (const type of ['major', 'minor', 'patch'] as const) {
const releases = groups[type];
if (!releases || releases.length === 0) continue;

const relevantBumpFiles = plan.bumpFiles.filter((bf) => r.bumpFiles.includes(bf.id));

if (relevantBumpFiles.length > 0) {
for (const bf of relevantBumpFiles) {
if (bf.summary) {
const bfLink = changesBaseUrl
? ` ([bump file](${changesBaseUrl}#diff-${sha256Hex(`.bumpy/${bf.id}.md`)}))`
: '';
const summaryLines = bf.summary.split('\n');
lines.push(`- ${summaryLines[0]}${bfLink}`);
for (let i = 1; i < summaryLines.length; i++) {
if (summaryLines[i]!.trim()) {
lines.push(` ${summaryLines[i]}`);
lines.push(bumpSectionHeader(type));
lines.push('');
for (const r of releases) {
const suffix = r.isDependencyBump ? ' _(dep)_' : r.isCascadeBump ? ' _(cascade)_' : '';
const pkgDir = packageDirs.get(r.name);
const diffLinks = pkgDir ? buildDiffLinks(pkgDir, changesBaseUrl) : '';
lines.push(`#### \`${r.name}\` ${r.oldVersion} → **${r.newVersion}**${suffix}${diffLinks}`);
lines.push('');

if (!includeSummaries) continue;

const relevantBumpFiles = plan.bumpFiles.filter((bf) => r.bumpFiles.includes(bf.id));

if (relevantBumpFiles.length > 0) {
for (const bf of relevantBumpFiles) {
if (bf.summary) {
const bfLink = changesBaseUrl
? ` ([bump file](${changesBaseUrl}#diff-${sha256Hex(`.bumpy/${bf.id}.md`)}))`
: '';
const summaryLines = bf.summary.split('\n');
lines.push(`- ${summaryLines[0]}${bfLink}`);
for (let i = 1; i < summaryLines.length; i++) {
if (summaryLines[i]!.trim()) {
lines.push(` ${summaryLines[i]}`);
}
}
}
}
} else if (r.isDependencyBump) {
lines.push('- Updated dependencies');
} else if (r.isCascadeBump) {
lines.push('- Version bump via cascade rule');
}
} else if (r.isDependencyBump) {
lines.push('- Updated dependencies');
} else if (r.isCascadeBump) {
lines.push('- Version bump via cascade rule');

lines.push('');
}
}

if (showNoPatWarning) {
lines.push(
'> ⚠️ `BUMPY_GH_TOKEN` is not set — CI checks will not run automatically on this PR. Run `bumpy ci setup` for help.',
);
lines.push('');
}
}

if (showNoPatWarning) {
lines.push(
'> ⚠️ `BUMPY_GH_TOKEN` is not set — CI checks will not run automatically on this PR. Run `bumpy ci setup` for help.',
);
lines.push('');
}
return lines.join('\n');
};

return lines.join('\n');
const full = render(true);
if (full.length <= GH_BODY_SAFE) return full;

// Too long for GitHub — drop the inline change summaries and keep just the
// version-bump list. This is rare (dozens of packages or huge changelogs).
const compact = render(false);
if (compact.length <= GH_BODY_SAFE) return compact;

// Still too long (an enormous number of packages) — hard-truncate.
const notice = '\n\n> ⚠️ This release list was truncated because it exceeds GitHub’s size limit.';
return compact.slice(0, GH_BODY_MAX - notice.length) + notice;
}

const COMMENT_MARKER = '<!-- bumpy-release-plan -->';
Expand Down
69 changes: 69 additions & 0 deletions packages/bumpy/test/core/ci-version-pr-body.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, test, expect } from 'bun:test';
import { formatVersionPrBody } from '../../src/commands/ci.ts';
import type { ReleasePlan, PlannedRelease, BumpFile } from '../../src/types.ts';

// GitHub rejects PR bodies longer than 65536 characters. bumpy should degrade
// gracefully rather than fail the release when there are many packages and/or
// huge change summaries.
const GH_LIMIT = 65_536;

function makePlan(count: number, summary: string): ReleasePlan {
const releases: PlannedRelease[] = [];
const bumpFiles: BumpFile[] = [];
for (let i = 0; i < count; i++) {
const id = `bump-${i}`;
bumpFiles.push({ id, releases: [{ name: `@scope/pkg-${i}`, type: 'minor' }], summary });
releases.push({
name: `@scope/pkg-${i}`,
type: 'minor',
oldVersion: '1.0.0',
newVersion: '1.1.0',
bumpFiles: [id],
isDependencyBump: false,
isCascadeBump: false,
isGroupBump: false,
bumpSources: [],
});
}
return { bumpFiles, releases, warnings: [] };
}

const packageDirs = new Map<string, string>();
for (let i = 0; i < 200; i++) packageDirs.set(`@scope/pkg-${i}`, `packages/pkg-${i}`);

describe('formatVersionPrBody — within size limit', () => {
const body = formatVersionPrBody(makePlan(3, 'Add a feature'), 'Release', packageDirs, 'owner/repo', '42');

test('includes inline change summaries', () => {
expect(body).toContain('Add a feature');
expect(body).toContain('@scope/pkg-0');
expect(body.length).toBeLessThanOrEqual(GH_LIMIT);
});
});

describe('formatVersionPrBody — exceeds limit via large summaries', () => {
// 30 packages each with a multi-KB summary blows past 65536 chars.
const bigSummary = 'Detailed change notes. '.repeat(150);
const body = formatVersionPrBody(makePlan(30, bigSummary), 'Release', packageDirs, 'owner/repo', '42');

test('stays under the GitHub limit', () => {
expect(body.length).toBeLessThanOrEqual(GH_LIMIT);
});

test('drops the inline summaries but keeps the version-bump list', () => {
expect(body).not.toContain('Detailed change notes.');
expect(body).toContain('@scope/pkg-0');
expect(body).toContain('@scope/pkg-29');
expect(body).toContain('too many changes to summarize');
});
});

describe('formatVersionPrBody — exceeds limit even compact', () => {
// Thousands of packages: even the header-only list overflows.
const body = formatVersionPrBody(makePlan(3000, 'x'), 'Release', packageDirs, 'owner/repo', '42');

test('hard-truncates to under the GitHub limit', () => {
expect(body.length).toBeLessThanOrEqual(GH_LIMIT);
expect(body).toContain('truncated');
});
});