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
2 changes: 1 addition & 1 deletion packages/publish/src/stages/github-release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down
38 changes: 38 additions & 0 deletions packages/publish/test/unit/stages/github-release.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
7 changes: 5 additions & 2 deletions packages/release/src/preview/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] {
Expand All @@ -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}\`)`;
}
Expand Down
9 changes: 5 additions & 4 deletions packages/release/src/standing-pr/changelog-region.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions packages/release/test/unit/changelog-region.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
12 changes: 12 additions & 0 deletions packages/release/test/unit/preview-format.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)',
Expand Down
9 changes: 2 additions & 7 deletions packages/version/src/changelog/commitParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment thread
goosewobbler marked this conversation as resolved.
scope: scope || undefined,
issueIds: issueIds.length > 0 ? issueIds : undefined,
prNumber,
...(hasBreakingChange && { breaking: true }),
Comment thread
goosewobbler marked this conversation as resolved.
originalType: type, // Store original type for custom formatting
};
}
Expand Down
2 changes: 1 addition & 1 deletion packages/version/src/package/packageProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
4 changes: 2 additions & 2 deletions packages/version/test/unit/changelog/commitParser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
12 changes: 12 additions & 0 deletions packages/version/test/unit/package/packageProcessor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Loading