Skip to content

Commit 884e1f4

Browse files
committed
feat(release): preserve finalized changelog history when merging previous releases
1 parent 0cb4827 commit 884e1f4

9 files changed

Lines changed: 492 additions & 9 deletions

File tree

‎.eslintrc.js‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,21 @@ module.exports = {
3333
files: ['*.test.ts', '*.test.js'],
3434
extends: ['@metamask/eslint-config-jest'],
3535
},
36+
37+
{
38+
files: ['.github/scripts/**/*.mts'],
39+
parserOptions: {
40+
project: './tsconfig.json',
41+
},
42+
},
43+
44+
{
45+
files: ['.github/scripts/**/*.test.mts'],
46+
rules: {
47+
'@typescript-eslint/no-floating-promises': 'off',
48+
'n/no-process-env': 'off',
49+
},
50+
},
3651
],
3752

3853
ignorePatterns: [

‎.github/actions/merge-previous-releases/action.yml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ runs:
3434
ref: ${{ inputs.github-tools-ref }}
3535
path: ./github-tools
3636

37+
- name: Setup Node.js
38+
uses: actions/setup-node@v6
39+
with:
40+
node-version-file: ./github-tools/.nvmrc
41+
3742
- name: Set Git user and email
3843
shell: bash
3944
run: |
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
/*
2+
* Restores a finalized release's changelog history after `git merge -s ours`.
3+
*
4+
* `merge-previous-releases.sh` merges the previous release branch with the
5+
* `ours` strategy, which records the ancestry but keeps the destination's
6+
* files, so the finalized `## [version]` section and its `[version]: url` link
7+
* reference from the source's CHANGELOG.md would be lost. This script copies
8+
* them back in version order.
9+
*
10+
* Usage: node merge-previous-release-changelog.mts <source-ref> <version>
11+
*
12+
* The changelog is edited as plain text, not through a changelog parser, so
13+
* anything this script doesn't fully understand is left untouched. Every
14+
* ambiguous or failing case warns and exits successfully, preserving the
15+
* destination: a wrong automated edit to release history is worse than a
16+
* missing one that a reviewer can add by hand.
17+
*/
18+
import { execFileSync } from 'node:child_process';
19+
import { readFileSync, writeFileSync } from 'node:fs';
20+
21+
// Any `[label]: url` line, including `[Unreleased]: url`.
22+
const LINK_REFERENCE = /^\[[^\]]+\]: /mu;
23+
// A release section ends at the next heading or at the link references.
24+
const SECTION_END = /^(?:## \[|\[[^\]]+\]: )/mu;
25+
26+
/**
27+
* Compares two `major.minor.patch` versions.
28+
* @param left - The first version.
29+
* @param right - The second version.
30+
* @returns A positive number when `left` is newer, negative when older, else 0.
31+
*/
32+
function compareVersions(left: string, right: string): number {
33+
const leftParts = left.split('.').map(Number);
34+
const rightParts = right.split('.').map(Number);
35+
36+
for (let index = 0; index < leftParts.length; index += 1) {
37+
const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0);
38+
if (difference !== 0) {
39+
return difference;
40+
}
41+
}
42+
return 0;
43+
}
44+
45+
/**
46+
* Escapes a string for literal use inside a regular expression.
47+
* @param value - The string to escape.
48+
* @returns The escaped string.
49+
*/
50+
function escapeRegex(value: string): string {
51+
return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
52+
}
53+
54+
/**
55+
* Finds every `## [version]` heading for a version.
56+
* @param changelog - The changelog contents.
57+
* @param version - The release version.
58+
* @returns All matching headings.
59+
*/
60+
function getReleaseHeadingMatches(
61+
changelog: string,
62+
version: string,
63+
): RegExpMatchArray[] {
64+
const heading = `## [${version}]`;
65+
return [
66+
...changelog.matchAll(new RegExp(`^${escapeRegex(heading)}$`, 'gmu')),
67+
];
68+
}
69+
70+
/**
71+
* Extracts the section for a release, if it appears exactly once.
72+
* @param changelog - The changelog contents.
73+
* @param version - The release version.
74+
* @returns The section text, or undefined when missing or ambiguous.
75+
*/
76+
function getReleaseSection(
77+
changelog: string,
78+
version: string,
79+
): string | undefined {
80+
const matches = getReleaseHeadingMatches(changelog, version);
81+
// A duplicated heading means we can't tell which copy is the real one.
82+
if (matches.length !== 1 || matches[0]?.index === undefined) {
83+
return undefined;
84+
}
85+
86+
const start = matches[0].index;
87+
const bodyStart = start + matches[0][0].length;
88+
const bodyEnd = changelog.slice(bodyStart).search(SECTION_END);
89+
return changelog
90+
.slice(start, bodyEnd === -1 ? undefined : bodyStart + bodyEnd)
91+
.trimEnd();
92+
}
93+
94+
/**
95+
* Finds the `[version]: url` link reference for a release.
96+
* @param changelog - The changelog contents.
97+
* @param version - The release version.
98+
* @returns The reference line, or undefined when missing or ambiguous.
99+
*/
100+
function getReleaseReference(
101+
changelog: string,
102+
version: string,
103+
): string | undefined {
104+
const matches = [
105+
...changelog.matchAll(
106+
new RegExp(`^\\[${escapeRegex(version)}\\]: .+$`, 'gmu'),
107+
),
108+
];
109+
return matches.length === 1 ? matches[0]?.[0] : undefined;
110+
}
111+
112+
/**
113+
* Finds where a version belongs in descending version order.
114+
* @param changelog - The changelog contents.
115+
* @param version - The version being inserted.
116+
* @param pattern - A global regex whose first group captures a version.
117+
* @returns The index of the first older entry, or undefined if none.
118+
*/
119+
function findInsertionIndex(
120+
changelog: string,
121+
version: string,
122+
pattern: RegExp,
123+
): number | undefined {
124+
// Entries are newest-first, so the first older entry marks the insert point.
125+
for (const match of changelog.matchAll(pattern)) {
126+
const candidate = match[1];
127+
if (
128+
candidate &&
129+
match.index !== undefined &&
130+
compareVersions(version, candidate) > 0
131+
) {
132+
return match.index;
133+
}
134+
}
135+
return undefined;
136+
}
137+
138+
/**
139+
* Inserts a release section in version order.
140+
* @param changelog - The destination changelog contents.
141+
* @param section - The section to insert.
142+
* @param version - The release version.
143+
* @param lineEnding - The destination's line ending.
144+
* @returns The updated changelog.
145+
*/
146+
function insertReleaseSection(
147+
changelog: string,
148+
section: string,
149+
version: string,
150+
lineEnding: string,
151+
): string {
152+
const index = findInsertionIndex(
153+
changelog,
154+
version,
155+
/^## \[(\d+\.\d+\.\d+)\]$/gmu,
156+
);
157+
// `git show` returns committed (LF) text, which can differ from the checkout.
158+
const normalizedSection = section.replaceAll('\n', lineEnding);
159+
160+
// Nothing older exists, so the section goes last, above the link references.
161+
if (index === undefined) {
162+
const referencesIndex = changelog.search(LINK_REFERENCE);
163+
const beforeReferences =
164+
referencesIndex === -1 ? changelog.length : referencesIndex;
165+
return `${changelog.slice(0, beforeReferences).trimEnd()}${lineEnding}${lineEnding}${normalizedSection}${lineEnding}${lineEnding}${changelog.slice(beforeReferences)}`;
166+
}
167+
168+
return `${changelog.slice(0, index)}${normalizedSection}${lineEnding}${lineEnding}${changelog.slice(index)}`;
169+
}
170+
171+
/**
172+
* Inserts a release link reference in version order, if not already present.
173+
* @param changelog - The destination changelog contents.
174+
* @param reference - The reference line to insert.
175+
* @param version - The release version.
176+
* @param lineEnding - The destination's line ending.
177+
* @returns The updated changelog.
178+
*/
179+
function insertReleaseReference(
180+
changelog: string,
181+
reference: string | undefined,
182+
version: string,
183+
lineEnding: string,
184+
): string {
185+
// Skip when the source has no reference, or when one already exists so the
186+
// destination's own link wins.
187+
if (!reference || getReleaseReference(changelog, version)) {
188+
return changelog;
189+
}
190+
191+
const index = findInsertionIndex(
192+
changelog,
193+
version,
194+
/^\[(\d+\.\d+\.\d+)\]: /gmu,
195+
);
196+
if (index === undefined) {
197+
return `${changelog.trimEnd()}${lineEnding}${reference}${lineEnding}`;
198+
}
199+
return `${changelog.slice(0, index)}${reference}${lineEnding}${changelog.slice(index)}`;
200+
}
201+
202+
/**
203+
* Copies a finalized release section from a source branch's changelog into
204+
* the destination changelog, preserving the destination on any ambiguity.
205+
*/
206+
function main(): void {
207+
const [sourceRef, version] = process.argv.slice(2);
208+
if (!sourceRef || !version) {
209+
throw new Error(
210+
'Usage: merge-previous-release-changelog.mts <source-ref> <version>',
211+
);
212+
}
213+
214+
const destination = readFileSync('CHANGELOG.md', 'utf8');
215+
const source = execFileSync('git', ['show', `${sourceRef}:CHANGELOG.md`], {
216+
encoding: 'utf8',
217+
});
218+
const section = getReleaseSection(source, version);
219+
220+
// Only a finalized release has a versioned section; an unreleased or
221+
// duplicated one is not safe to copy.
222+
if (!section) {
223+
console.warn(
224+
`Source changelog has no unambiguous finalized ${version} section; preserving destination changelog.`,
225+
);
226+
return;
227+
}
228+
// The destination already records this release, so there is nothing to
229+
// restore and inserting would duplicate the heading.
230+
if (getReleaseHeadingMatches(destination, version).length > 0) {
231+
console.warn(
232+
`Destination already has a ${version} section; preserving destination changelog.`,
233+
);
234+
return;
235+
}
236+
237+
// Match the destination so the diff doesn't rewrite every line ending.
238+
const lineEnding = destination.includes('\r\n') ? '\r\n' : '\n';
239+
const withSection = insertReleaseSection(
240+
destination,
241+
section,
242+
version,
243+
lineEnding,
244+
);
245+
const merged = insertReleaseReference(
246+
withSection,
247+
getReleaseReference(source, version),
248+
version,
249+
lineEnding,
250+
);
251+
writeFileSync('CHANGELOG.md', merged);
252+
console.log(
253+
`Inserted finalized ${version} changelog history from ${sourceRef}.`,
254+
);
255+
}
256+
257+
// Never fail the release merge over changelog history; see the header comment.
258+
try {
259+
main();
260+
} catch (error) {
261+
console.warn(
262+
`Unable to merge source changelog history; preserving destination changelog: ${error instanceof Error ? error.message : String(error)}`,
263+
);
264+
}

