From abf90e0cf3e95a3c2d98c613947852b1272e72bd Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Thu, 27 Aug 2026 22:46:34 +0200 Subject: [PATCH 01/10] Add script to auto-resolve CHANGELOG.md merge conflicts Every consumer-facing change requires an Unreleased changelog entry, so packages/*/CHANGELOG.md conflicts constantly when multiple PRs land around the same time, even though there's usually no real disagreement about content. This adds `yarn changelog:merge` to automatically resolve those conflicts by taking the union of entries added on each side, using @metamask/auto-changelog's parsing/stringification so category ordering and formatting stay correct. Files it can't confidently merge are left with their conflict markers intact for manual resolution. --- package.json | 2 + scripts/lib/changelog-conflicts.test.ts | 552 ++++++++++++++++++++++ scripts/lib/changelog-conflicts.ts | 352 ++++++++++++++ scripts/merge-changelog-conflicts.test.ts | 71 +++ scripts/merge-changelog-conflicts.ts | 45 ++ yarn.lock | 24 + 6 files changed, 1046 insertions(+) create mode 100644 scripts/lib/changelog-conflicts.test.ts create mode 100644 scripts/lib/changelog-conflicts.ts create mode 100644 scripts/merge-changelog-conflicts.test.ts create mode 100644 scripts/merge-changelog-conflicts.ts diff --git a/package.json b/package.json index 7fd1a41abea..f3b06eb100e 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "build:docs": "yarn workspaces foreach --all --no-private --parallel --interlaced --verbose run build:docs", "build:only-clean": "rimraf -g 'packages/*/dist'", "build:types": "tsc --build tsconfig.build.json --verbose", + "changelog:merge": "tsx scripts/merge-changelog-conflicts.ts", "changelog:update": "yarn workspaces foreach --all --no-private --parallel --interlaced --verbose run changelog:update", "changelog:validate": "yarn workspaces foreach --all --no-private --parallel --interlaced --verbose run changelog:validate", "codeowners:check": "tsx scripts/manage-codeowners.ts check", @@ -63,6 +64,7 @@ "@actions/github": "^9.1.1", "@lavamoat/allow-scripts": "^3.0.4", "@lavamoat/preinstall-always-fail": "^2.1.0", + "@metamask/auto-changelog": "^6.2.1", "@metamask/create-release-branch": "^4.2.2", "@metamask/eslint-config": "^15.0.0", "@metamask/eslint-config-jest": "^15.0.0", diff --git a/scripts/lib/changelog-conflicts.test.ts b/scripts/lib/changelog-conflicts.test.ts new file mode 100644 index 00000000000..57100aa4cbf --- /dev/null +++ b/scripts/lib/changelog-conflicts.test.ts @@ -0,0 +1,552 @@ +import execa from 'execa'; +import { promises as fs } from 'fs'; + +import { + findConflictedChangelogFiles, + mergeChangelogs, + readGitBlob, + resolveChangelogConflicts, + resolvePackageMetadata, +} from './changelog-conflicts.js'; + +jest.mock('execa'); + +const REPO_URL = 'https://github.com/MetaMask/core'; +const TAG_PREFIX = '@metamask/example@'; + +/** + * Build minimal changelog content with the given `## [Unreleased]` body. + * + * @param unreleasedBody - The Markdown to place under `## [Unreleased]`. + * @returns The changelog content. + */ +function buildChangelog(unreleasedBody: string): string { + return `# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +${unreleasedBody} + +## [1.0.0] + +### Added + +- Initial release ([#1](${REPO_URL}/pull/1)) + +[Unreleased]: ${REPO_URL}/compare/${TAG_PREFIX}1.0.0...HEAD +[1.0.0]: ${REPO_URL}/releases/tag/${TAG_PREFIX}1.0.0 +`; +} + +describe('changelog-conflicts', () => { + describe('mergeChangelogs', () => { + it('takes the union of distinct entries added on each side, preserving order', async () => { + const oursContent = buildChangelog( + `### Added + +- Added ours entry ([#10](${REPO_URL}/pull/10))`, + ); + const theirsContent = buildChangelog( + `### Added + +- Added theirs entry ([#11](${REPO_URL}/pull/11))`, + ); + + const { content, mergedEntryCount } = await mergeChangelogs({ + oursContent, + theirsContent, + repoUrl: REPO_URL, + tagPrefix: TAG_PREFIX, + }); + + expect(mergedEntryCount).toBe(1); + const addedIndex = content.indexOf('### Added'); + const theirsIndex = content.indexOf('Added theirs entry'); + const oursIndex = content.indexOf('Added ours entry'); + expect(addedIndex).toBeGreaterThan(-1); + expect(theirsIndex).toBeGreaterThan(addedIndex); + expect(oursIndex).toBeGreaterThan(theirsIndex); + }); + + it('does not duplicate an entry that both sides added (identified by PR number)', async () => { + const sharedEntry = `- Added shared entry ([#20](${REPO_URL}/pull/20))`; + const oursContent = buildChangelog(`### Added + +${sharedEntry}`); + const theirsContent = buildChangelog(`### Added + +${sharedEntry}`); + + const { content, mergedEntryCount } = await mergeChangelogs({ + oursContent, + theirsContent, + repoUrl: REPO_URL, + tagPrefix: TAG_PREFIX, + }); + + expect(mergedEntryCount).toBe(0); + expect(content.match(/Added shared entry/gu)).toHaveLength(1); + }); + + it('does not duplicate an entry with no PR number that both sides added (identified by description)', async () => { + const sharedEntry = '- Added shared entry with no PR number'; + const oursContent = buildChangelog(`### Added + +${sharedEntry}`); + const theirsContent = buildChangelog(`### Added + +${sharedEntry}`); + + const { content, mergedEntryCount } = await mergeChangelogs({ + oursContent, + theirsContent, + repoUrl: REPO_URL, + tagPrefix: TAG_PREFIX, + }); + + expect(mergedEntryCount).toBe(0); + expect( + content.match(/Added shared entry with no PR number/gu), + ).toHaveLength(1); + }); + + it('inserts a new breaking entry below existing breaking entries, above non-breaking ones', async () => { + const oursContent = buildChangelog( + `### Changed + +- **BREAKING:** Ours breaking entry ([#30](${REPO_URL}/pull/30))`, + ); + const theirsContent = buildChangelog( + `### Changed + +- **BREAKING:** Theirs existing breaking entry ([#31](${REPO_URL}/pull/31)) +- Theirs existing non-breaking entry ([#32](${REPO_URL}/pull/32))`, + ); + + const { content, mergedEntryCount } = await mergeChangelogs({ + oursContent, + theirsContent, + repoUrl: REPO_URL, + tagPrefix: TAG_PREFIX, + }); + + expect(mergedEntryCount).toBe(1); + const existingBreakingIndex = content.indexOf( + 'Theirs existing breaking entry', + ); + const newBreakingIndex = content.indexOf('Ours breaking entry'); + const nonBreakingIndex = content.indexOf( + 'Theirs existing non-breaking entry', + ); + expect(existingBreakingIndex).toBeLessThan(newBreakingIndex); + expect(newBreakingIndex).toBeLessThan(nonBreakingIndex); + }); + + it('merges in a category that only exists on one side', async () => { + const oursContent = buildChangelog( + `### Fixed + +- Fixed ours entry ([#40](${REPO_URL}/pull/40))`, + ); + const theirsContent = buildChangelog( + `### Added + +- Added theirs entry ([#41](${REPO_URL}/pull/41))`, + ); + + const { content, mergedEntryCount } = await mergeChangelogs({ + oursContent, + theirsContent, + repoUrl: REPO_URL, + tagPrefix: TAG_PREFIX, + }); + + expect(mergedEntryCount).toBe(1); + expect(content).toContain('### Fixed'); + expect(content).toContain('Fixed ours entry'); + expect(content).toContain('### Added'); + expect(content).toContain('Added theirs entry'); + }); + + it('merges in a release version that only exists on one side', async () => { + const oursContent = `# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [2.0.0] + +### Added + +- Added in 2.0.0 ([#50](${REPO_URL}/pull/50)) + +## [1.0.0] + +### Added + +- Initial release ([#1](${REPO_URL}/pull/1)) + +[Unreleased]: ${REPO_URL}/compare/${TAG_PREFIX}2.0.0...HEAD +[2.0.0]: ${REPO_URL}/compare/${TAG_PREFIX}1.0.0...${TAG_PREFIX}2.0.0 +[1.0.0]: ${REPO_URL}/releases/tag/${TAG_PREFIX}1.0.0 +`; + const theirsContent = buildChangelog(''); + + const { content, mergedEntryCount } = await mergeChangelogs({ + oursContent, + theirsContent, + repoUrl: REPO_URL, + tagPrefix: TAG_PREFIX, + }); + + expect(mergedEntryCount).toBe(1); + expect(content).toContain('## [2.0.0]'); + expect(content).toContain('Added in 2.0.0'); + }); + + it('appends a new release version that is older than every existing release', async () => { + const oursContent = `# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [2.0.0] + +### Added + +- Added in 2.0.0 ([#50](${REPO_URL}/pull/50)) + +## [0.5.0] + +### Added + +- Added in 0.5.0 ([#51](${REPO_URL}/pull/51)) + +[Unreleased]: ${REPO_URL}/compare/${TAG_PREFIX}2.0.0...HEAD +[2.0.0]: ${REPO_URL}/compare/${TAG_PREFIX}0.5.0...${TAG_PREFIX}2.0.0 +[0.5.0]: ${REPO_URL}/releases/tag/${TAG_PREFIX}0.5.0 +`; + const theirsContent = `# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [2.0.0] + +### Added + +- Added in 2.0.0 ([#50](${REPO_URL}/pull/50)) + +[Unreleased]: ${REPO_URL}/compare/${TAG_PREFIX}2.0.0...HEAD +[2.0.0]: ${REPO_URL}/releases/tag/${TAG_PREFIX}2.0.0 +`; + + const { content, mergedEntryCount } = await mergeChangelogs({ + oursContent, + theirsContent, + repoUrl: REPO_URL, + tagPrefix: TAG_PREFIX, + }); + + expect(mergedEntryCount).toBe(1); + const version2Index = content.indexOf('## [2.0.0]'); + const version05Index = content.indexOf('## [0.5.0]'); + expect(version2Index).toBeLessThan(version05Index); + }); + + it('inserts a new release version into its correct descending-SemVer position, not just at the start or end', async () => { + const oursContent = `# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [3.0.0] + +### Added + +- Added in 3.0.0 ([#60](${REPO_URL}/pull/60)) + +## [2.0.0] + +### Added + +- Added in 2.0.0 ([#61](${REPO_URL}/pull/61)) + +## [1.0.0] + +### Added + +- Initial release ([#1](${REPO_URL}/pull/1)) + +[Unreleased]: ${REPO_URL}/compare/${TAG_PREFIX}3.0.0...HEAD +[3.0.0]: ${REPO_URL}/compare/${TAG_PREFIX}2.0.0...${TAG_PREFIX}3.0.0 +[2.0.0]: ${REPO_URL}/compare/${TAG_PREFIX}1.0.0...${TAG_PREFIX}2.0.0 +[1.0.0]: ${REPO_URL}/releases/tag/${TAG_PREFIX}1.0.0 +`; + const theirsContent = `# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [3.0.0] + +### Added + +- Added in 3.0.0 ([#60](${REPO_URL}/pull/60)) + +## [1.0.0] + +### Added + +- Initial release ([#1](${REPO_URL}/pull/1)) + +[Unreleased]: ${REPO_URL}/compare/${TAG_PREFIX}3.0.0...HEAD +[3.0.0]: ${REPO_URL}/compare/${TAG_PREFIX}1.0.0...${TAG_PREFIX}3.0.0 +[1.0.0]: ${REPO_URL}/releases/tag/${TAG_PREFIX}1.0.0 +`; + + const { content, mergedEntryCount } = await mergeChangelogs({ + oursContent, + theirsContent, + repoUrl: REPO_URL, + tagPrefix: TAG_PREFIX, + }); + + expect(mergedEntryCount).toBe(1); + const version3Index = content.indexOf('## [3.0.0]'); + const version2Index = content.indexOf('## [2.0.0]'); + const version1Index = content.indexOf('## [1.0.0]'); + expect(version3Index).toBeLessThan(version2Index); + expect(version2Index).toBeLessThan(version1Index); + expect(content).toContain( + `[3.0.0]: ${REPO_URL}/compare/${TAG_PREFIX}2.0.0...${TAG_PREFIX}3.0.0`, + ); + }); + }); + + describe('findConflictedChangelogFiles', () => { + it('filters unmerged paths down to package changelogs', async () => { + (execa as unknown as jest.Mock).mockResolvedValue({ + stdout: [ + 'packages/foo/CHANGELOG.md', + 'packages/foo/package.json', + 'yarn.lock', + ].join('\n'), + }); + + const result = await findConflictedChangelogFiles(); + + expect(result).toStrictEqual(['packages/foo/CHANGELOG.md']); + expect(execa).toHaveBeenCalledWith( + 'git', + ['diff', '--name-only', '--diff-filter=U'], + expect.objectContaining({ encoding: 'utf8' }), + ); + }); + + it('returns an empty array when there are no unmerged paths', async () => { + (execa as unknown as jest.Mock).mockResolvedValue({ stdout: '' }); + + expect(await findConflictedChangelogFiles()).toStrictEqual([]); + }); + }); + + describe('readGitBlob', () => { + it('reads a file at the given ref', async () => { + (execa as unknown as jest.Mock).mockResolvedValue({ stdout: 'content' }); + + const result = await readGitBlob(':2', 'packages/foo/CHANGELOG.md'); + + expect(result).toBe('content'); + expect(execa).toHaveBeenCalledWith( + 'git', + ['show', ':2:packages/foo/CHANGELOG.md'], + expect.objectContaining({ encoding: 'utf8' }), + ); + }); + }); + + describe('resolvePackageMetadata', () => { + it('resolves the package name, repo URL, and tag prefix from the working tree', async () => { + jest.spyOn(fs, 'readFile').mockResolvedValue( + JSON.stringify({ + name: '@metamask/example', + repository: { + type: 'git', + url: 'https://github.com/MetaMask/core.git', + }, + }), + ); + + const result = await resolvePackageMetadata( + 'packages/example/CHANGELOG.md', + ); + + expect(result).toStrictEqual({ + name: '@metamask/example', + repoUrl: 'https://github.com/MetaMask/core', + tagPrefix: '@metamask/example@', + }); + expect(fs.readFile).toHaveBeenCalledWith( + expect.stringContaining('packages/example/package.json'), + 'utf8', + ); + expect(execa).not.toHaveBeenCalled(); + }); + + it('falls back to the "ours" conflict stage if package.json is not parseable in the working tree', async () => { + jest.spyOn(fs, 'readFile').mockResolvedValue('<<<<<<< HEAD\nconflict'); + (execa as unknown as jest.Mock).mockResolvedValue({ + stdout: JSON.stringify({ + name: '@metamask/example', + repository: { url: 'https://github.com/MetaMask/core' }, + }), + }); + + const result = await resolvePackageMetadata( + 'packages/example/CHANGELOG.md', + ); + + expect(result).toStrictEqual({ + name: '@metamask/example', + repoUrl: 'https://github.com/MetaMask/core', + tagPrefix: '@metamask/example@', + }); + expect(execa).toHaveBeenCalledWith( + 'git', + ['show', ':2:packages/example/package.json'], + expect.objectContaining({ encoding: 'utf8' }), + ); + }); + + it('throws if the package name or repository URL is missing', async () => { + jest + .spyOn(fs, 'readFile') + .mockResolvedValue(JSON.stringify({ name: '@metamask/example' })); + + await expect( + resolvePackageMetadata('packages/example/CHANGELOG.md'), + ).rejects.toThrow( + "Could not resolve package name/repository for 'packages/example/CHANGELOG.md'.", + ); + }); + }); + + describe('resolveChangelogConflicts', () => { + it('resolves each conflicted file and stages it with git add', async () => { + const changelogPath = 'packages/example/CHANGELOG.md'; + const oursContent = buildChangelog( + `### Added + +- Added ours entry ([#10](${REPO_URL}/pull/10))`, + ); + const theirsContent = buildChangelog( + `### Added + +- Added theirs entry ([#11](${REPO_URL}/pull/11))`, + ); + + (execa as unknown as jest.Mock).mockImplementation( + async (command: string, args: string[]) => { + if (args[0] === 'diff') { + return { stdout: changelogPath }; + } + if (args[0] === 'show' && args[1] === `:2:${changelogPath}`) { + return { stdout: oursContent }; + } + if (args[0] === 'show' && args[1] === `:3:${changelogPath}`) { + return { stdout: theirsContent }; + } + if (args[0] === 'add') { + return { stdout: '' }; + } + throw new Error(`Unexpected execa call: ${command} ${args.join(' ')}`); + }, + ); + + jest.spyOn(fs, 'readFile').mockResolvedValue( + JSON.stringify({ + name: '@metamask/example', + repository: { type: 'git', url: `${REPO_URL}.git` }, + }), + ); + jest.spyOn(fs, 'writeFile').mockResolvedValue(); + + const result = await resolveChangelogConflicts(); + + expect(result.skipped).toStrictEqual([]); + expect(result.resolved).toStrictEqual([ + { path: changelogPath, mergedEntryCount: 1 }, + ]); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining(changelogPath), + expect.stringContaining('Added theirs entry'), + 'utf8', + ); + expect(execa).toHaveBeenCalledWith( + 'git', + ['add', changelogPath], + expect.objectContaining({ cwd: expect.any(String) }), + ); + }); + + it('skips a file that cannot be parsed and leaves it unresolved', async () => { + const changelogPath = 'packages/example/CHANGELOG.md'; + + (execa as unknown as jest.Mock).mockImplementation( + async (command: string, args: string[]) => { + if (args[0] === 'diff') { + return { stdout: changelogPath }; + } + if (args[0] === 'show' && args[1] === `:2:${changelogPath}`) { + return { stdout: 'this is not a valid changelog' }; + } + if (args[0] === 'show' && args[1] === `:3:${changelogPath}`) { + return { stdout: buildChangelog('') }; + } + throw new Error(`Unexpected execa call: ${command} ${args.join(' ')}`); + }, + ); + + jest.spyOn(fs, 'readFile').mockResolvedValue( + JSON.stringify({ + name: '@metamask/example', + repository: { type: 'git', url: `${REPO_URL}.git` }, + }), + ); + const writeFileSpy = jest.spyOn(fs, 'writeFile').mockResolvedValue(); + + const result = await resolveChangelogConflicts(); + + expect(result.resolved).toStrictEqual([]); + expect(result.skipped).toHaveLength(1); + expect(result.skipped[0]?.path).toBe(changelogPath); + expect(writeFileSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/scripts/lib/changelog-conflicts.ts b/scripts/lib/changelog-conflicts.ts new file mode 100644 index 00000000000..7822f823350 --- /dev/null +++ b/scripts/lib/changelog-conflicts.ts @@ -0,0 +1,352 @@ +import type { ReleaseChanges } from '@metamask/auto-changelog'; +import { oxfmt, parseChangelog } from '@metamask/auto-changelog'; +import execa from 'execa'; +import { promises as fs } from 'fs'; +import path from 'path'; +import { gt } from 'semver'; + +export const ROOT_WORKSPACE = path.resolve(__dirname, '../..'); + +const CHANGELOG_PATH_PATTERN = /^packages\/[^/]+\/CHANGELOG\.md$/u; + +/** + * A regular expression for breaking entries. Assumes the entry is in the + * "**BREAKING**:" or "**BREAKING:**" format. + */ +const BREAKING_CHANGE_PATTERN = /^\*\*BREAKING:?\*\*/u; + +type Category = keyof ReleaseChanges; +type Change = NonNullable[number]; + +export type PackageMetadata = { + name: string; + repoUrl: string; + tagPrefix: string; +}; + +export type ConflictResolution = { + path: string; + mergedEntryCount: number; +}; + +export type ConflictSkip = { + path: string; + reason: string; +}; + +export type ConflictResolutionResult = { + resolved: ConflictResolution[]; + skipped: ConflictSkip[]; +}; + +/** + * Find `packages/*\/CHANGELOG.md` files that currently have an unresolved + * Git merge conflict. + * + * @returns The repo-relative paths of the conflicted changelog files. + */ +export async function findConflictedChangelogFiles(): Promise { + const { stdout } = await execa( + 'git', + ['diff', '--name-only', '--diff-filter=U'], + { cwd: ROOT_WORKSPACE, encoding: 'utf8' }, + ); + + return stdout + .trim() + .split('\n') + .filter((filePath) => CHANGELOG_PATH_PATTERN.test(filePath)); +} + +/** + * Read a file as it exists at a given Git ref, e.g. a conflict stage such as + * `:2` (ours) or `:3` (theirs). + * + * @param ref - The Git ref to read the file from. + * @param filePath - The repo-relative path of the file. + * @returns The contents of the file at that ref. + */ +export async function readGitBlob( + ref: string, + filePath: string, +): Promise { + const { stdout } = await execa('git', ['show', `${ref}:${filePath}`], { + cwd: ROOT_WORKSPACE, + encoding: 'utf8', + }); + + return stdout; +} + +/** + * Resolve the package name and repository URL for the package that owns the + * given changelog file. `package.json` is read from the working tree, since + * it's normally not part of the conflict; if it can't be parsed there (e.g. + * it's also mid-merge and contains conflict markers), it's re-read from the + * "ours" conflict stage instead. + * + * @param changelogPath - The repo-relative path of the changelog file. + * @returns The package's name, repository URL, and changelog tag prefix. + */ +export async function resolvePackageMetadata( + changelogPath: string, +): Promise { + const packageJsonPath = path.posix.join( + path.posix.dirname(changelogPath), + 'package.json', + ); + + let packageJson; + try { + const content = await fs.readFile( + path.join(ROOT_WORKSPACE, packageJsonPath), + 'utf8', + ); + packageJson = JSON.parse(content); + } catch { + const content = await readGitBlob(':2', packageJsonPath); + packageJson = JSON.parse(content); + } + + const repositoryUrl = packageJson.repository?.url; + if (!packageJson.name || !repositoryUrl) { + throw new Error( + `Could not resolve package name/repository for '${changelogPath}'.`, + ); + } + + return { + name: packageJson.name, + repoUrl: repositoryUrl.replace(/\.git$/u, ''), + tagPrefix: `${packageJson.name}@`, + }; +} + +/** + * Determine whether a change entry is a breaking change, per the + * `**BREAKING:**` description prefix convention. + * + * @param change - The change entry. + * @returns Whether the change is a breaking change. + */ +function isBreakingChange(change: Change): boolean { + return BREAKING_CHANGE_PATTERN.test(change.description.trim()); +} + +/** + * Build a key that identifies "the same change" across both conflict sides, + * preferring the PR numbers (since wording may drift slightly between + * sides) and falling back to the description. + * + * @param change - The change entry. + * @returns The dedup key for the change. + */ +function getChangeKey(change: Change): string { + if (change.prNumbers.length > 0) { + return `pr:${[...change.prNumbers].sort().join(',')}`; + } + + return `desc:${change.description.trim()}`; +} + +/** + * Merge new entries from `incoming` into `base`, mutating `base` in place. + * Breaking changes are inserted below any existing leading breaking changes; + * other changes are appended to the end. Relative order within `incoming` is + * preserved. + * + * @param base - The category's changes to merge into. + * @param incoming - The category's changes to merge from. + * @returns The number of new entries added to `base`. + */ +function mergeCategoryEntries(base: Change[], incoming: Change[]): number { + const existingKeys = new Set(base.map(getChangeKey)); + let addedCount = 0; + + for (const change of incoming) { + const key = getChangeKey(change); + if (existingKeys.has(key)) { + continue; + } + existingKeys.add(key); + addedCount += 1; + + if (isBreakingChange(change)) { + let insertIndex = 0; + while (insertIndex < base.length && isBreakingChange(base[insertIndex])) { + insertIndex += 1; + } + base.splice(insertIndex, 0, change); + } else { + base.push(change); + } + } + + return addedCount; +} + +/** + * Merge new entries from `incoming` into `base` across every category + * present on either side, mutating `base` in place. + * + * @param base - The release's changes (by category) to merge into. + * @param incoming - The release's changes (by category) to merge from. + * @returns The number of new entries added to `base`. + */ +function mergeReleaseChanges( + base: ReleaseChanges, + incoming: ReleaseChanges, +): number { + let addedCount = 0; + const categories = new Set([ + ...Object.keys(base), + ...Object.keys(incoming), + ]) as Set; + + for (const category of categories) { + const incomingEntries = incoming[category] ?? []; + if (incomingEntries.length === 0) { + continue; + } + + base[category] ??= []; + + addedCount += mergeCategoryEntries( + base[category] as Change[], + incomingEntries, + ); + } + + return addedCount; +} + +/** + * Merge two conflicting versions of a changelog by taking the union of their + * entries: every entry unique to either side is kept, deduplicated by PR + * number (falling back to description), with new `**BREAKING:**` entries + * placed below existing breaking entries and other new entries appended. + * + * @param options - Options. + * @param options.oursContent - The changelog content on the "ours" conflict + * side. + * @param options.theirsContent - The changelog content on the "theirs" + * conflict side. + * @param options.repoUrl - The GitHub repository URL for the package. + * @param options.tagPrefix - The changelog tag prefix for the package. + * @returns The merged, re-serialized changelog content and the number of new + * entries that were merged in. + */ +export async function mergeChangelogs({ + oursContent, + theirsContent, + repoUrl, + tagPrefix, +}: { + oursContent: string; + theirsContent: string; + repoUrl: string; + tagPrefix: string; +}): Promise<{ content: string; mergedEntryCount: number }> { + const ours = parseChangelog({ + changelogContent: oursContent, + repoUrl, + tagPrefix, + shouldExtractPrLinks: true, + }); + const theirs = parseChangelog({ + changelogContent: theirsContent, + repoUrl, + tagPrefix, + formatter: oxfmt, + shouldExtractPrLinks: true, + }); + + let mergedEntryCount = mergeReleaseChanges( + theirs.getUnreleasedChanges(), + ours.getUnreleasedChanges(), + ); + + const theirsVersions = new Set( + theirs.getReleases().map(({ version }) => version), + ); + + for (const oursRelease of ours.getReleases()) { + if (!theirsVersions.has(oursRelease.version)) { + // `addRelease` can only add to the very start or end of the release + // list, so insert at the start and then reposition it into its + // correct descending-SemVer slot among the existing releases. + theirs.addRelease(oursRelease); + const releases = theirs.getReleases(); + const [inserted] = releases.splice(0, 1); + let sortedIndex = releases.findIndex(({ version }) => + gt(inserted.version, version), + ); + if (sortedIndex === -1) { + sortedIndex = releases.length; + } + releases.splice(sortedIndex, 0, inserted); + } + + const theirsReleaseChanges = theirs.getReleaseChanges(oursRelease.version); + const oursReleaseChanges = ours.getReleaseChanges(oursRelease.version); + mergedEntryCount += mergeReleaseChanges( + theirsReleaseChanges, + oursReleaseChanges ?? {}, + ); + } + + return { + content: await theirs.toString(), + mergedEntryCount, + }; +} + +/** + * Find every conflicted `packages/*\/CHANGELOG.md` file, resolve as many as + * possible via {@link mergeChangelogs}, write the merged result back to the + * working tree, and stage it with `git add`. Files that can't be + * automatically merged (e.g. a structurally invalid side) are left with + * their conflict markers intact. + * + * @returns The set of files that were resolved and the set that were + * skipped, along with the reason for each skip. + */ +export async function resolveChangelogConflicts(): Promise { + const paths = await findConflictedChangelogFiles(); + const resolved: ConflictResolution[] = []; + const skipped: ConflictSkip[] = []; + + for (const changelogPath of paths) { + try { + const [oursContent, theirsContent, { repoUrl, tagPrefix }] = + await Promise.all([ + readGitBlob(':2', changelogPath), + readGitBlob(':3', changelogPath), + resolvePackageMetadata(changelogPath), + ]); + + const { content, mergedEntryCount } = await mergeChangelogs({ + oursContent, + theirsContent, + repoUrl, + tagPrefix, + }); + + await fs.writeFile( + path.join(ROOT_WORKSPACE, changelogPath), + content, + 'utf8', + ); + await execa('git', ['add', changelogPath], { cwd: ROOT_WORKSPACE }); + + resolved.push({ path: changelogPath, mergedEntryCount }); + } catch (error) { + skipped.push({ + path: changelogPath, + reason: error instanceof Error ? error.message : String(error), + }); + } + } + + return { resolved, skipped }; +} diff --git a/scripts/merge-changelog-conflicts.test.ts b/scripts/merge-changelog-conflicts.test.ts new file mode 100644 index 00000000000..fea45971a63 --- /dev/null +++ b/scripts/merge-changelog-conflicts.test.ts @@ -0,0 +1,71 @@ +import * as changelogConflicts from './lib/changelog-conflicts.js'; +import { main } from './merge-changelog-conflicts.js'; + +jest.mock('./lib/changelog-conflicts'); + +describe('merge-changelog-conflicts', () => { + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(); + jest.spyOn(console, 'warn').mockImplementation(); + jest.spyOn(console, 'error').mockImplementation(); + // The module under test invokes `main()` once as a side effect of being + // imported (using the auto-mocked, undefined-returning + // `resolveChangelogConflicts`), which leaves a stale exit code. + process.exitCode = 0; + }); + + afterEach(() => { + process.exitCode = 0; + }); + + it('logs a message and exits cleanly when there are no conflicted files', async () => { + jest + .spyOn(changelogConflicts, 'resolveChangelogConflicts') + .mockResolvedValue({ resolved: [], skipped: [] }); + + await main(); + + expect(console.log).toHaveBeenCalledWith( + 'No conflicted CHANGELOG.md files found.', + ); + expect(process.exitCode).toBe(0); + }); + + it('logs each resolved file and exits cleanly', async () => { + jest.spyOn(changelogConflicts, 'resolveChangelogConflicts').mockResolvedValue({ + resolved: [ + { path: 'packages/example/CHANGELOG.md', mergedEntryCount: 2 }, + ], + skipped: [], + }); + + await main(); + + expect(console.log).toHaveBeenCalledWith( + 'Resolved packages/example/CHANGELOG.md (merged 2 new entries).', + ); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('yarn changelog:validate'), + ); + expect(process.exitCode).toBe(0); + }); + + it('warns about skipped files and exits with a non-zero code', async () => { + jest.spyOn(changelogConflicts, 'resolveChangelogConflicts').mockResolvedValue({ + resolved: [], + skipped: [ + { + path: 'packages/example/CHANGELOG.md', + reason: 'Malformed release header', + }, + ], + }); + + await main(); + + expect(console.warn).toHaveBeenCalledWith( + 'Could not automatically resolve packages/example/CHANGELOG.md: Malformed release header', + ); + expect(process.exitCode).toBe(1); + }); +}); diff --git a/scripts/merge-changelog-conflicts.ts b/scripts/merge-changelog-conflicts.ts new file mode 100644 index 00000000000..4b091239250 --- /dev/null +++ b/scripts/merge-changelog-conflicts.ts @@ -0,0 +1,45 @@ +import { resolveChangelogConflicts } from './lib/changelog-conflicts.js'; + +// Run the script immediately. +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); + +/** + * The entrypoint to this script. + * + * Automatically resolves Git merge conflicts in `packages/*\/CHANGELOG.md` + * files by taking the union of entries added on each side of the conflict. + * Files that can't be automatically merged are left with their conflict + * markers intact, and cause the script to exit with a non-zero code. + * + * Usage: `tsx scripts/merge-changelog-conflicts.ts` + */ +export async function main(): Promise { + const { resolved, skipped } = await resolveChangelogConflicts(); + + if (resolved.length === 0 && skipped.length === 0) { + console.log('No conflicted CHANGELOG.md files found.'); + return; + } + + for (const { path, mergedEntryCount } of resolved) { + const entryWord = mergedEntryCount === 1 ? 'entry' : 'entries'; + console.log(`Resolved ${path} (merged ${mergedEntryCount} new ${entryWord}).`); + } + + for (const { path, reason } of skipped) { + console.warn(`Could not automatically resolve ${path}: ${reason}`); + } + + if (resolved.length > 0) { + console.log( + '\nRun `yarn changelog:validate` to confirm the merged changelogs are still valid.', + ); + } + + if (skipped.length > 0) { + process.exitCode = 1; + } +} diff --git a/yarn.lock b/yarn.lock index ce9edb7e018..40a84032489 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6186,6 +6186,29 @@ __metadata: languageName: node linkType: hard +"@metamask/auto-changelog@npm:^6.2.1": + version: 6.2.1 + resolution: "@metamask/auto-changelog@npm:6.2.1" + dependencies: + "@octokit/rest": "npm:^20.0.0" + diff: "npm:^5.0.0" + execa: "npm:^5.1.1" + semver: "npm:^7.3.5" + yargs: "npm:^17.0.1" + peerDependencies: + oxfmt: ^0.45.0 + prettier: ">=3.0.0" + peerDependenciesMeta: + oxfmt: + optional: true + prettier: + optional: true + bin: + auto-changelog: dist/cli.mjs + checksum: 10/88f5bc63ef5003b3e4f88ebf018229ce5e701283cadee1dcbd37f869541a1102ba6a8a96fd54993863e3578c8b79f0c6e0242e4afd08c58d5db529bc75dbaf2c + languageName: node + linkType: hard + "@metamask/base-controller@npm:^9.0.1, @metamask/base-controller@npm:^9.1.0, @metamask/base-controller@workspace:packages/base-controller": version: 0.0.0-use.local resolution: "@metamask/base-controller@workspace:packages/base-controller" @@ -6677,6 +6700,7 @@ __metadata: "@actions/github": "npm:^9.1.1" "@lavamoat/allow-scripts": "npm:^3.0.4" "@lavamoat/preinstall-always-fail": "npm:^2.1.0" + "@metamask/auto-changelog": "npm:^6.2.1" "@metamask/create-release-branch": "npm:^4.2.2" "@metamask/eslint-config": "npm:^15.0.0" "@metamask/eslint-config-jest": "npm:^15.0.0" From 3895ac537a4cf1d551ca56a457a7828ec2fc57d2 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 28 Aug 2026 10:15:13 +0200 Subject: [PATCH 02/10] Simplify changelog conflict merge helpers Extract package.json reading into its own function instead of mutating a local variable, derive the merged entry count from array length deltas instead of a manual counter, replace insertion-index loops with findIndex, narrow exported types to only what's used outside the module, and use @metamask/utils#getErrorMessage instead of a hand-rolled instanceof check. --- scripts/lib/changelog-conflicts.test.ts | 10 ++- scripts/lib/changelog-conflicts.ts | 87 +++++++++++++++---------- scripts/merge-changelog-conflicts.ts | 5 +- 3 files changed, 63 insertions(+), 39 deletions(-) diff --git a/scripts/lib/changelog-conflicts.test.ts b/scripts/lib/changelog-conflicts.test.ts index 57100aa4cbf..c53214e72e3 100644 --- a/scripts/lib/changelog-conflicts.test.ts +++ b/scripts/lib/changelog-conflicts.test.ts @@ -452,7 +452,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 await expect( resolvePackageMetadata('packages/example/CHANGELOG.md'), ).rejects.toThrow( - "Could not resolve package name/repository for 'packages/example/CHANGELOG.md'.", + 'Could not resolve package name or repository for "packages/example/CHANGELOG.md".', ); }); }); @@ -485,7 +485,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 if (args[0] === 'add') { return { stdout: '' }; } - throw new Error(`Unexpected execa call: ${command} ${args.join(' ')}`); + throw new Error( + `Unexpected execa call: ${command} ${args.join(' ')}`, + ); }, ); @@ -529,7 +531,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 if (args[0] === 'show' && args[1] === `:3:${changelogPath}`) { return { stdout: buildChangelog('') }; } - throw new Error(`Unexpected execa call: ${command} ${args.join(' ')}`); + throw new Error( + `Unexpected execa call: ${command} ${args.join(' ')}`, + ); }, ); diff --git a/scripts/lib/changelog-conflicts.ts b/scripts/lib/changelog-conflicts.ts index 7822f823350..9af722c7b50 100644 --- a/scripts/lib/changelog-conflicts.ts +++ b/scripts/lib/changelog-conflicts.ts @@ -1,5 +1,6 @@ import type { ReleaseChanges } from '@metamask/auto-changelog'; import { oxfmt, parseChangelog } from '@metamask/auto-changelog'; +import { getErrorMessage } from '@metamask/utils'; import execa from 'execa'; import { promises as fs } from 'fs'; import path from 'path'; @@ -18,23 +19,28 @@ const BREAKING_CHANGE_PATTERN = /^\*\*BREAKING:?\*\*/u; type Category = keyof ReleaseChanges; type Change = NonNullable[number]; -export type PackageMetadata = { +type PackageJson = { + name?: string; + repository?: { url?: string }; +}; + +type PackageMetadata = { name: string; repoUrl: string; tagPrefix: string; }; -export type ConflictResolution = { +type ConflictResolution = { path: string; mergedEntryCount: number; }; -export type ConflictSkip = { +type ConflictSkip = { path: string; reason: string; }; -export type ConflictResolutionResult = { +type ConflictResolutionResult = { resolved: ConflictResolution[]; skipped: ConflictSkip[]; }; @@ -78,12 +84,32 @@ export async function readGitBlob( return stdout; } +/** + * Read and parse a `package.json` file, preferring the working tree copy + * since it's normally not part of the conflict; if it can't be parsed there + * (e.g. it's also mid-merge and contains conflict markers), it's re-read + * from the "ours" conflict stage instead. + * + * @param packageJsonPath - The repo-relative path of the `package.json` file. + * @returns The parsed `package.json` contents. + */ +async function readPackageJson(packageJsonPath: string): Promise { + try { + const content = await fs.readFile( + path.join(ROOT_WORKSPACE, packageJsonPath), + 'utf8', + ); + + return JSON.parse(content); + } catch { + const content = await readGitBlob(':2', packageJsonPath); + return JSON.parse(content); + } +} + /** * Resolve the package name and repository URL for the package that owns the - * given changelog file. `package.json` is read from the working tree, since - * it's normally not part of the conflict; if it can't be parsed there (e.g. - * it's also mid-merge and contains conflict markers), it's re-read from the - * "ours" conflict stage instead. + * given changelog file. * * @param changelogPath - The repo-relative path of the changelog file. * @returns The package's name, repository URL, and changelog tag prefix. @@ -96,22 +122,11 @@ export async function resolvePackageMetadata( 'package.json', ); - let packageJson; - try { - const content = await fs.readFile( - path.join(ROOT_WORKSPACE, packageJsonPath), - 'utf8', - ); - packageJson = JSON.parse(content); - } catch { - const content = await readGitBlob(':2', packageJsonPath); - packageJson = JSON.parse(content); - } - + const packageJson = await readPackageJson(packageJsonPath); const repositoryUrl = packageJson.repository?.url; if (!packageJson.name || !repositoryUrl) { throw new Error( - `Could not resolve package name/repository for '${changelogPath}'.`, + `Could not resolve package name or repository for "${changelogPath}".`, ); } @@ -160,29 +175,32 @@ function getChangeKey(change: Change): string { * @returns The number of new entries added to `base`. */ function mergeCategoryEntries(base: Change[], incoming: Change[]): number { + const initialLength = base.length; const existingKeys = new Set(base.map(getChangeKey)); - let addedCount = 0; for (const change of incoming) { const key = getChangeKey(change); if (existingKeys.has(key)) { continue; } + existingKeys.add(key); - addedCount += 1; if (isBreakingChange(change)) { - let insertIndex = 0; - while (insertIndex < base.length && isBreakingChange(base[insertIndex])) { - insertIndex += 1; - } + const firstNonBreakingIndex = base.findIndex( + (entry) => !isBreakingChange(entry), + ); + + const insertIndex = + firstNonBreakingIndex === -1 ? base.length : firstNonBreakingIndex; + base.splice(insertIndex, 0, change); } else { base.push(change); } } - return addedCount; + return base.length - initialLength; } /** @@ -253,6 +271,7 @@ export async function mergeChangelogs({ tagPrefix, shouldExtractPrLinks: true, }); + const theirs = parseChangelog({ changelogContent: theirsContent, repoUrl, @@ -278,13 +297,13 @@ export async function mergeChangelogs({ theirs.addRelease(oursRelease); const releases = theirs.getReleases(); const [inserted] = releases.splice(0, 1); - let sortedIndex = releases.findIndex(({ version }) => + + const sortedIndex = releases.findIndex(({ version }) => gt(inserted.version, version), ); - if (sortedIndex === -1) { - sortedIndex = releases.length; - } - releases.splice(sortedIndex, 0, inserted); + + const insertIndex = sortedIndex === -1 ? releases.length : sortedIndex; + releases.splice(insertIndex, 0, inserted); } const theirsReleaseChanges = theirs.getReleaseChanges(oursRelease.version); @@ -343,7 +362,7 @@ export async function resolveChangelogConflicts(): Promise { console.error(error); process.exitCode = 1; @@ -26,7 +25,9 @@ export async function main(): Promise { for (const { path, mergedEntryCount } of resolved) { const entryWord = mergedEntryCount === 1 ? 'entry' : 'entries'; - console.log(`Resolved ${path} (merged ${mergedEntryCount} new ${entryWord}).`); + console.log( + `Resolved ${path} (merged ${mergedEntryCount} new ${entryWord}).`, + ); } for (const { path, reason } of skipped) { From 4720cb69c2ae9735725cd32dff46d7c5475b90d7 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 28 Aug 2026 10:22:03 +0200 Subject: [PATCH 03/10] Fix auto-changelog version --- package.json | 2 +- yarn.lock | 25 +------------------------ 2 files changed, 2 insertions(+), 25 deletions(-) diff --git a/package.json b/package.json index f3b06eb100e..c5c69713a2e 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "@actions/github": "^9.1.1", "@lavamoat/allow-scripts": "^3.0.4", "@lavamoat/preinstall-always-fail": "^2.1.0", - "@metamask/auto-changelog": "^6.2.1", + "@metamask/auto-changelog": "^6.1.0", "@metamask/create-release-branch": "^4.2.2", "@metamask/eslint-config": "^15.0.0", "@metamask/eslint-config-jest": "^15.0.0", diff --git a/yarn.lock b/yarn.lock index 40a84032489..c1138984df7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6186,29 +6186,6 @@ __metadata: languageName: node linkType: hard -"@metamask/auto-changelog@npm:^6.2.1": - version: 6.2.1 - resolution: "@metamask/auto-changelog@npm:6.2.1" - dependencies: - "@octokit/rest": "npm:^20.0.0" - diff: "npm:^5.0.0" - execa: "npm:^5.1.1" - semver: "npm:^7.3.5" - yargs: "npm:^17.0.1" - peerDependencies: - oxfmt: ^0.45.0 - prettier: ">=3.0.0" - peerDependenciesMeta: - oxfmt: - optional: true - prettier: - optional: true - bin: - auto-changelog: dist/cli.mjs - checksum: 10/88f5bc63ef5003b3e4f88ebf018229ce5e701283cadee1dcbd37f869541a1102ba6a8a96fd54993863e3578c8b79f0c6e0242e4afd08c58d5db529bc75dbaf2c - languageName: node - linkType: hard - "@metamask/base-controller@npm:^9.0.1, @metamask/base-controller@npm:^9.1.0, @metamask/base-controller@workspace:packages/base-controller": version: 0.0.0-use.local resolution: "@metamask/base-controller@workspace:packages/base-controller" @@ -6700,7 +6677,7 @@ __metadata: "@actions/github": "npm:^9.1.1" "@lavamoat/allow-scripts": "npm:^3.0.4" "@lavamoat/preinstall-always-fail": "npm:^2.1.0" - "@metamask/auto-changelog": "npm:^6.2.1" + "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/create-release-branch": "npm:^4.2.2" "@metamask/eslint-config": "npm:^15.0.0" "@metamask/eslint-config-jest": "npm:^15.0.0" From 32b2b69f0c6c5f01b3d52c8462163dceaaa947da Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 28 Aug 2026 10:24:37 +0200 Subject: [PATCH 04/10] Fix formatting --- scripts/merge-changelog-conflicts.test.ts | 34 +++++++++++++---------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/scripts/merge-changelog-conflicts.test.ts b/scripts/merge-changelog-conflicts.test.ts index fea45971a63..2965cfc90b5 100644 --- a/scripts/merge-changelog-conflicts.test.ts +++ b/scripts/merge-changelog-conflicts.test.ts @@ -32,12 +32,14 @@ describe('merge-changelog-conflicts', () => { }); it('logs each resolved file and exits cleanly', async () => { - jest.spyOn(changelogConflicts, 'resolveChangelogConflicts').mockResolvedValue({ - resolved: [ - { path: 'packages/example/CHANGELOG.md', mergedEntryCount: 2 }, - ], - skipped: [], - }); + jest + .spyOn(changelogConflicts, 'resolveChangelogConflicts') + .mockResolvedValue({ + resolved: [ + { path: 'packages/example/CHANGELOG.md', mergedEntryCount: 2 }, + ], + skipped: [], + }); await main(); @@ -51,15 +53,17 @@ describe('merge-changelog-conflicts', () => { }); it('warns about skipped files and exits with a non-zero code', async () => { - jest.spyOn(changelogConflicts, 'resolveChangelogConflicts').mockResolvedValue({ - resolved: [], - skipped: [ - { - path: 'packages/example/CHANGELOG.md', - reason: 'Malformed release header', - }, - ], - }); + jest + .spyOn(changelogConflicts, 'resolveChangelogConflicts') + .mockResolvedValue({ + resolved: [], + skipped: [ + { + path: 'packages/example/CHANGELOG.md', + reason: 'Malformed release header', + }, + ], + }); await main(); From f6c8c03d10d268c580c1648c9e4d479fd18831af Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 28 Aug 2026 10:57:34 +0200 Subject: [PATCH 05/10] Fix changelog entry dedupe dropping distinct same-PR entries Keying merged entries by PR number alone meant a PR that legitimately adds multiple distinct changelog bullets would have all but the first treated as duplicates and silently dropped. Key on the PR number and description together instead, and add a regression test. --- scripts/lib/changelog-conflicts.test.ts | 24 ++++++++++++++++++++++++ scripts/lib/changelog-conflicts.ts | 17 +++++++---------- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/scripts/lib/changelog-conflicts.test.ts b/scripts/lib/changelog-conflicts.test.ts index c53214e72e3..9e359ef30f1 100644 --- a/scripts/lib/changelog-conflicts.test.ts +++ b/scripts/lib/changelog-conflicts.test.ts @@ -115,6 +115,30 @@ ${sharedEntry}`); ).toHaveLength(1); }); + it('keeps distinct entries that share the same PR number', async () => { + const sharedEntry = `- Added shared entry ([#20](${REPO_URL}/pull/20))`; + const oursContent = buildChangelog(`### Added + +${sharedEntry} +- Added a second, distinct entry from the same PR ([#20](${REPO_URL}/pull/20))`); + const theirsContent = buildChangelog(`### Added + +${sharedEntry}`); + + const { content, mergedEntryCount } = await mergeChangelogs({ + oursContent, + theirsContent, + repoUrl: REPO_URL, + tagPrefix: TAG_PREFIX, + }); + + expect(mergedEntryCount).toBe(1); + expect(content.match(/Added shared entry/gu)).toHaveLength(1); + expect(content).toContain( + 'Added a second, distinct entry from the same PR', + ); + }); + it('inserts a new breaking entry below existing breaking entries, above non-breaking ones', async () => { const oursContent = buildChangelog( `### Changed diff --git a/scripts/lib/changelog-conflicts.ts b/scripts/lib/changelog-conflicts.ts index 9af722c7b50..8c8517808fb 100644 --- a/scripts/lib/changelog-conflicts.ts +++ b/scripts/lib/changelog-conflicts.ts @@ -149,19 +149,16 @@ function isBreakingChange(change: Change): boolean { } /** - * Build a key that identifies "the same change" across both conflict sides, - * preferring the PR numbers (since wording may drift slightly between - * sides) and falling back to the description. + * Build a key that identifies "the same change" across both conflict sides. + * Includes the PR numbers alongside the description, since a single PR can add + * multiple distinct changelog entries that all reference it. * * @param change - The change entry. * @returns The dedup key for the change. */ function getChangeKey(change: Change): string { - if (change.prNumbers.length > 0) { - return `pr:${[...change.prNumbers].sort().join(',')}`; - } - - return `desc:${change.description.trim()}`; + const prKey = [...change.prNumbers].sort().join(','); + return `${prKey}:${change.description.trim()}`; } /** @@ -241,8 +238,8 @@ function mergeReleaseChanges( /** * Merge two conflicting versions of a changelog by taking the union of their * entries: every entry unique to either side is kept, deduplicated by PR - * number (falling back to description), with new `**BREAKING:**` entries - * placed below existing breaking entries and other new entries appended. + * number and description together, with new `**BREAKING:**` entries placed + * below existing breaking entries and other new entries appended. * * @param options - Options. * @param options.oursContent - The changelog content on the "ours" conflict From 90feb0b0990b20e5e34de838bd45022e11b70532 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 28 Aug 2026 13:22:07 +0200 Subject: [PATCH 06/10] Merge into "ours" instead of "theirs" to preserve entry order During a rebase, "theirs" is the commit being replayed, which can already contain an entry that also exists in "ours" (the branch being rebased onto). Since "ours" is usually the side more likely to already overlap with "theirs", using it as the merge base keeps shared entries in their existing position and only appends genuinely new entries from "theirs", instead of the reverse producing a confusing reordering. --- scripts/lib/changelog-conflicts.test.ts | 66 +++++++++++++++++++------ scripts/lib/changelog-conflicts.ts | 34 ++++++++----- 2 files changed, 71 insertions(+), 29 deletions(-) diff --git a/scripts/lib/changelog-conflicts.test.ts b/scripts/lib/changelog-conflicts.test.ts index 9e359ef30f1..bcedd07e65e 100644 --- a/scripts/lib/changelog-conflicts.test.ts +++ b/scripts/lib/changelog-conflicts.test.ts @@ -66,11 +66,11 @@ describe('changelog-conflicts', () => { expect(mergedEntryCount).toBe(1); const addedIndex = content.indexOf('### Added'); - const theirsIndex = content.indexOf('Added theirs entry'); const oursIndex = content.indexOf('Added ours entry'); + const theirsIndex = content.indexOf('Added theirs entry'); expect(addedIndex).toBeGreaterThan(-1); - expect(theirsIndex).toBeGreaterThan(addedIndex); - expect(oursIndex).toBeGreaterThan(theirsIndex); + expect(oursIndex).toBeGreaterThan(addedIndex); + expect(theirsIndex).toBeGreaterThan(oursIndex); }); it('does not duplicate an entry that both sides added (identified by PR number)', async () => { @@ -132,7 +132,7 @@ ${sharedEntry}`); tagPrefix: TAG_PREFIX, }); - expect(mergedEntryCount).toBe(1); + expect(mergedEntryCount).toBe(0); expect(content.match(/Added shared entry/gu)).toHaveLength(1); expect(content).toContain( 'Added a second, distinct entry from the same PR', @@ -143,13 +143,13 @@ ${sharedEntry}`); const oursContent = buildChangelog( `### Changed -- **BREAKING:** Ours breaking entry ([#30](${REPO_URL}/pull/30))`, +- **BREAKING:** Ours existing breaking entry ([#31](${REPO_URL}/pull/31)) +- Ours existing non-breaking entry ([#32](${REPO_URL}/pull/32))`, ); const theirsContent = buildChangelog( `### Changed -- **BREAKING:** Theirs existing breaking entry ([#31](${REPO_URL}/pull/31)) -- Theirs existing non-breaking entry ([#32](${REPO_URL}/pull/32))`, +- **BREAKING:** Theirs breaking entry ([#30](${REPO_URL}/pull/30))`, ); const { content, mergedEntryCount } = await mergeChangelogs({ @@ -161,11 +161,11 @@ ${sharedEntry}`); expect(mergedEntryCount).toBe(1); const existingBreakingIndex = content.indexOf( - 'Theirs existing breaking entry', + 'Ours existing breaking entry', ); - const newBreakingIndex = content.indexOf('Ours breaking entry'); + const newBreakingIndex = content.indexOf('Theirs breaking entry'); const nonBreakingIndex = content.indexOf( - 'Theirs existing non-breaking entry', + 'Ours existing non-breaking entry', ); expect(existingBreakingIndex).toBeLessThan(newBreakingIndex); expect(newBreakingIndex).toBeLessThan(nonBreakingIndex); @@ -198,7 +198,7 @@ ${sharedEntry}`); }); it('merges in a release version that only exists on one side', async () => { - const oursContent = `# Changelog + const theirsContent = `# Changelog All notable changes to this project will be documented in this file. @@ -223,7 +223,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [2.0.0]: ${REPO_URL}/compare/${TAG_PREFIX}1.0.0...${TAG_PREFIX}2.0.0 [1.0.0]: ${REPO_URL}/releases/tag/${TAG_PREFIX}1.0.0 `; - const theirsContent = buildChangelog(''); + const oursContent = buildChangelog(''); const { content, mergedEntryCount } = await mergeChangelogs({ oursContent, @@ -238,7 +238,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 }); it('appends a new release version that is older than every existing release', async () => { - const oursContent = `# Changelog + const theirsContent = `# Changelog All notable changes to this project will be documented in this file. @@ -263,7 +263,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [2.0.0]: ${REPO_URL}/compare/${TAG_PREFIX}0.5.0...${TAG_PREFIX}2.0.0 [0.5.0]: ${REPO_URL}/releases/tag/${TAG_PREFIX}0.5.0 `; - const theirsContent = `# Changelog + const oursContent = `# Changelog All notable changes to this project will be documented in this file. @@ -296,7 +296,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 }); it('inserts a new release version into its correct descending-SemVer position, not just at the start or end', async () => { - const oursContent = `# Changelog + const theirsContent = `# Changelog All notable changes to this project will be documented in this file. @@ -328,7 +328,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [2.0.0]: ${REPO_URL}/compare/${TAG_PREFIX}1.0.0...${TAG_PREFIX}2.0.0 [1.0.0]: ${REPO_URL}/releases/tag/${TAG_PREFIX}1.0.0 `; - const theirsContent = `# Changelog + const oursContent = `# Changelog All notable changes to this project will be documented in this file. @@ -371,6 +371,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `[3.0.0]: ${REPO_URL}/compare/${TAG_PREFIX}2.0.0...${TAG_PREFIX}3.0.0`, ); }); + + it('keeps an entry shared by both sides in its "ours" position, appending only genuinely new entries', async () => { + // Regression test for a rebase scenario: "ours" (the branch being + // rebased onto) has since gained a new entry, while "theirs" (the + // replayed commit) already contained an entry that also exists in + // "ours". Only the entry unique to "ours" should be appended; the + // shared entry should not move. + const oursContent = buildChangelog( + `### Changed + +- New entry only on ours ([#6388](${REPO_URL}/pull/6388)) +- Shared entry ([#9960](${REPO_URL}/pull/9960))`, + ); + const theirsContent = buildChangelog( + `### Changed + +- **BREAKING:** Breaking entry only on theirs ([#9168](${REPO_URL}/pull/9168)) +- Shared entry ([#9960](${REPO_URL}/pull/9960))`, + ); + + const { content, mergedEntryCount } = await mergeChangelogs({ + oursContent, + theirsContent, + repoUrl: REPO_URL, + tagPrefix: TAG_PREFIX, + }); + + expect(mergedEntryCount).toBe(1); + const breakingIndex = content.indexOf('Breaking entry only on theirs'); + const newEntryIndex = content.indexOf('New entry only on ours'); + const sharedIndex = content.indexOf('Shared entry'); + expect(breakingIndex).toBeLessThan(newEntryIndex); + expect(newEntryIndex).toBeLessThan(sharedIndex); + }); }); describe('findConflictedChangelogFiles', () => { diff --git a/scripts/lib/changelog-conflicts.ts b/scripts/lib/changelog-conflicts.ts index 8c8517808fb..eaffc9fc9e0 100644 --- a/scripts/lib/changelog-conflicts.ts +++ b/scripts/lib/changelog-conflicts.ts @@ -262,10 +262,17 @@ export async function mergeChangelogs({ repoUrl: string; tagPrefix: string; }): Promise<{ content: string; mergedEntryCount: number }> { + // `ours` is used as the base to mutate and stringify. During a Git merge, + // `ours` is the current branch (HEAD); during a rebase, it's the upstream + // branch being rebased onto. In both cases, that's the side more likely to + // already contain entries also present in `theirs`, so preserving its + // existing order (and only appending genuinely new entries from `theirs`) + // produces more intuitive results than the reverse. const ours = parseChangelog({ changelogContent: oursContent, repoUrl, tagPrefix, + formatter: oxfmt, shouldExtractPrLinks: true, }); @@ -273,26 +280,25 @@ export async function mergeChangelogs({ changelogContent: theirsContent, repoUrl, tagPrefix, - formatter: oxfmt, shouldExtractPrLinks: true, }); let mergedEntryCount = mergeReleaseChanges( - theirs.getUnreleasedChanges(), ours.getUnreleasedChanges(), + theirs.getUnreleasedChanges(), ); - const theirsVersions = new Set( - theirs.getReleases().map(({ version }) => version), + const oursVersions = new Set( + ours.getReleases().map(({ version }) => version), ); - for (const oursRelease of ours.getReleases()) { - if (!theirsVersions.has(oursRelease.version)) { + for (const theirsRelease of theirs.getReleases()) { + if (!oursVersions.has(theirsRelease.version)) { // `addRelease` can only add to the very start or end of the release // list, so insert at the start and then reposition it into its // correct descending-SemVer slot among the existing releases. - theirs.addRelease(oursRelease); - const releases = theirs.getReleases(); + ours.addRelease(theirsRelease); + const releases = ours.getReleases(); const [inserted] = releases.splice(0, 1); const sortedIndex = releases.findIndex(({ version }) => @@ -303,16 +309,18 @@ export async function mergeChangelogs({ releases.splice(insertIndex, 0, inserted); } - const theirsReleaseChanges = theirs.getReleaseChanges(oursRelease.version); - const oursReleaseChanges = ours.getReleaseChanges(oursRelease.version); + const oursReleaseChanges = ours.getReleaseChanges(theirsRelease.version); + const theirsReleaseChanges = theirs.getReleaseChanges( + theirsRelease.version, + ); mergedEntryCount += mergeReleaseChanges( - theirsReleaseChanges, - oursReleaseChanges ?? {}, + oursReleaseChanges, + theirsReleaseChanges ?? {}, ); } return { - content: await theirs.toString(), + content: await ours.toString(), mergedEntryCount, }; } From cd4046c7cde0accd52db4484de7d0cd3ceebb7e9 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 28 Aug 2026 13:45:34 +0200 Subject: [PATCH 07/10] Update scripts/merge-changelog-conflicts.ts Co-authored-by: Frederik Bolding --- scripts/merge-changelog-conflicts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/merge-changelog-conflicts.ts b/scripts/merge-changelog-conflicts.ts index 59ba9974b00..d3d08d6ed7e 100644 --- a/scripts/merge-changelog-conflicts.ts +++ b/scripts/merge-changelog-conflicts.ts @@ -19,7 +19,7 @@ export async function main(): Promise { const { resolved, skipped } = await resolveChangelogConflicts(); if (resolved.length === 0 && skipped.length === 0) { - console.log('No conflicted CHANGELOG.md files found.'); + console.log('No CHANGELOG.md files with conflicts found.'); return; } From e6f137225c144f2f8c500c4ea5afbfbc457f8cf9 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 28 Aug 2026 13:49:31 +0200 Subject: [PATCH 08/10] Fix test --- scripts/merge-changelog-conflicts.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/merge-changelog-conflicts.test.ts b/scripts/merge-changelog-conflicts.test.ts index 2965cfc90b5..63c72e10cbd 100644 --- a/scripts/merge-changelog-conflicts.test.ts +++ b/scripts/merge-changelog-conflicts.test.ts @@ -26,7 +26,7 @@ describe('merge-changelog-conflicts', () => { await main(); expect(console.log).toHaveBeenCalledWith( - 'No conflicted CHANGELOG.md files found.', + 'No CHANGELOG.md files with conflicts found.', ); expect(process.exitCode).toBe(0); }); From e09805603d2ef4533656b25e713a96d9df718b56 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 28 Aug 2026 14:40:11 +0200 Subject: [PATCH 09/10] Don't stage resolved CHANGELOG.md files automatically Writing the merged result without running `git add` lets the user review the resolution (and the state of any other conflicted files) before deciding what to stage, rather than silently adding to the index on their behalf. --- scripts/lib/changelog-conflicts.test.ts | 7 +------ scripts/lib/changelog-conflicts.ts | 9 ++++----- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/scripts/lib/changelog-conflicts.test.ts b/scripts/lib/changelog-conflicts.test.ts index bcedd07e65e..73696ebfb0f 100644 --- a/scripts/lib/changelog-conflicts.test.ts +++ b/scripts/lib/changelog-conflicts.test.ts @@ -516,7 +516,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 }); describe('resolveChangelogConflicts', () => { - it('resolves each conflicted file and stages it with git add', async () => { + it('resolves each conflicted file', async () => { const changelogPath = 'packages/example/CHANGELOG.md'; const oursContent = buildChangelog( `### Added @@ -568,11 +568,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 expect.stringContaining('Added theirs entry'), 'utf8', ); - expect(execa).toHaveBeenCalledWith( - 'git', - ['add', changelogPath], - expect.objectContaining({ cwd: expect.any(String) }), - ); }); it('skips a file that cannot be parsed and leaves it unresolved', async () => { diff --git a/scripts/lib/changelog-conflicts.ts b/scripts/lib/changelog-conflicts.ts index eaffc9fc9e0..13616ecc25b 100644 --- a/scripts/lib/changelog-conflicts.ts +++ b/scripts/lib/changelog-conflicts.ts @@ -327,10 +327,10 @@ export async function mergeChangelogs({ /** * Find every conflicted `packages/*\/CHANGELOG.md` file, resolve as many as - * possible via {@link mergeChangelogs}, write the merged result back to the - * working tree, and stage it with `git add`. Files that can't be - * automatically merged (e.g. a structurally invalid side) are left with - * their conflict markers intact. + * possible via {@link mergeChangelogs}, and write the merged result back to + * the working tree (without staging it, so it can still be reviewed before + * committing). Files that can't be automatically merged (e.g. a + * structurally invalid side) are left with their conflict markers intact. * * @returns The set of files that were resolved and the set that were * skipped, along with the reason for each skip. @@ -361,7 +361,6 @@ export async function resolveChangelogConflicts(): Promise Date: Fri, 28 Aug 2026 14:50:19 +0200 Subject: [PATCH 10/10] Normalize reported paths to the OS-native separator changelogPath stays forward-slash internally (git commands and path.posix calls need that form), but the path surfaced in the resolved/skipped results is what gets printed to the terminal, where a Windows-style backslash path is more likely to be recognized as a clickable link. --- scripts/lib/changelog-conflicts.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/lib/changelog-conflicts.ts b/scripts/lib/changelog-conflicts.ts index 13616ecc25b..3d796dac0fe 100644 --- a/scripts/lib/changelog-conflicts.ts +++ b/scripts/lib/changelog-conflicts.ts @@ -31,11 +31,13 @@ type PackageMetadata = { }; type ConflictResolution = { + /** OS-native path, for display (e.g. in a terminal). */ path: string; mergedEntryCount: number; }; type ConflictSkip = { + /** OS-native path, for display (e.g. in a terminal). */ path: string; reason: string; }; @@ -362,10 +364,13 @@ export async function resolveChangelogConflicts(): Promise