diff --git a/.bumpy/version-pr-body-limit.md b/.bumpy/version-pr-body-limit.md new file mode 100644 index 0000000..ff41945 --- /dev/null +++ b/.bumpy/version-pr-body-limit.md @@ -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. diff --git a/packages/bumpy/src/commands/ci.ts b/packages/bumpy/src/commands/ci.ts index 9d5ce12..68530a5 100644 --- a/packages/bumpy/src/commands/ci.ts +++ b/packages/bumpy/src/commands/ci.ts @@ -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, @@ -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 = { 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 = ''; diff --git a/packages/bumpy/test/core/ci-version-pr-body.test.ts b/packages/bumpy/test/core/ci-version-pr-body.test.ts new file mode 100644 index 0000000..68e2174 --- /dev/null +++ b/packages/bumpy/test/core/ci-version-pr-body.test.ts @@ -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(); +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'); + }); +});