‎.github/scripts/merge-previous-releases.sh‎

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
#
99
# Key behaviors:
1010
# - Merges older active RCs into the new release branch
11-
# - Always resets to destination (new RC) content after merge, preventing
12-
# git from creating duplicate content when both branches have the same changes
11+
# - Uses an ancestry-only merge to prevent duplicate code content, while
12+
# preserving a finalized source release's changelog history
1313
# - Both branches remain open after merge
1414
# - Fails fast on errors to prevent pushing partial merges
1515
#
@@ -19,6 +19,8 @@
1919

2020
set -e
2121

22+
SCRIPT_DIRECTORY="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
23+
2224
# Parse a release branch name to extract version components
2325
# Returns: "major minor patch" or empty string if not valid
2426
parse_release_version() {
@@ -55,8 +57,23 @@ is_branch_merged() {
5557
git merge-base --is-ancestor "origin/${source_branch}" HEAD 2>/dev/null
5658
}
5759

60+
# Copy the source's finalized changelog section into the destination and fold it
61+
# into the merge commit. The script leaves the changelog untouched when unsure.
62+
preserve_source_changelog_history() {
63+
local source_branch="$1"
64+
65+
node "$SCRIPT_DIRECTORY/merge-previous-release-changelog.mts" \
66+
"origin/${source_branch}" \
67+
"${source_branch#release/}"
68+
69+
if ! git diff --quiet -- CHANGELOG.md; then
70+
git_exec add -- CHANGELOG.md
71+
git_exec commit --amend --no-edit
72+
fi
73+
}
74+
5875
# Merge a source branch (older release branch) into the current branch (new release branch),
59-
# always keeping destination content.
76+
# keeping destination code and then restoring the source's finalized changelog history.
6077
merge_with_favor_destination() {
6178
local source_branch="$1"
6279
local dest_branch="$2"
@@ -72,12 +89,14 @@ merge_with_favor_destination() {
7289
return 1 # Return 1 to indicate skipped
7390
fi
7491

75-
# -s ours: creates merge commit but keeps destination content entirely
92+
# -s ours records ancestry without duplicating code changes from the source.
7693
if ! git_exec merge -s ours "origin/${source_branch}" -m "Merge ${source_branch} into ${dest_branch}"; then
7794
echo "❌ Failed to merge ${source_branch}"
7895
exit 1
7996
fi
8097

98+
preserve_source_changelog_history "$source_branch"
99+
81100
echo "✅ Successfully merged ${source_branch} into ${dest_branch}"
82101
return 0 # Return 0 to indicate merged
83102
}

0 commit comments

Comments
 (0)