|
| 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 | +} |
0 commit comments