diff --git a/.eslintrc.js b/.eslintrc.js index 112e5826..7c44adad 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -33,6 +33,21 @@ module.exports = { files: ['*.test.ts', '*.test.js'], extends: ['@metamask/eslint-config-jest'], }, + + { + files: ['.github/scripts/**/*.mts'], + parserOptions: { + project: './tsconfig.json', + }, + }, + + { + files: ['.github/scripts/**/*.test.mts'], + rules: { + '@typescript-eslint/no-floating-promises': 'off', + 'n/no-process-env': 'off', + }, + }, ], ignorePatterns: [ diff --git a/.github/actions/merge-previous-releases/action.yml b/.github/actions/merge-previous-releases/action.yml index c87eca2b..31e029ca 100644 --- a/.github/actions/merge-previous-releases/action.yml +++ b/.github/actions/merge-previous-releases/action.yml @@ -34,6 +34,11 @@ runs: ref: ${{ inputs.github-tools-ref }} path: ./github-tools + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: ./github-tools/.nvmrc + - name: Set Git user and email shell: bash run: | diff --git a/.github/scripts/merge-previous-release-changelog.mts b/.github/scripts/merge-previous-release-changelog.mts new file mode 100644 index 00000000..e830eb25 --- /dev/null +++ b/.github/scripts/merge-previous-release-changelog.mts @@ -0,0 +1,266 @@ +/* + * Restores a finalized release's changelog history after `git merge -s ours`. + * + * `merge-previous-releases.sh` merges the previous release branch with the + * `ours` strategy, which records the ancestry but keeps the destination's + * files, so the finalized `## [version]` section and its `[version]: url` link + * reference from the source's CHANGELOG.md would be lost. This script copies + * them back in version order. + * + * Usage: node merge-previous-release-changelog.mts + * + * The changelog is edited as plain text, not through a changelog parser, so + * anything this script doesn't fully understand is left untouched. Every + * ambiguous or failing case warns and exits successfully, preserving the + * destination: a wrong automated edit to release history is worse than a + * missing one that a reviewer can add by hand. + */ +import { execFileSync } from 'node:child_process'; +import { readFileSync, writeFileSync } from 'node:fs'; + +// Any `[label]: url` line, including `[Unreleased]: url`. +const LINK_REFERENCE = /^\[[^\]]+\]: /mu; +// A release section ends at the next heading or at the link references. +const SECTION_END = /^(?:## \[|\[[^\]]+\]: )/mu; + +/** + * Compares two `major.minor.patch` versions. + * @param left - The first version. + * @param right - The second version. + * @returns A positive number when `left` is newer, negative when older, else 0. + */ +function compareVersions(left: string, right: string): number { + const leftParts = left.split('.').map(Number); + const rightParts = right.split('.').map(Number); + + for (let index = 0; index < leftParts.length; index += 1) { + const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0); + if (difference !== 0) { + return difference; + } + } + return 0; +} + +/** + * Escapes a string for literal use inside a regular expression. + * @param value - The string to escape. + * @returns The escaped string. + */ +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); +} + +/** + * Finds every `## [version]` heading for a version. + * @param changelog - The changelog contents. + * @param version - The release version. + * @returns All matching headings. + */ +function getReleaseHeadingMatches( + changelog: string, + version: string, +): RegExpMatchArray[] { + const heading = `## [${version}]`; + return [ + ...changelog.matchAll(new RegExp(`^${escapeRegex(heading)}$`, 'gmu')), + ]; +} + +/** + * Extracts the section for a release, if it appears exactly once. + * @param changelog - The changelog contents. + * @param version - The release version. + * @returns The section text, or undefined when missing or ambiguous. + */ +function getReleaseSection( + changelog: string, + version: string, +): string | undefined { + const matches = getReleaseHeadingMatches(changelog, version); + // A duplicated heading means we can't tell which copy is the real one. + if (matches.length !== 1 || matches[0]?.index === undefined) { + return undefined; + } + + const start = matches[0].index; + const bodyStart = start + matches[0][0].length; + const bodyEnd = changelog.slice(bodyStart).search(SECTION_END); + return changelog + .slice(start, bodyEnd === -1 ? undefined : bodyStart + bodyEnd) + .trimEnd(); +} + +/** + * Finds the `[version]: url` link reference for a release. + * @param changelog - The changelog contents. + * @param version - The release version. + * @returns The reference line, or undefined when missing or ambiguous. + */ +function getReleaseReference( + changelog: string, + version: string, +): string | undefined { + const matches = [ + ...changelog.matchAll( + new RegExp(`^\\[${escapeRegex(version)}\\]: .+$`, 'gmu'), + ), + ]; + return matches.length === 1 ? matches[0]?.[0] : undefined; +} + +/** + * Finds where a version belongs in descending version order. + * @param changelog - The changelog contents. + * @param version - The version being inserted. + * @param pattern - A global regex whose first group captures a version. + * @returns The index of the first older entry, or undefined if none. + */ +function findInsertionIndex( + changelog: string, + version: string, + pattern: RegExp, +): number | undefined { + // Entries are newest-first, so the first older entry marks the insert point. + for (const match of changelog.matchAll(pattern)) { + const candidate = match[1]; + if ( + candidate && + match.index !== undefined && + compareVersions(version, candidate) > 0 + ) { + return match.index; + } + } + return undefined; +} + +/** + * Inserts a release section in version order. + * @param changelog - The destination changelog contents. + * @param section - The section to insert. + * @param version - The release version. + * @param lineEnding - The destination's line ending. + * @returns The updated changelog. + */ +function insertReleaseSection( + changelog: string, + section: string, + version: string, + lineEnding: string, +): string { + const index = findInsertionIndex( + changelog, + version, + /^## \[(\d+\.\d+\.\d+)\]$/gmu, + ); + // `git show` returns committed (LF) text, which can differ from the checkout. + const normalizedSection = section.replaceAll('\n', lineEnding); + + // Nothing older exists, so the section goes last, above the link references. + if (index === undefined) { + const referencesIndex = changelog.search(LINK_REFERENCE); + const beforeReferences = + referencesIndex === -1 ? changelog.length : referencesIndex; + return `${changelog.slice(0, beforeReferences).trimEnd()}${lineEnding}${lineEnding}${normalizedSection}${lineEnding}${lineEnding}${changelog.slice(beforeReferences)}`; + } + + return `${changelog.slice(0, index)}${normalizedSection}${lineEnding}${lineEnding}${changelog.slice(index)}`; +} + +/** + * Inserts a release link reference in version order, if not already present. + * @param changelog - The destination changelog contents. + * @param reference - The reference line to insert. + * @param version - The release version. + * @param lineEnding - The destination's line ending. + * @returns The updated changelog. + */ +function insertReleaseReference( + changelog: string, + reference: string | undefined, + version: string, + lineEnding: string, +): string { + // Skip when the source has no reference, or when one already exists so the + // destination's own link wins. + if (!reference || getReleaseReference(changelog, version)) { + return changelog; + } + + const index = findInsertionIndex( + changelog, + version, + /^\[(\d+\.\d+\.\d+)\]: /gmu, + ); + if (index === undefined) { + return `${changelog.trimEnd()}${lineEnding}${reference}${lineEnding}`; + } + return `${changelog.slice(0, index)}${reference}${lineEnding}${changelog.slice(index)}`; +} + +/** + * Copies a finalized release section from a source branch's changelog into + * the destination changelog, preserving the destination on any ambiguity. + */ +function main(): void { + const [sourceRef, version] = process.argv.slice(2); + if (!sourceRef || !version) { + throw new Error( + 'Usage: merge-previous-release-changelog.mts ', + ); + } + + const destination = readFileSync('CHANGELOG.md', 'utf8'); + // Mobile's changelog is larger than the 1 MiB default buffer. + const source = execFileSync('git', ['show', `${sourceRef}:CHANGELOG.md`], { + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + }); + const section = getReleaseSection(source, version); + + // Only a finalized release has a versioned section; an unreleased or + // duplicated one is not safe to copy. + if (!section) { + console.warn( + `Source changelog has no unambiguous finalized ${version} section; preserving destination changelog.`, + ); + return; + } + // The destination already records this release, so there is nothing to + // restore and inserting would duplicate the heading. + if (getReleaseHeadingMatches(destination, version).length > 0) { + console.warn( + `Destination already has a ${version} section; preserving destination changelog.`, + ); + return; + } + + // Match the destination so the diff doesn't rewrite every line ending. + const lineEnding = destination.includes('\r\n') ? '\r\n' : '\n'; + const withSection = insertReleaseSection( + destination, + section, + version, + lineEnding, + ); + const merged = insertReleaseReference( + withSection, + getReleaseReference(source, version), + version, + lineEnding, + ); + writeFileSync('CHANGELOG.md', merged); + console.log( + `Inserted finalized ${version} changelog history from ${sourceRef}.`, + ); +} + +// Never fail the release merge over changelog history; see the header comment. +try { + main(); +} catch (error) { + console.warn( + `Unable to merge source changelog history; preserving destination changelog: ${error instanceof Error ? error.message : String(error)}`, + ); +} diff --git a/.github/scripts/merge-previous-releases.sh b/.github/scripts/merge-previous-releases.sh index 4bc2fb43..8fd0da01 100644 --- a/.github/scripts/merge-previous-releases.sh +++ b/.github/scripts/merge-previous-releases.sh @@ -8,8 +8,8 @@ # # Key behaviors: # - Merges older active RCs into the new release branch -# - Always resets to destination (new RC) content after merge, preventing -# git from creating duplicate content when both branches have the same changes +# - Uses an ancestry-only merge to prevent duplicate code content, while +# preserving a finalized source release's changelog history # - Both branches remain open after merge # - Fails fast on errors to prevent pushing partial merges # @@ -19,6 +19,8 @@ set -e +SCRIPT_DIRECTORY="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + # Parse a release branch name to extract version components # Returns: "major minor patch" or empty string if not valid parse_release_version() { @@ -55,8 +57,23 @@ is_branch_merged() { git merge-base --is-ancestor "origin/${source_branch}" HEAD 2>/dev/null } +# Copy the source's finalized changelog section into the destination and fold it +# into the merge commit. The script leaves the changelog untouched when unsure. +preserve_source_changelog_history() { + local source_branch="$1" + + node "$SCRIPT_DIRECTORY/merge-previous-release-changelog.mts" \ + "origin/${source_branch}" \ + "${source_branch#release/}" + + if ! git diff --quiet -- CHANGELOG.md; then + git_exec add -- CHANGELOG.md + git_exec commit --amend --no-edit + fi +} + # Merge a source branch (older release branch) into the current branch (new release branch), -# always keeping destination content. +# keeping destination code and then restoring the source's finalized changelog history. merge_with_favor_destination() { local source_branch="$1" local dest_branch="$2" @@ -72,12 +89,14 @@ merge_with_favor_destination() { return 1 # Return 1 to indicate skipped fi - # -s ours: creates merge commit but keeps destination content entirely + # -s ours records ancestry without duplicating code changes from the source. if ! git_exec merge -s ours "origin/${source_branch}" -m "Merge ${source_branch} into ${dest_branch}"; then echo "❌ Failed to merge ${source_branch}" exit 1 fi + preserve_source_changelog_history "$source_branch" + echo "✅ Successfully merged ${source_branch} into ${dest_branch}" return 0 # Return 0 to indicate merged } diff --git a/.github/scripts/tests/merge-previous-releases.test.mts b/.github/scripts/tests/merge-previous-releases.test.mts new file mode 100644 index 00000000..7e37d4dc --- /dev/null +++ b/.github/scripts/tests/merge-previous-releases.test.mts @@ -0,0 +1,196 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { + chmodSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { delimiter, join } from 'node:path'; +import test, { after } from 'node:test'; + +const SCRIPT_PATH = join( + process.cwd(), + '.github', + 'scripts', + 'merge-previous-releases.sh', +); + +const TEMPORARY_DIRECTORY = mkdtempSync( + join(tmpdir(), 'previous-release-merge-'), +); +after(() => rmSync(TEMPORARY_DIRECTORY, { force: true, recursive: true })); + +// CI runners have no global git identity, and the script commits. +const GIT_IDENTITY = { + GIT_AUTHOR_NAME: 'Test User', + GIT_AUTHOR_EMAIL: 'test@example.com', + GIT_COMMITTER_NAME: 'Test User', + GIT_COMMITTER_EMAIL: 'test@example.com', +}; + +/** + * Runs a git command and returns its trimmed output. + * @param directory - The working directory. + * @param argumentsList - The git arguments. + * @returns The trimmed stdout. + */ +function git(directory: string, ...argumentsList: string[]): string { + return execFileSync('git', argumentsList, { + cwd: directory, + encoding: 'utf8', + env: { ...process.env, ...GIT_IDENTITY }, + }).trim(); +} + +/** + * Creates an origin repository with a `main`, an older active release, and a + * new release branch, plus a clone and a fake `gh` for running the script. + * @param changelogs - The changelogs for the two release branches. + * @param changelogs.sourceChangelog - The older release branch's changelog. + * @param changelogs.destinationChangelog - The new release branch's changelog. + * @returns The clone directory and functions to run the script and read the + * resulting changelog. + */ +function createReleaseMergeFixture({ + sourceChangelog, + destinationChangelog, +}: { + sourceChangelog: string; + destinationChangelog: string; +}) { + const directory = mkdtempSync(join(TEMPORARY_DIRECTORY, 'fixture-')); + const originDirectory = join(directory, 'origin'); + const workDirectory = join(directory, 'work'); + const binDirectory = join(directory, 'bin'); + + git(directory, 'init', '--initial-branch', 'main', originDirectory); + writeFileSync( + join(originDirectory, 'CHANGELOG.md'), + '# Changelog\n\n## [13.53.0]\n\n- Existing entry (#1)\n', + ); + git(originDirectory, 'add', 'CHANGELOG.md'); + git(originDirectory, 'commit', '-m', 'Initial changelog'); + + for (const [branch, changelog] of Object.entries({ + 'release/13.54.0': sourceChangelog, + 'release/13.55.0': destinationChangelog, + })) { + git(originDirectory, 'switch', '-c', branch, 'main'); + writeFileSync(join(originDirectory, 'CHANGELOG.md'), changelog); + git(originDirectory, 'commit', '-am', `Update ${branch} changelog`); + } + // Leave `main` checked out so the script can push the release branch. + git(originDirectory, 'switch', 'main'); + + // Check out LF on Windows too, matching the Linux runners. + git( + directory, + 'clone', + '-c', + 'core.autocrlf=false', + '--branch', + 'release/13.55.0', + originDirectory, + workDirectory, + ); + + mkdirSync(binDirectory); + const ghPath = join(binDirectory, 'gh'); + writeFileSync( + ghPath, + '#!/usr/bin/env bash\nprintf "%s\\n" release/13.54.0\n', + ); + chmodSync(ghPath, 0o755); + + return { + workDirectory, + run: () => + execFileSync('bash', [SCRIPT_PATH], { + cwd: workDirectory, + env: { + ...process.env, + ...GIT_IDENTITY, + NEW_RELEASE_BRANCH: 'release/13.55.0', + GITHUB_TOKEN: 'test-token', + PATH: `${binDirectory}${delimiter}${process.env.PATH ?? ''}`, + }, + stdio: 'pipe', + }), + readChangelog: () => + readFileSync(join(workDirectory, 'CHANGELOG.md'), 'utf8'), + }; +} + +test('preserves changelog history from an older active release branch', () => { + // 13.54.0 is the source's oldest section, so it ends at the link references. + const fixture = createReleaseMergeFixture({ + sourceChangelog: + '# Changelog\n\n## [Unreleased]\n\n## [13.54.0]\n\n- Released entry (#2)\n\n[Unreleased]: https://example.com/HEAD\n[13.54.0]: https://example.com/13.54.0\n', + destinationChangelog: + '# Changelog\n\n## [Unreleased]\n\n## [13.53.0]\n\n- Destination-only entry (#3)\n\n[Unreleased]: https://example.com/HEAD\n[13.53.0]: https://example.com/13.53.0\n', + }); + + fixture.run(); + + assert.equal( + fixture.readChangelog(), + '# Changelog\n\n## [Unreleased]\n\n## [13.54.0]\n\n- Released entry (#2)\n\n## [13.53.0]\n\n- Destination-only entry (#3)\n\n[Unreleased]: https://example.com/HEAD\n[13.54.0]: https://example.com/13.54.0\n[13.53.0]: https://example.com/13.53.0\n', + ); + assert.doesNotThrow(() => + git( + fixture.workDirectory, + 'merge-base', + '--is-ancestor', + 'origin/release/13.54.0', + 'HEAD', + ), + ); +}); + +test('preserves changelog history when the source changelog exceeds the default process buffer', () => { + // 80,000 lines is about 1.3 MiB, above the 1 MiB default of execFileSync. + const padding = '- Padding entry (#5)\n'.repeat(80_000); + const fixture = createReleaseMergeFixture({ + sourceChangelog: `# Changelog\n\n## [13.54.0]\n\n- Released entry (#2)\n\n## [13.52.0]\n\n${padding}`, + destinationChangelog: + '# Changelog\n\n## [13.53.0]\n\n- Destination-only entry (#3)\n', + }); + + fixture.run(); + + assert.equal( + fixture.readChangelog(), + '# Changelog\n\n## [13.54.0]\n\n- Released entry (#2)\n\n## [13.53.0]\n\n- Destination-only entry (#3)\n', + ); +}); + +test('preserves destination changelog when the source section is ambiguous', () => { + const destinationChangelog = + '# Changelog\n\n## [13.53.0]\n\n- Destination-only entry (#3)\n'; + const fixture = createReleaseMergeFixture({ + sourceChangelog: + '# Changelog\n\n## [13.54.0]\n\n- First entry (#2)\n\n## [13.54.0]\n\n- Duplicate entry (#4)\n', + destinationChangelog, + }); + + fixture.run(); + + assert.equal(fixture.readChangelog(), destinationChangelog); +}); + +test('preserves destination changelog when it already has the release section', () => { + const destinationChangelog = + '# Changelog\n\n## [13.54.0]\n\n- Destination entry (#3)\n'; + const fixture = createReleaseMergeFixture({ + sourceChangelog: '# Changelog\n\n## [13.54.0]\n\n- Released entry (#2)\n', + destinationChangelog, + }); + + fixture.run(); + + assert.equal(fixture.readChangelog(), destinationChangelog); +}); diff --git a/.nvmrc b/.nvmrc index 9a2a0e21..a9276c4b 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v20 +v22.23.4 diff --git a/constraints.pro b/constraints.pro index 2ee08737..dd6cb7cf 100644 --- a/constraints.pro +++ b/constraints.pro @@ -24,4 +24,4 @@ gen_enforced_dependency(WorkspaceCwd, DependencyIdent, null, DependencyType) :- DependencyType == 'devDependencies'. % The package must specify a minimum Node version of 20. -gen_enforced_field(WorkspaceCwd, 'engines.node', '>=20.0.0'). +gen_enforced_field(WorkspaceCwd, 'engines.node', '>=22.18.0'). diff --git a/package.json b/package.json index 715f0a3e..aa3fe5b4 100644 --- a/package.json +++ b/package.json @@ -16,13 +16,13 @@ "lint:constraints": "yarn constraints", "lint:changelog": "auto-changelog validate --prettier", "lint:dependencies": "depcheck && yarn dedupe", - "lint:eslint": "eslint . --cache --ext js,ts,mts && eslint --no-ignore --cache --ext mjs .github/actions/draft-segment-schema-pr", + "lint:eslint": "eslint . --cache --ext js,ts,mts && eslint --no-ignore --cache --ext mjs .github/actions/draft-segment-schema-pr '.github/scripts/**/*.mts'", "lint:fix": "yarn lint:eslint --fix && yarn lint:constraints --fix && yarn lint:misc --write && yarn lint:dependencies", "lint:misc": "prettier '**/*.json' '**/*.md' '**/*.yml' '!.yarnrc.yml' '!.cursor/**' --ignore-path .gitignore --no-error-on-unmatched-pattern", "lint:tsc": "tsc", "segment-schema:draft-pr": "ts-node src/segment-schema-draft-pr/cli.ts", "slack:release-testing": "node .github/scripts/slack-release-testing.mjs", - "test": "jest && node --test .github/actions/draft-segment-schema-pr/gate.test.mjs && jest-it-up", + "test": "jest && node --test '.github/**/*.test.{mjs,mts}' && jest-it-up", "test:watch": "jest --watch", "update-release-sheet": "node .github/scripts/update-release-sheet.mjs" }, @@ -80,7 +80,7 @@ }, "packageManager": "yarn@4.14.1", "engines": { - "node": ">=20.0.0" + "node": ">=22.18.0" }, "lavamoat": { "allowScripts": { diff --git a/tsconfig.json b/tsconfig.json index 25251915..032d9536 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,5 +13,6 @@ "strict": true, "target": "es2020" }, + "include": ["src", ".github/scripts/**/*.mts"], "exclude": ["./dist/**/*"] }