diff --git a/packages/publish/src/stages/github-release.ts b/packages/publish/src/stages/github-release.ts index afe5646a..07e5463d 100644 --- a/packages/publish/src/stages/github-release.ts +++ b/packages/publish/src/stages/github-release.ts @@ -189,7 +189,7 @@ function formatChangelogForTag(tag: string, changelogs: VersionPackageChangelog[ const lines: string[] = []; for (const entry of target.entries) { const scope = entry.scope ? `**${entry.scope}:** ` : ''; - lines.push(`- ${scope}${entry.description}`); + lines.push(`- ${entry.breaking ? '**BREAKING** ' : ''}${scope}${entry.description}`); } return lines.join('\n'); } diff --git a/packages/publish/test/unit/stages/github-release.spec.ts b/packages/publish/test/unit/stages/github-release.spec.ts index 0054be24..bf4d2d4a 100644 --- a/packages/publish/test/unit/stages/github-release.spec.ts +++ b/packages/publish/test/unit/stages/github-release.spec.ts @@ -272,6 +272,44 @@ describe('github-release stage', () => { expect(args).not.toContain('--notes'); }); + it('should mark a breaking change in a changelog body', async () => { + const { execCommand } = await import('../../../src/utils/exec.js'); + const config = getDefaultConfig(); + config.githubRelease.body = 'changelog'; + + const ctx = createContext({ + config, + input: { + dryRun: false, + updates: [], + changelogs: [ + { + packageName: 'pkg-a', + version: '1.0.0', + previousVersion: '0.9.0', + revisionRange: 'v0.9.0..HEAD', + repoUrl: null, + entries: [{ type: 'fixed', description: 'Reject the old preset', scope: 'config', breaking: true }], + }, + ], + tags: ['pkg-a@v1.0.0'], + }, + output: { + dryRun: false, + git: { committed: true, tags: ['pkg-a@v1.0.0'], pushed: true }, + npm: [], + cargo: [], + verification: [], + githubReleases: [], + }, + }); + + await runGithubReleaseStage(ctx); + + const args = vi.mocked(execCommand).mock.calls[0]?.[1] as string[]; + expect(args[args.indexOf('--notes') + 1]).toBe('- **BREAKING** **config:** Reject the old preset'); + }); + it('should match sanitized tag format (scope-pkg-vX.Y.Z) to scoped package name', async () => { const { execCommand } = await import('../../../src/utils/exec.js'); diff --git a/packages/release/src/preview/format.ts b/packages/release/src/preview/format.ts index 6f3660ab..09b5e485 100644 --- a/packages/release/src/preview/format.ts +++ b/packages/release/src/preview/format.ts @@ -557,7 +557,7 @@ function formatPackageChangelog(changelog: VersionPackageChangelog, refs: Change function formatEntryGroup( type: string, - entries: { description: string; scope?: string; issueIds?: string[]; prNumber?: string }[], + entries: { description: string; scope?: string; issueIds?: string[]; prNumber?: string; breaking?: boolean }[], refs: ChangelogRefsMode, repoUrl: string | null, ): string[] { @@ -568,7 +568,10 @@ function formatEntryGroup( // Always neutralise `@`-mentions in the description; the scope is already backticked (safe). Bare // `#N` refs carried over from the commit subject are neutralised / de-duped against the label. const appendedRefs = [...(entry.issueIds ?? []), entry.prNumber]; - let line = `- ${escapeChangelogMentions(neutralizeDescriptionRefs(entry.description, appendedRefs, refs, repoUrl))}`; + const description = escapeChangelogMentions( + neutralizeDescriptionRefs(entry.description, appendedRefs, refs, repoUrl), + ); + let line = `- ${entry.breaking ? '**BREAKING** ' : ''}${description}`; if (entry.scope) { line += ` (\`${entry.scope}\`)`; } diff --git a/packages/release/src/standing-pr/changelog-region.ts b/packages/release/src/standing-pr/changelog-region.ts index 2c3d027e..0ac06dfa 100644 --- a/packages/release/src/standing-pr/changelog-region.ts +++ b/packages/release/src/standing-pr/changelog-region.ts @@ -74,10 +74,11 @@ interface DedupedEntry { /** Identity of the underlying change: the same commit/PR yields an identical type + description + * scope (+ issue refs) across every package it touched, so this key collapses those N copies into - * one. Scope is part of the identity — two commits sharing a description but differing in scope - * (`fix(cli)` vs `fix(router)`) are distinct changes and must not merge into a mis-attributed line. */ + * one. Scope and the breaking flag are part of the identity — two commits sharing a description but + * differing in scope (`fix(cli)` vs `fix(router)`) are distinct changes and must not merge into a + * mis-attributed line. */ function dedupeKey(e: VersionChangelogEntry): string { - return JSON.stringify([e.type, e.description, e.scope ?? null, e.issueIds ?? []]); + return JSON.stringify([e.type, e.description, e.scope ?? null, e.issueIds ?? [], e.breaking === true]); } /** De-duplicate by underlying change, preserving first-seen order and collecting contributing @@ -129,7 +130,7 @@ function entryLine(d: DedupedEntry, attribution: boolean, refOpts: RefRenderOpti const refs = renderIssueRefs(entry.issueIds ?? [], refOpts.refs, refOpts.repoUrl, entry.prNumber); const issues = refs ? ` ${refs}` : ''; const attr = attribution && pkgs.size > 0 ? ` _(${[...pkgs].map(shortName).sort().join(', ')})_` : ''; - return `- ${description}${scope}${issues}${attr}`; + return `- ${entry.breaking ? '**BREAKING** ' : ''}${description}${scope}${issues}${attr}`; } /** Bare heading for the trailing demoted subsection — deliberately no count and no diff --git a/packages/release/test/unit/changelog-region.spec.ts b/packages/release/test/unit/changelog-region.spec.ts index a9c8761c..1552471a 100644 --- a/packages/release/test/unit/changelog-region.spec.ts +++ b/packages/release/test/unit/changelog-region.spec.ts @@ -85,6 +85,28 @@ describe('changelog-region', () => { expect(footer).toContain('_(a, b)_'); }); + it('should mark a breaking change', () => { + const footer = renderCombinedFooter( + output({ + changelogs: [cl('@scope/a', [{ type: 'fix', description: 'Reject the old preset', breaking: true }])], + }), + ); + expect(footer).toContain('- **BREAKING** Reject the old preset'); + }); + + it('should keep a breaking change apart from a non-breaking one with the same subject', () => { + const footer = renderCombinedFooter( + output({ + changelogs: [ + cl('@scope/a', [{ type: 'fix', description: 'Reject the old preset' }]), + cl('@scope/b', [{ type: 'fix', description: 'Reject the old preset', breaking: true }]), + ], + }), + ); + expect(footer).toContain('- **BREAKING** Reject the old preset'); + expect(footer).toContain('Show all changes (2 changes, de-duplicated)'); + }); + it('should keep same-description changes with different scopes as distinct lines', () => { const footer = renderCombinedFooter( output({ diff --git a/packages/release/test/unit/preview-format.spec.ts b/packages/release/test/unit/preview-format.spec.ts index 3a59d286..ddae26a6 100644 --- a/packages/release/test/unit/preview-format.spec.ts +++ b/packages/release/test/unit/preview-format.spec.ts @@ -215,6 +215,18 @@ describe('formatPreviewComment', () => { expect(result).toContain('- Migrate to Vitest'); }); + it('should mark a breaking change', () => { + const output = structuredClone(releaseOutput); + const entry = output.versionOutput.changelogs + .flatMap((c) => c.entries) + .find((e) => e.description === 'New dry-run flag'); + if (entry) { + entry.breaking = true; + } + + expect(formatPreviewComment(output)).toContain('- **BREAKING** New dry-run flag (`cli`)'); + }); + it('should render bare #NNN refs per the refs mode', () => { expect(formatPreviewComment(releaseOutput, { refs: 'escape' })).toContain( '- Fix prerelease sorting (`semver`) (\\#42)', diff --git a/packages/version/src/changelog/commitParser.ts b/packages/version/src/changelog/commitParser.ts index da3a3464..7f46dc5b 100644 --- a/packages/version/src/changelog/commitParser.ts +++ b/packages/version/src/changelog/commitParser.ts @@ -300,18 +300,13 @@ function parseCommitMessage(message: string): ChangelogEntry | null { const bodyIssueIds = extractIssueIds(body); const issueIds = prNumber ? [prNumber, ...bodyIssueIds] : bodyIssueIds; - // Format description, adding BREAKING prefix if needed - let description = subjectText; - if (hasBreakingChange) { - description = `**BREAKING** ${description}`; - } - return { type: changelogType, - description, + description: subjectText, scope: scope || undefined, issueIds: issueIds.length > 0 ? issueIds : undefined, prNumber, + ...(hasBreakingChange && { breaking: true }), originalType: type, // Store original type for custom formatting }; } diff --git a/packages/version/src/package/packageProcessor.ts b/packages/version/src/package/packageProcessor.ts index f327ea5d..a4347128 100644 --- a/packages/version/src/package/packageProcessor.ts +++ b/packages/version/src/package/packageProcessor.ts @@ -281,7 +281,7 @@ export class PackageProcessor { if (repoLevelEntries.length > 0) { log(`Found ${repoLevelEntries.length} repo-level commit(s) for ${name}`, 'debug'); for (const entry of repoLevelEntries) { - sharedEntriesMap.set(`${entry.type}:${entry.description}`, entry); + sharedEntriesMap.set(`${entry.type}${entry.breaking ? '!' : ''}:${entry.description}`, entry); } } diff --git a/packages/version/test/unit/changelog/commitParser.spec.ts b/packages/version/test/unit/changelog/commitParser.spec.ts index 199fc80a..f582298c 100644 --- a/packages/version/test/unit/changelog/commitParser.spec.ts +++ b/packages/version/test/unit/changelog/commitParser.spec.ts @@ -70,8 +70,8 @@ describe('Commit Parser', () => { const entries = await extractChangelogEntriesFromCommits('/test', RANGE, gitWithLog(mockGitOutput)); expect(entries).toHaveLength(2); - expect(entries[0].description).toContain('**BREAKING**'); - expect(entries[1].description).toContain('**BREAKING**'); + expect(entries[0]).toMatchObject({ description: 'breaking change', breaking: true }); + expect(entries[1]).toMatchObject({ description: 'another fix', breaking: true }); }); it('should extract issue IDs from commit messages', async () => { diff --git a/packages/version/test/unit/package/packageProcessor.spec.ts b/packages/version/test/unit/package/packageProcessor.spec.ts index 3f0421af..a005ad51 100644 --- a/packages/version/test/unit/package/packageProcessor.spec.ts +++ b/packages/version/test/unit/package/packageProcessor.spec.ts @@ -728,6 +728,18 @@ describe('Package Processor', () => { } }); + it('should keep a breaking repo-level entry apart from a non-breaking one with the same subject', async () => { + const plain = { type: 'fixed', description: 'Reject the old preset' }; + const breaking = { ...plain, breaking: true }; + vi.spyOn(commitParser, 'extractChangelogEntriesFromCommits').mockImplementation(() => []); + vi.spyOn(commitParser, 'extractRepoLevelChangelogEntries').mockResolvedValue([plain, breaking]); + + const processor = new PackageProcessor({ ...defaultOptions }); + await processor.processPackages(mockPackages); + + expect(jsonOutput.setSharedEntries).toHaveBeenCalledWith([plain, breaking]); + }); + it('should track tags via JSON output without creating git tags', async () => { const processor = new PackageProcessor(defaultOptions);