From 9194aeaeb76d237f52a5bad49d1a19368dd88189 Mon Sep 17 00:00:00 2001 From: Howard Braham Date: Mon, 26 Oct 2026 12:02:40 -0700 Subject: [PATCH] feat(release/auto-changelog): update release changelogs deterministically from a predecessor baseline Replace update-release-changelog.sh with a TypeScript updater that builds the release section from the predecessor release's changelog plus the new PRs, keeps reviewer edits on an open changelog PR, and sends only new entries to the clean-room proofreader. --- .github/actions/create-release-pr/action.yml | 29 +- .../update-release-changelog/action.yml | 62 +- .github/scripts/clean-release-changelog.mts | 3 +- .github/scripts/create-platform-release-pr.sh | 71 +- .../merge-previous-release-changelog.mts | 22 +- .github/scripts/release-section.mts | 434 +++++ .github/scripts/release-version-utils.mts | 94 + .../scripts/tests/release-section.test.mts | 559 ++++++ .../tests/release-version-utils.test.mts | 53 + ...update-release-changelog-baseline.test.mts | 237 +++ .../update-release-changelog-git.test.mts | 588 ++++++ .../tests/update-release-changelog.test.mts | 587 ++++++ .../update-release-changelog-baseline.mts | 134 ++ .github/scripts/update-release-changelog.mts | 1677 +++++++++++++++++ .github/scripts/update-release-changelog.sh | 185 +- package.json | 2 +- yarn.lock | 106 +- 17 files changed, 4553 insertions(+), 290 deletions(-) create mode 100644 .github/scripts/release-version-utils.mts create mode 100644 .github/scripts/tests/release-version-utils.test.mts create mode 100644 .github/scripts/tests/update-release-changelog-baseline.test.mts create mode 100644 .github/scripts/tests/update-release-changelog-git.test.mts create mode 100644 .github/scripts/tests/update-release-changelog.test.mts create mode 100644 .github/scripts/update-release-changelog-baseline.mts create mode 100644 .github/scripts/update-release-changelog.mts mode change 100755 => 100644 .github/scripts/update-release-changelog.sh diff --git a/.github/actions/create-release-pr/action.yml b/.github/actions/create-release-pr/action.yml index 7d9c5dab..c3554b13 100644 --- a/.github/actions/create-release-pr/action.yml +++ b/.github/actions/create-release-pr/action.yml @@ -48,14 +48,6 @@ inputs: google-application-creds-base64: description: 'Google application credentials base64 encoded.' required: true - github-tools-repository: - description: 'The GitHub repository containing the GitHub tools. Defaults to the GitHub tools action repositor, and usually does not need to be changed.' - required: false - default: ${{ github.action_repository }} - github-tools-ref: - description: 'The SHA of the action to use. Defaults to the current action ref, and usually does not need to be changed.' - required: false - default: ${{ github.action_ref }} runs: using: composite @@ -68,21 +60,13 @@ runs: ref: ${{ inputs.checkout-base-branch }} token: ${{ inputs.github-token }} - # Step 2: Checkout github-tools repository - - name: Checkout github-tools repository - uses: actions/checkout@v6 - with: - repository: ${{ inputs.github-tools-repository }} - ref: ${{ inputs.github-tools-ref }} - path: github-tools - - # Step 3: Setup environment + # Step 2: Setup environment - name: Checkout and setup environment uses: MetaMask/action-checkout-and-setup@v3 with: is-high-risk-environment: true - # Step 4: Print Input Values + # Step 3: Print Input Values - name: Print Input Values env: PLATFORM: ${{ inputs.platform }} @@ -117,7 +101,7 @@ runs: echo "Git User Email: $GIT_USER_EMAIL" echo "-------------" - # Step 5: Create Release PR + # Step 4: Create Release PR - name: Create Release PR id: create-release-pr shell: bash @@ -137,10 +121,11 @@ runs: MOBILE_BUILD_VERSION: ${{ inputs.mobile-build-version }} GIT_USER_NAME: ${{ inputs.git-user-name }} GIT_USER_EMAIL: ${{ inputs.git-user-email }} + GITHUB_TOOLS_DIR: ${{ github.action_path }}/../../.. working-directory: ${{ github.workspace }} run: | - # Execute the script from github-tools - ./github-tools/.github/scripts/create-platform-release-pr.sh \ + # Use the action revision GitHub resolved for this run. + "$GITHUB_TOOLS_DIR/.github/scripts/create-platform-release-pr.sh" \ "$PLATFORM" \ "$PREVIOUS_VERSION_REF" \ "$SEMVER_VERSION" \ @@ -148,7 +133,7 @@ runs: "$GIT_USER_NAME" \ "$GIT_USER_EMAIL" - # Step 6: Upload commits.csv as artifact (if generated) + # Step 5: Upload commits.csv as artifact (if generated) - name: Upload commits.csv artifact if: ${{ hashFiles('commits.csv') != '' }} uses: actions/upload-artifact@v7 diff --git a/.github/actions/update-release-changelog/action.yml b/.github/actions/update-release-changelog/action.yml index 0b618be3..d5003fa2 100644 --- a/.github/actions/update-release-changelog/action.yml +++ b/.github/actions/update-release-changelog/action.yml @@ -12,21 +12,9 @@ inputs: repository-url: required: true description: 'Full HTTPS URL for the invoking repository.' - previous-version-ref: - required: false - default: 'null' - description: 'Previous release version reference (branch/tag/SHA). Use "null" for hotfixes.' github-token: required: true description: 'GitHub token with write access to the invoking repository and read access to planning resources.' - github-tools-repository: - description: 'The GitHub repository containing the GitHub tools. Defaults to the GitHub tools action repositor, and usually does not need to be changed.' - required: false - default: ${{ github.action_repository }} - github-tools-ref: - description: 'The SHA of the action to use. Defaults to the current action ref, and usually does not need to be changed.' - required: false - default: ${{ github.action_ref }} runs: using: composite @@ -34,26 +22,32 @@ runs: # Step 1: Checkout invoking repository (metamask-mobile | metamask-extension) - name: Checkout invoking repository uses: actions/checkout@v6 + env: + AI_ANALYZER_LITELLM_KEY: '' with: fetch-depth: 0 ref: ${{ inputs.release-branch }} token: ${{ inputs.github-token }} - # Step 2: Checkout github-tools repository - - name: Checkout GitHub tools repository - uses: actions/checkout@v6 + - name: Set up Node 24 for release changelog automation + uses: actions/setup-node@v6 + env: + AI_ANALYZER_LITELLM_KEY: '' with: - repository: ${{ inputs.github-tools-repository }} - ref: ${{ inputs.github-tools-ref }} - path: ./github-tools + node-version: '24' - # Step 3: Setup environment - - name: Checkout and setup environment - uses: MetaMask/action-checkout-and-setup@v3 - with: - is-high-risk-environment: true + # Corepack resolves Yarn from the working directory, so install from the + # github-tools root to use its own pinned Yarn and lockfile. + - name: Install github-tools dependencies + shell: bash + working-directory: ${{ github.action_path }}/../../.. + env: + AI_ANALYZER_LITELLM_KEY: '' + run: | + set -euo pipefail + corepack enable + corepack yarn install --immutable - # Step 4: Update changelog using shared helper script - name: Update changelog branch shell: bash env: @@ -63,15 +57,21 @@ runs: RELEASE_BRANCH: ${{ inputs.release-branch }} PLATFORM: ${{ inputs.platform }} REPOSITORY_URL: ${{ inputs.repository-url }} - PREVIOUS_VERSION_REF: ${{ inputs.previous-version-ref }} + AUTO_CHANGELOG_CLI: ${{ github.action_path }}/../../../node_modules/@metamask/auto-changelog/dist/cli.js run: | set -euo pipefail - corepack enable - yarn install --immutable - - ./github-tools/.github/scripts/update-release-changelog.sh \ + node "$GITHUB_ACTION_PATH/../../scripts/update-release-changelog.mts" \ "$RELEASE_BRANCH" \ "$PLATFORM" \ - "$REPOSITORY_URL" \ - "$PREVIOUS_VERSION_REF" + "$REPOSITORY_URL" + + - name: Upload clean-room proofread report + if: ${{ always() && hashFiles('.tmp/release-changelog-ai/*.json') != '' }} + uses: actions/upload-artifact@v7 + env: + AI_ANALYZER_LITELLM_KEY: '' + with: + name: release-changelog-ai-report + path: .tmp/release-changelog-ai/*.json + if-no-files-found: ignore diff --git a/.github/scripts/clean-release-changelog.mts b/.github/scripts/clean-release-changelog.mts index 6e9daad9..85686b4e 100644 --- a/.github/scripts/clean-release-changelog.mts +++ b/.github/scripts/clean-release-changelog.mts @@ -270,7 +270,8 @@ function assertNotAdjacentListItems(line: string): void { /** * Groups a model response's entries by category, rejecting stray Markdown. - * It is deliberately strict because the input is untrusted model output. + * This is stricter than `parseReleaseSectionCategories` because the input is + * untrusted model output, not auto-changelog's own format. * * @param section - One chunk's cleaned section. * @param version - The release version. diff --git a/.github/scripts/create-platform-release-pr.sh b/.github/scripts/create-platform-release-pr.sh index 4d864806..0a2b4588 100755 --- a/.github/scripts/create-platform-release-pr.sh +++ b/.github/scripts/create-platform-release-pr.sh @@ -2,10 +2,10 @@ # Script to create platform release PRs for MetaMask # This script handles the creation of release PRs for both mobile and extension platforms -# It creates three PRs: +# It creates two PRs: # 1. A release PR with version updates -# 2. A changelog PR with updated changelog and test plan (skipped in test mode) -# 3. A version bump PR for the main branch +# 2. A version bump PR for the main branch (skipped for hotfix releases) +# The changelog PR is created separately by the release-branch push workflow. # # Usage: # create-platform-release-pr.sh [new_version_number] [git_user_name] [git_user_email] @@ -21,8 +21,7 @@ # Environment: # BASE_BRANCH - The base branch for the release PR (defaults to 'main') # GITHUB_TOKEN - Token for GitHub CLI operations (falls back to gh auth config) -# TEST_ONLY - When set to "true", uses test branch prefixes and skips changelog PR -# GITHUB_REPOSITORY_URL - Full HTTPS URL for the repository (required for changelog generation when TEST_ONLY is not "true") +# TEST_ONLY - When set to "true", uses test branch prefixes and skips test plan generation set -e set -u @@ -30,6 +29,7 @@ set -o pipefail # Sourcing helper functions SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +GITHUB_TOOLS_DIR="${GITHUB_TOOLS_DIR:-$(cd "${SCRIPT_DIR}/../.." && pwd)}" # shellcheck source=.github/scripts/utils.sh source "${SCRIPT_DIR}/utils.sh" @@ -145,7 +145,6 @@ create_release_pr() { local new_version="$2" local new_version_number="$3" local release_branch_name="$4" - local changelog_branch_name="$5" echo "Checking out the release branch: ${release_branch_name}" checkout_or_create_branch "${release_branch_name}" "${BASE_BRANCH}" @@ -156,7 +155,7 @@ create_release_pr() { # Version Updates echo "Running version update scripts.." - ./github-tools/.github/scripts/set-semvar-version.sh "${new_version}" "${platform}" + "${GITHUB_TOOLS_DIR}/.github/scripts/set-semvar-version.sh" "${new_version}" "${platform}" # Commit Changes local changed_files @@ -251,13 +250,12 @@ Many thanks in advance create_pr_if_not_exists "${release_branch_name}" "release: ${new_version}" "${release_body}" "${BASE_BRANCH}" "" "head" } -# Create changelog branch and generate changelog -create_changelog_pr() { +# Generate the release test plan. The release-branch push workflow publishes +# the changelog separately so this script never races it for the same branch. +generate_release_test_plan() { local platform="$1" - local new_version="$2" - local previous_version_ref="$3" - local release_branch_name="$4" - local changelog_branch_name="$5" + local previous_version_ref="$2" + local release_branch_name="$3" # Skip commits.csv for hotfix releases (previous_version_ref is literal "null") # - When we create a new major/minor release, we fetch all commits included in the release, by fetching the diff between HEAD and previous version reference. @@ -265,7 +263,7 @@ create_changelog_pr() { if [[ "${previous_version_ref,,}" == "null" ]]; then echo "Hotfix release detected (previous-version-ref is 'null'); skipping commits.csv generation." else - # Need to run from .github-tools context to inherit it's dependencies/environment + # Generate the CSV with GitHub Tools' own dependencies, not the consumer's. echo "Current Directory: $(pwd)" PROJECT_GIT_DIR=$(pwd) @@ -290,37 +288,18 @@ create_changelog_pr() { echo "Previous version is not a recognized release branch pattern. Treating as tag or SHA: ${previous_version_ref}" fi - # Switch to github-tools directory - cd ./github-tools/ + # Switch to the immutable action source directory. + cd "${GITHUB_TOOLS_DIR}" ls -ltra corepack prepare yarn@4.14.1 --activate # This can't be done from the actions context layer due to the upstream repository having it's own context set with yarn - yarn --cwd install + yarn install echo "Generating test plan csv.." yarn run gen:commits "${platform}" "${DIFF_BASE}" "${release_branch_name}" "${PROJECT_GIT_DIR}" - # Return to project root after generating commits.csv - cd ../ + # The action checkout is not nested under the consumer repository. + cd "${PROJECT_GIT_DIR}" fi - - # Delegate changelog update and PR creation to the shared update-release-changelog.sh script - echo "Updating changelog and creating PR.." - - # Export git identity for the shared script - export GIT_AUTHOR_NAME="${GIT_USER_NAME}" - export GIT_AUTHOR_EMAIL="${GIT_USER_EMAIL}" - - # Call the shared script - # The script is located in the same directory as this one - "${SCRIPT_DIR}/update-release-changelog.sh" \ - "${release_branch_name}" \ - "${platform}" \ - "${GITHUB_REPOSITORY_URL}" \ - "${previous_version_ref}" \ - "${changelog_branch_name}" \ - "${new_version}" - - echo "Changelog PR Ready" } # Create version bump PR for main branch @@ -339,7 +318,7 @@ create_version_bump_pr() { # Update version files on main branch echo "Running version update scripts for ${main_branch} branch.." - ./github-tools/.github/scripts/set-semvar-version.sh "${next_version}" "${platform}" + "${GITHUB_TOOLS_DIR}/.github/scripts/set-semvar-version.sh" "${next_version}" "${platform}" # Commit version bump changes echo "Committing version bump changes.." @@ -436,20 +415,20 @@ main() { next_version=$(get_next_version "$NEW_VERSION") # Initialize branch names - local release_branch_name changelog_branch_name version_bump_branch_name + local release_branch_name version_bump_branch_name release_branch_name=$(get_release_branch_name "$NEW_VERSION") - changelog_branch_name="release-changelog/${NEW_VERSION}" version_bump_branch_name=$(get_version_bump_branch_name "$next_version") # Execute main workflow configure_git "${GIT_USER_NAME}" "${GIT_USER_EMAIL}" # Step 1: Create release branch and PR - create_release_pr "$PLATFORM" "$NEW_VERSION" "$NEW_VERSION_NUMBER" "$release_branch_name" "$changelog_branch_name" + create_release_pr "$PLATFORM" "$NEW_VERSION" "$NEW_VERSION_NUMBER" "$release_branch_name" - # Step 2: Create changelog PR (skip in test mode) + # Step 2: The release-branch push workflow owns changelog publication. + # Test-plan generation is still needed by release creation in production. if [ "$TEST_ONLY" == "true" ]; then - echo "Skipping changelog generation in test mode" + echo "Skipping test plan generation in test mode" else - create_changelog_pr "$PLATFORM" "$NEW_VERSION" "$PREVIOUS_VERSION_REF" "$release_branch_name" "$changelog_branch_name" + generate_release_test_plan "$PLATFORM" "$PREVIOUS_VERSION_REF" "$release_branch_name" fi # Step 3: Create version bump PR for main branch (skip for hotfix releases) @@ -467,7 +446,7 @@ main() { echo "Created PRs:" echo "1. Release PR: release: ${NEW_VERSION}" if [ "$TEST_ONLY" != "true" ]; then - echo "2. Changelog PR: release: ${changelog_branch_name}" + echo "2. Changelog PR: generated by the release-branch workflow" if [[ "${PREVIOUS_VERSION_REF,,}" == "null" ]]; then echo "(Hotfix) Skipped version bump PR" else diff --git a/.github/scripts/merge-previous-release-changelog.mts b/.github/scripts/merge-previous-release-changelog.mts index bf547fa8..b7113d57 100644 --- a/.github/scripts/merge-previous-release-changelog.mts +++ b/.github/scripts/merge-previous-release-changelog.mts @@ -25,25 +25,7 @@ import { hasSingleReleaseHeading, LINK_REFERENCE, } from './release-section.mts'; - -/** - * 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; -} +import { compareSemver } from './release-version-utils.mts'; /** * Finds the `[version]: url` link reference for a release. @@ -81,7 +63,7 @@ function findInsertionIndex( if ( candidate && match.index !== undefined && - compareVersions(version, candidate) > 0 + compareSemver(version, candidate) > 0 ) { return match.index; } diff --git a/.github/scripts/release-section.mts b/.github/scripts/release-section.mts index c7f362b2..ca243614 100644 --- a/.github/scripts/release-section.mts +++ b/.github/scripts/release-section.mts @@ -10,6 +10,10 @@ export const CHANGE_CATEGORIES = [ 'Security', ] as const; +// Must match the order auto-changelog writes categories in +// (`orderedChangeCategories` in packages/auto-changelog/src/constants.ts). +export const CATEGORY_ORDER = ['Uncategorized', ...CHANGE_CATEGORIES]; + export type ReleaseSection = { section: string; start: number; @@ -99,6 +103,16 @@ export function splitLines(text: string): string[] { return text.split(/\r?\n/u); } +/** + * Finds the first line of every changelog entry in a section. + * + * @param section - A release section. + * @returns The lines starting with `- `. + */ +export function getEntryLines(section: string): string[] { + return section.match(/^- .+$/gmu) ?? []; +} + /** * Finds a release's section and where it sits in the changelog. * @@ -169,6 +183,43 @@ export function getEntryPrNumbers(entry: string): string[] { ); } +/** + * Rewrites this repository's long PR links in each entry's first line into the + * short `(#123)` form, as auto-changelog does when it regenerates an entry. + * Links to other repositories stay, because they are not this repository's PRs. + * + * @param section - The section text. + * @param repositoryUrl - The repository's GitHub URL. + * @returns The section with this repository's PR links shortened. + */ +export function normalizeRepositoryPrLinks( + section: string, + repositoryUrl: string, +): string { + const repositoryName = new URL(repositoryUrl).pathname + .split('/') + .filter(Boolean) + .pop() + ?.replace(/\.git$/u, ''); + if (!repositoryName) { + return section; + } + + // Same shape as auto-changelog's: one group of long links, or legacy + // adjacent groups separated by commas. + const escapedName = repositoryName.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); + const longGroup = `\\(\\s*(?:\\[#\\d+\\]\\([^)]*${escapedName}[^)]*\\)\\s*,?\\s*)+\\)`; + const groups = new RegExp(`\\s+${longGroup}(?:\\s*,\\s*${longGroup})*`, 'gu'); + return section.replace(/^- .*$/gmu, (line) => + line.replace(groups, (group) => { + const prNumbers = [...group.matchAll(/\[#(\d+)\]/gu)].flatMap( + ([, prNumber]) => (prNumber ? [`#${prNumber}`] : []), + ); + return ` (${prNumbers.join(', ')})`; + }), + ); +} + /** * Collects the PR numbers of every entry in a section. * @@ -190,3 +241,386 @@ export function getPrNumbers(section: string): string[] { } return entries.flatMap((entry) => getEntryPrNumbers(entry.join('\n'))); } + +/** + * Puts a release section into a changelog, replacing the release's existing + * section or adding it above the newest release. + * + * @param options - Options. + * @param options.changelog - The changelog contents. + * @param options.currentReleaseSection - The section to write. + * @param options.version - The release version. + * @returns The updated changelog. + */ +export function mergeCurrentReleaseSection({ + changelog, + currentReleaseSection, + version, +}: { + changelog: string; + currentReleaseSection: string; + version: string; +}): string { + if (changelog.includes(releaseHeading(version))) { + return replaceReleaseSection(changelog, version, currentReleaseSection); + } + + if (!/^# Changelog\r?\n/u.test(changelog)) { + throw new Error('CHANGELOG.md must begin with a Changelog heading'); + } + + // Releases follow the preamble and `## [Unreleased]`, newest first. + const firstRelease = /^## \[(?!Unreleased\])/mu.exec(changelog); + const lineEnding = detectLineEnding(changelog); + const normalizedSection = currentReleaseSection.replace( + /\r?\n/gu, + lineEnding, + ); + if (!firstRelease) { + return `${changelog.trimEnd()}${lineEnding}${lineEnding}${normalizedSection}${lineEnding}`; + } + return `${changelog.slice(0, firstRelease.index)}${normalizedSection}${lineEnding}${lineEnding}${changelog.slice(firstRelease.index)}`; +} + +type ReleaseSectionEntry = { + lines: string[]; + prNumbers: string[]; +}; + +type ReleaseSectionCategory = { + heading: string; + entries: ReleaseSectionEntry[]; +}; + +/** + * Splits a release section into its `###` categories and their entries, + * including each entry's continuation lines. + * + * @param section - A release section. + * @returns The categories in file order. + * @throws If an entry has no category or the section has other content. + */ +function parseReleaseSectionCategories( + section: string, +): ReleaseSectionCategory[] { + const [, ...lines] = splitLines(section); + const categories: ReleaseSectionCategory[] = []; + let category: ReleaseSectionCategory | undefined; + let entry: ReleaseSectionEntry | undefined; + + for (const line of lines) { + if (line.startsWith('### ')) { + category = { heading: line, entries: [] }; + categories.push(category); + entry = undefined; + } else if (line.startsWith('- ')) { + if (!category) { + throw new Error('Release entries must appear under a category heading'); + } + entry = { lines: [line], prNumbers: getPrNumbers(line) }; + category.entries.push(entry); + } else if (line !== '' && entry) { + entry.lines.push(line); + entry.prNumbers = getPrNumbers(entry.lines.join('\n')); + } else if (line !== '') { + throw new Error(`Unexpected release section content: ${line}`); + } + } + + return categories; +} + +const PR_REFERENCE_PATTERN = /\(#(\d+)\)|#(\d+)/gu; + +/** + * Reads the PRs of an entry's owning reference group: the last group on its + * first line, where the generator puts the entry's own PRs. + * + * @param firstLine - The entry's first line. + * @returns The owning PR numbers, or none when the line has no group. + */ +function getOwningPrNumbers(firstLine: string): string[] { + const [group] = [...firstLine.matchAll(PR_REFERENCE_GROUPS)].pop() ?? []; + return [...(group ?? '').matchAll(/#(\d+)/gu)].flatMap(([, prNumber]) => + prNumber ? [prNumber] : [], + ); +} + +/** + * Rewrites an entry's PR references so only the owning group stays linked. + * The generator appends the entry's own PRs last, as one group such as + * `(#123, #124)`, so earlier references are prose. + * + * @param lines - The entry's lines. + * @returns The lines with other references turned into plain `PR 123` text. + */ +function keepOnlyOwningReference(lines: string[]): string[] { + const [firstLine = '', ...otherLines] = lines; + const owningGroup = [...firstLine.matchAll(PR_REFERENCE_GROUPS)].pop(); + const keepStart = owningGroup?.index ?? 0; + const keepEnd = keepStart + (owningGroup?.[0].length ?? 0); + const demote = (text: string, isKept: (offset: number) => boolean): string => + text.replace( + PR_REFERENCE_PATTERN, + (match, parenthesized?: string, bare?: string, offset?: number) => + isKept(offset ?? -1) ? match : `PR ${parenthesized ?? bare ?? ''}`, + ); + return [ + demote(firstLine, (offset) => offset >= keepStart && offset < keepEnd), + ...otherLines.map((line) => demote(line, () => false)), + ]; +} + +/** + * Builds a section holding only the generated entries whose PR is not already + * in the existing section. Entries copied unchanged from the existing section + * are skipped first, so a reviewer's entry without a PR reference or with a + * repeated one does not fail the run. + * + * @param options - Options. + * @param options.existingSection - The release section to preserve. + * @param options.generatedSection - The section auto-changelog generated. + * @param options.version - The release version. + * @param options.repositoryUrl - The repository's GitHub URL. Long links to it + * in the existing section count as documented references. + * @param options.deletedPrNumbers - PRs an earlier run already generated that + * the existing section no longer lists, because a reviewer removed them. + * @returns The new-entries section, or undefined when nothing is new. + * @throws If a generated entry has no PR reference. + */ +export function getNewReleaseEntriesSection({ + existingSection, + generatedSection, + version, + repositoryUrl, + deletedPrNumbers, +}: { + existingSection: string; + generatedSection: string; + version: string; + repositoryUrl?: string; + deletedPrNumbers?: ReadonlySet; +}): string | undefined { + const existing = repositoryUrl + ? normalizeRepositoryPrLinks(existingSection, repositoryUrl) + : existingSection; + const existingPrNumbers = new Set(getPrNumbers(existing)); + const entryKey = (lines: string[]): string => + lines.map((line) => line.trimEnd()).join('\n'); + const existingEntries = new Set( + parseReleaseSectionCategories(existing).flatMap(({ entries }) => + entries.map(({ lines }) => entryKey(lines)), + ), + ); + const categories = parseReleaseSectionCategories(generatedSection) + .map(({ heading, entries }) => ({ + heading, + entries: entries.flatMap(({ lines }) => { + if (existingEntries.has(entryKey(lines))) { + return []; + } + const owners = getOwningPrNumbers(lines[0] ?? ''); + if (owners.length === 0) { + throw new Error('Generated release entry lacks a PR reference'); + } + // A grouped entry covers all its PRs, so one documented PR means the + // reviewer already has an entry for it. + const isDocumented = owners.some( + (owner) => + existingPrNumbers.has(owner) || deletedPrNumbers?.has(owner), + ); + return isDocumented ? [] : [{ lines: keepOnlyOwningReference(lines) }]; + }), + })) + .filter(({ entries }) => entries.length > 0); + + if (categories.length === 0) { + return undefined; + } + + return [ + releaseHeading(version), + ...categories.flatMap(({ heading, entries }) => [ + '', + heading, + '', + ...entries.flatMap(({ lines }) => lines), + ]), + ].join('\n'); +} + +const CATEGORY_BY_LEADING_VERB = new Map([ + ['Added', '### Added'], + ['Changed', '### Changed'], + ['Updated', '### Changed'], + ['Deprecated', '### Deprecated'], + ['Removed', '### Removed'], + ['Fixed', '### Fixed'], +]); + +/** + * Finds the category an entry's leading verb implies. + * + * @param entryLine - The entry's first line. + * @returns The category heading, or undefined when the verb is not a known one. + */ +function getCategoryImpliedByWording(entryLine: string): string | undefined { + const [, verb] = /^- (\w+)\b/u.exec(entryLine) ?? []; + return verb ? CATEGORY_BY_LEADING_VERB.get(verb) : undefined; +} + +/** + * Files each AI-cleaned entry under the category its wording implies, so an + * entry reworded to "Fixed ..." is not left under Removed. Other entries go + * back under the category the generator chose, so proofreading cannot + * recategorize a PR. Entries that merge PRs from different generated + * categories, or that the generator left Uncategorized, keep the proofreader's + * category. + * + * @param options - Options. + * @param options.generatedSection - The new-entries section before proofreading. + * @param options.cleanedSection - The same section after proofreading. + * @param options.version - The release version. + * @returns The cleaned section with generator categories restored. + */ +export function restoreGeneratedCategories({ + generatedSection, + cleanedSection, + version, +}: { + generatedSection: string; + cleanedSection: string; + version: string; +}): string { + const generatedCategoryByPr = new Map(); + for (const { heading, entries } of parseReleaseSectionCategories( + generatedSection, + )) { + for (const entry of entries) { + for (const prNumber of entry.prNumbers) { + generatedCategoryByPr.set(prNumber, heading); + } + } + } + + const buckets = new Map(); + const order: string[] = []; + for (const { heading, entries } of parseReleaseSectionCategories( + cleanedSection, + )) { + for (const entry of entries) { + const generatedHeadings = new Set( + entry.prNumbers.map((prNumber) => generatedCategoryByPr.get(prNumber)), + ); + const [only] = [...generatedHeadings]; + const target = + getCategoryImpliedByWording(entry.lines[0] ?? '') ?? + (generatedHeadings.size === 1 && + only !== undefined && + only !== '### Uncategorized' + ? only + : heading); + if (!buckets.has(target)) { + buckets.set(target, []); + order.push(target); + } + buckets.get(target)?.push(...entry.lines); + } + } + + return [ + releaseHeading(version), + ...order + .sort((left, right) => getCategoryRank(left) - getCategoryRank(right)) + .flatMap((heading) => ['', heading, '', ...(buckets.get(heading) ?? [])]), + ].join('\n'); +} + +/** + * Ranks a `### Category` heading by auto-changelog's category order. + * + * @param heading - The category heading line. + * @returns The position, or past the end for an unknown category. + */ +function getCategoryRank(heading: string): number { + const rank = CATEGORY_ORDER.indexOf(heading.slice('### '.length)); + return rank === -1 ? CATEGORY_ORDER.length : rank; +} + +/** + * Merges newly generated entries into an existing release section. Existing + * lines are never modified. New entries go at the top of a matching category, + * as auto-changelog does, and a missing category is inserted in canonical + * order. + * + * @param options - Options. + * @param options.existingSection - The release section to preserve. + * @param options.newEntriesSection - A release section holding only new entries. + * @param options.version - The release version. + * @returns The merged release section. + */ +export function mergeNewReleaseEntries({ + existingSection, + newEntriesSection, + version, +}: { + existingSection: string; + newEntriesSection: string; + version: string; +}): string { + const heading = releaseHeading(version); + if (!newEntriesSection.startsWith(heading)) { + throw new Error(`Expected new entries section to begin with ${heading}`); + } + + const lineEnding = detectLineEnding(existingSection); + const lines = splitLines(existingSection.trimEnd()); + + for (const { + heading: categoryHeading, + entries, + } of parseReleaseSectionCategories(newEntriesSection)) { + const entryLines = entries.flatMap((entry) => entry.lines); + if (entryLines.length === 0) { + continue; + } + + const headingIndex = lines.indexOf(categoryHeading); + if (headingIndex !== -1) { + // Skip the blank line(s) after the heading so entries lead the list. + let insertAt = headingIndex + 1; + while (insertAt < lines.length && lines[insertAt] === '') { + insertAt += 1; + } + const isEmptyCategory = + insertAt >= lines.length || lines[insertAt]?.startsWith('### '); + lines.splice( + insertAt, + 0, + ...(insertAt === headingIndex + 1 ? [''] : []), + ...entryLines, + ...(isEmptyCategory && insertAt < lines.length ? [''] : []), + ); + continue; + } + + const rank = getCategoryRank(categoryHeading); + const nextCategoryIndex = lines.findIndex( + (line) => line.startsWith('### ') && getCategoryRank(line) > rank, + ); + if (nextCategoryIndex === -1) { + lines.push('', categoryHeading, '', ...entryLines); + } else { + lines.splice( + nextCategoryIndex, + 0, + categoryHeading, + '', + ...entryLines, + '', + ); + } + } + + return lines.join(lineEnding); +} diff --git a/.github/scripts/release-version-utils.mts b/.github/scripts/release-version-utils.mts new file mode 100644 index 00000000..d651ef3e --- /dev/null +++ b/.github/scripts/release-version-utils.mts @@ -0,0 +1,94 @@ +export type ReleaseBranchKind = 'native' | 'ota'; + +export type ReleaseBranch = { + version: string; + kind: ReleaseBranchKind; + name: string; +}; + +const SEMVER_PATTERN = /^(\d+)\.(\d+)\.(\d+)$/u; + +/** + * Parses a `release/X.Y.Z` or `release/X.Y.Z-ota` branch name. + * + * @param branchName - The branch name without the `origin/` prefix. + * @returns The parsed branch, or `undefined` when the name is not a release + * branch. + */ +export function parseReleaseBranch( + branchName: string, +): ReleaseBranch | undefined { + const match = /^release\/(\d+\.\d+\.\d+)(-ota)?$/u.exec(branchName); + if (!match) { + return undefined; + } + + const version = match[1]; + if (!version) { + return undefined; + } + + return { + version, + kind: match[2] ? 'ota' : 'native', + name: branchName, + }; +} + +/** + * Compares two `X.Y.Z` versions. + * + * @param left - The first version. + * @param right - The second version. + * @returns A negative number when `left` is lower, a positive number when it is + * higher, and 0 when they are equal. + */ +export function compareSemver(left: string, right: string): number { + const leftMatch = SEMVER_PATTERN.exec(left); + const rightMatch = SEMVER_PATTERN.exec(right); + + if (!leftMatch || !rightMatch) { + throw new Error( + `Expected semantic versions, received ${left} and ${right}`, + ); + } + + for (let index = 1; index <= 3; index += 1) { + const difference = Number(leftMatch[index]) - Number(rightMatch[index]); + if (difference !== 0) { + return difference; + } + } + + return 0; +} + +/** + * Picks the highest candidate version that is lower than the target. + * + * @param targetVersion - The version the result must be lower than. + * @param candidateVersions - The versions to choose from. + * @returns The highest lower version, or `undefined` when none is lower. + */ +export function selectHighestLowerVersion( + targetVersion: string, + candidateVersions: readonly string[], +): string | undefined { + return candidateVersions.reduce( + (selectedVersion, candidateVersion) => { + if (compareSemver(candidateVersion, targetVersion) >= 0) { + return selectedVersion; + } + + if ( + !selectedVersion || + compareSemver(candidateVersion, selectedVersion) > 0 + ) { + return candidateVersion; + } + + return selectedVersion; + }, + undefined, + ); +} diff --git a/.github/scripts/tests/release-section.test.mts b/.github/scripts/tests/release-section.test.mts index 35b245c3..184a72e0 100644 --- a/.github/scripts/tests/release-section.test.mts +++ b/.github/scripts/tests/release-section.test.mts @@ -3,7 +3,12 @@ import test from 'node:test'; import { extractReleaseSection, + getNewReleaseEntriesSection, + mergeCurrentReleaseSection, + mergeNewReleaseEntries, + normalizeRepositoryPrLinks, replaceReleaseSection, + restoreGeneratedCategories, } from '../release-section.mts'; test('finds the heading line even when its text appears earlier', () => { @@ -25,6 +30,560 @@ test('writes a replacement in the changelog line endings', () => { ); }); +test('isolates newly generated entries without changing the existing release section', () => { + const existingSection = `## [13.51.0] + +### Added + +- Added an existing feature (#101) + +### Fixed + +- Fixed an existing issue (#102)`; + const generatedSection = `## [13.51.0] + +### Added + +- Reworded existing feature (#101) +- Added a new feature (#103) + +### Fixed + +- Reworded existing issue (#102)`; + + const newEntriesSection = getNewReleaseEntriesSection({ + existingSection, + generatedSection, + version: '13.51.0', + }); + + assert.equal( + newEntriesSection, + `## [13.51.0] + +### Added + +- Added a new feature (#103)`, + ); + assert.equal( + mergeNewReleaseEntries({ + existingSection, + newEntriesSection: newEntriesSection ?? '', + version: '13.51.0', + }), + `## [13.51.0] + +### Added + +- Added a new feature (#103) +- Added an existing feature (#101) + +### Fixed + +- Fixed an existing issue (#102)`, + ); +}); + +test('merges new entries into matching categories and inserts missing ones in canonical order', () => { + const existingSection = `## [13.51.0] + +### Added + +- Added an existing feature (#101) + +### Fixed + +- Fixed an existing issue (#102) + with a continuation line`; + + assert.equal( + mergeNewReleaseEntries({ + existingSection, + newEntriesSection: `## [13.51.0] + +### Changed + +- Changed a thing (#103) + +### Fixed + +- Fixed a new issue (#104) + +### Security + +- Hardened a thing (#105)`, + version: '13.51.0', + }), + `## [13.51.0] + +### Added + +- Added an existing feature (#101) + +### Changed + +- Changed a thing (#103) + +### Fixed + +- Fixed a new issue (#104) +- Fixed an existing issue (#102) + with a continuation line + +### Security + +- Hardened a thing (#105)`, + ); +}); + +test('restores the generator category when the proofread wording has no known verb', () => { + assert.equal( + restoreGeneratedCategories({ + generatedSection: `## [1.0.0]\n\n### Added\n\n- Added a (#1)\n\n### Fixed\n\n- Fixed b (#2)\n\n### Uncategorized\n\n- Did c (#3)\n\n### Fixed\n\n- Fixed d (#4)`, + cleanedSection: `## [1.0.0]\n\n### Changed\n\n- Improved a (#1)\n- Improved c (#3)\n- Merged d and b (#2, #4)`, + version: '1.0.0', + }), + `## [1.0.0]\n\n### Added\n\n- Improved a (#1)\n\n### Changed\n\n- Improved c (#3)\n\n### Fixed\n\n- Merged d and b (#2, #4)`, + ); +}); + +test('files an entry under the category its wording implies', () => { + assert.equal( + restoreGeneratedCategories({ + generatedSection: `## [1.0.0]\n\n### Removed\n\n- Removed a bar (#1)\n\n### Fixed\n\n- Fixed a crash (#2)`, + cleanedSection: `## [1.0.0]\n\n### Removed\n\n- Fixed the bar layout (#1)\n\n### Fixed\n\n- Added support for X (#2)`, + version: '1.0.0', + }), + `## [1.0.0]\n\n### Added\n\n- Added support for X (#2)\n\n### Fixed\n\n- Fixed the bar layout (#1)`, + ); +}); + +test("shortens only this repository's long PR links", () => { + const repositoryUrl = 'https://github.com/MetaMask/metamask-mobile'; + assert.equal( + normalizeRepositoryPrLinks( + [ + '- Own link ([#1](https://github.com/MetaMask/metamask-mobile/pull/1))', + '- Two links ([#2](https://github.com/MetaMask/metamask-mobile/pull/2), [#3](https://github.com/MetaMask/metamask-mobile/pull/3))', + '- Legacy groups ([#4](https://github.com/MetaMask/metamask-mobile/pull/4)), ([#5](https://github.com/MetaMask/metamask-mobile/pull/5))', + '- Other repo ([#6](https://github.com/MetaMask/core/pull/6))', + '- Already short (#7)', + ].join('\r\n'), + repositoryUrl, + ), + [ + '- Own link (#1)', + '- Two links (#2, #3)', + '- Legacy groups (#4, #5)', + '- Other repo ([#6](https://github.com/MetaMask/core/pull/6))', + '- Already short (#7)', + ].join('\r\n'), + ); +}); + +test('treats a long-link entry in the existing section as documented', () => { + assert.equal( + getNewReleaseEntriesSection({ + existingSection: `## [1.0.0]\n\n### Added\n\n- Added a ([#1](https://github.com/MetaMask/metamask-mobile/pull/1))`, + generatedSection: `## [1.0.0]\n\n### Added\n\n- Added a (#1)\n- Added b (#2)`, + version: '1.0.0', + repositoryUrl: 'https://github.com/MetaMask/metamask-mobile', + }), + `## [1.0.0]\n\n### Added\n\n- Added b (#2)`, + ); +}); + +test('matches an existing entry that has trailing whitespace', () => { + assert.equal( + getNewReleaseEntriesSection({ + existingSection: '## [1.0.0]\n\n### Added\n\n- Added a \n', + generatedSection: '## [1.0.0]\n\n### Added\n\n- Added a\n- Added b (#2)', + version: '1.0.0', + }), + '## [1.0.0]\n\n### Added\n\n- Added b (#2)', + ); +}); + +test('leaves out a PR a reviewer deleted', () => { + assert.equal( + getNewReleaseEntriesSection({ + existingSection: '## [1.0.0]\n\n### Added\n\n- Added a (#1)', + generatedSection: + '## [1.0.0]\n\n### Added\n\n- Added a (#1)\n- Added b (#2)\n- Added c (#3)', + version: '1.0.0', + deletedPrNumbers: new Set(['2']), + }), + '## [1.0.0]\n\n### Added\n\n- Added c (#3)', + ); +}); + +test('keeps the existing line ending when merging new entries', () => { + assert.equal( + mergeNewReleaseEntries({ + existingSection: '## [1.0.0]\r\n\r\n### Fixed\r\n\r\n- Old (#1)\r\n', + newEntriesSection: '## [1.0.0]\n\n### Added\n\n- New (#2)', + version: '1.0.0', + }), + '## [1.0.0]\r\n\r\n### Added\r\n\r\n- New (#2)\r\n\r\n### Fixed\r\n\r\n- Old (#1)', + ); +}); + +test('finds no new release entries when every generated PR is already documented', () => { + assert.equal( + getNewReleaseEntriesSection({ + existingSection: `## [13.51.0] + +### Fixed + +- Fixed an existing issue (#101)`, + generatedSection: `## [13.51.0] + +### Fixed + +- Reworded existing issue (#101)`, + version: '13.51.0', + }), + undefined, + ); +}); + +test('judges a generated entry by its last PR reference and demotes earlier mentions', () => { + const existingSection = `## [13.51.0] + +### Fixed + +- Fixed an existing issue (#101)`; + + assert.equal( + getNewReleaseEntriesSection({ + existingSection, + generatedSection: `## [13.51.0] + +### Fixed + +- Fixed a regression from (#101) and #100 (#102) + see also #101`, + version: '13.51.0', + }), + `## [13.51.0] + +### Fixed + +- Fixed a regression from PR 101 and PR 100 (#102) + see also PR 101`, + ); + assert.equal( + getNewReleaseEntriesSection({ + existingSection, + generatedSection: `## [13.51.0] + +### Fixed + +- Reworded a documented PR that mentions (#102) (#101)`, + version: '13.51.0', + }), + undefined, + ); +}); + +test('keeps a multi-PR group intact and demotes mentions before it', () => { + const existingSection = `## [13.51.0] + +### Fixed + +- Fixed an existing issue (#101)`; + const generatedSection = (entry: string) => + `## [13.51.0] + +### Fixed + +${entry}`; + + assert.equal( + getNewReleaseEntriesSection({ + existingSection, + generatedSection: generatedSection( + '- Fixed a regression from #100 (#102, #103)', + ), + version: '13.51.0', + }), + generatedSection('- Fixed a regression from PR 100 (#102, #103)'), + ); + assert.equal( + getNewReleaseEntriesSection({ + existingSection, + generatedSection: generatedSection( + '- Fixed legacy groups (#102), (#103)', + ), + version: '13.51.0', + }), + generatedSection('- Fixed legacy groups (#102), (#103)'), + ); +}); + +test('skips a grouped generated entry when any PR in its group is documented', () => { + const existingSection = `## [13.51.0]\n\n### Fixed\n\n- Fixed an existing issue (#101)`; + + assert.equal( + getNewReleaseEntriesSection({ + existingSection, + generatedSection: `## [13.51.0]\n\n### Fixed\n\n- Fixed an issue (#101, #102)`, + version: '13.51.0', + }), + undefined, + ); + assert.equal( + getNewReleaseEntriesSection({ + existingSection, + generatedSection: `## [13.51.0]\n\n### Fixed\n\n- Fixed an issue (#102, #103)`, + version: '13.51.0', + deletedPrNumbers: new Set(['103']), + }), + undefined, + ); +}); + +test('preserves the reviewed release section above historical predecessor history', () => { + assert.equal( + mergeCurrentReleaseSection({ + changelog: `# Changelog + +## [13.50.0] + +### Fixed + +- Fixed an earlier issue (#100) +`, + currentReleaseSection: `## [13.51.0] + +### Added + +- Added a current release feature (#101)`, + version: '13.51.0', + }), + `# Changelog + +## [13.51.0] + +### Added + +- Added a current release feature (#101) + +## [13.50.0] + +### Fixed + +- Fixed an earlier issue (#100) +`, + ); +}); + +test('inserts the reviewed release section after the preamble and Unreleased heading', () => { + assert.equal( + mergeCurrentReleaseSection({ + changelog: `# Changelog + +All notable changes to this project will be documented in this file. + +## [Unreleased] + +## [13.50.0] + +### Fixed + +- Fixed an earlier issue (#100) +`, + currentReleaseSection: `## [13.51.0] + +### Added + +- Added a current release feature (#101)`, + version: '13.51.0', + }), + `# Changelog + +All notable changes to this project will be documented in this file. + +## [Unreleased] + +## [13.51.0] + +### Added + +- Added a current release feature (#101) + +## [13.50.0] + +### Fixed + +- Fixed an earlier issue (#100) +`, + ); +}); + +const stableShapedChangelog = `# 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] + +## [13.50.0] + +### Fixed + +- Fixed an earlier issue ([#100](https://github.com/MetaMask/metamask-extension/pull/100)) + +## [13.49.0] + +### Added + +- Added an older feature ([#90](https://github.com/MetaMask/metamask-extension/pull/90)) + +[Unreleased]: https://github.com/MetaMask/metamask-extension/compare/v13.50.0...HEAD +[13.50.0]: https://github.com/MetaMask/metamask-extension/compare/v13.49.0...v13.50.0 +[13.49.0]: https://github.com/MetaMask/metamask-extension/releases/tag/v13.49.0 +`; + +const reviewedSection = `## [13.51.0] + +### Added + +- Added a current release feature (#101)`; + +test('merges into a stable-shaped changelog without disturbing history or link references', () => { + const merged = mergeCurrentReleaseSection({ + changelog: stableShapedChangelog, + currentReleaseSection: reviewedSection, + version: '13.51.0', + }); + + assert.equal( + merged, + stableShapedChangelog.replace( + '## [13.50.0]', + `${reviewedSection}\n\n## [13.50.0]`, + ), + ); + assert.equal( + extractReleaseSection(merged, '13.51.0').section, + reviewedSection, + ); + assert.ok(merged.indexOf('## [Unreleased]') < merged.indexOf('## [13.51.0]')); + assert.ok(merged.indexOf('## [13.51.0]') < merged.indexOf('## [13.50.0]')); +}); + +test('keeps CRLF line endings when merging into a CRLF changelog', () => { + const merged = mergeCurrentReleaseSection({ + changelog: stableShapedChangelog.replaceAll('\n', '\r\n'), + currentReleaseSection: reviewedSection, + version: '13.51.0', + }); + + assert.equal(merged.replaceAll('\r\n', '').includes('\n'), false); + assert.ok(merged.includes('## [13.51.0]\r\n\r\n### Added')); +}); + +test('replaces an existing current heading instead of duplicating it', () => { + const once = mergeCurrentReleaseSection({ + changelog: stableShapedChangelog, + currentReleaseSection: reviewedSection, + version: '13.51.0', + }); + const twice = mergeCurrentReleaseSection({ + changelog: once, + currentReleaseSection: reviewedSection.replace('(#101)', '(#102)'), + version: '13.51.0', + }); + + assert.equal(twice.match(/^## \[13\.51\.0\]$/gmu)?.length, 1); + assert.match(twice, /\(#102\)/u); + assert.doesNotMatch(twice, /\(#101\)/u); +}); + +test('appends the reviewed section when the changelog has no releases yet', () => { + const merged = mergeCurrentReleaseSection({ + changelog: '# Changelog\n\n## [Unreleased]\n', + currentReleaseSection: reviewedSection, + version: '13.51.0', + }); + + assert.equal( + merged, + `# Changelog\n\n## [Unreleased]\n\n${reviewedSection}\n`, + ); +}); + +test('does not double carriage returns when inserting a CRLF section into a CRLF changelog', () => { + const merged = mergeCurrentReleaseSection({ + changelog: '# Changelog\r\n\r\n## [Unreleased]\r\n\r\n## [13.50.0]\r\n', + currentReleaseSection: + '## [13.51.0]\r\n\r\n### Added\r\n\r\n- Added it (#1)', + version: '13.51.0', + }); + + assert.equal(merged.includes('\r\r'), false); + assert.equal( + merged, + '# Changelog\r\n\r\n## [Unreleased]\r\n\r\n## [13.51.0]\r\n\r\n### Added\r\n\r\n- Added it (#1)\r\n\r\n## [13.50.0]\r\n', + ); +}); + +test('keeps a reviewer entry without a PR reference out of the new entries', () => { + const existingSection = [ + '## [1.0.0]', + '', + '### Added', + '', + '- Updated translations', + '- Added a thing (#1)', + '', + '### Fixed', + '', + '- Fixed a thing (#1)', + ].join('\n'); + const generatedSection = existingSection.replace( + '- Added a thing (#1)', + '- Added a thing (#1)\n- Added another thing (#2)', + ); + + assert.equal( + getNewReleaseEntriesSection({ + existingSection, + generatedSection, + version: '1.0.0', + }), + '## [1.0.0]\n\n### Added\n\n- Added another thing (#2)', + ); +}); + +test('still rejects a generated entry without a PR reference', () => { + assert.throws( + () => + getNewReleaseEntriesSection({ + existingSection: '## [1.0.0]\n\n### Added\n\n- Added a thing (#1)', + generatedSection: + '## [1.0.0]\n\n### Added\n\n- Added a thing (#1)\n- Mystery entry', + version: '1.0.0', + }), + /lacks a PR reference/u, + ); +}); + +test('rejects a changelog without the Changelog heading', () => { + assert.throws( + () => + mergeCurrentReleaseSection({ + changelog: '## [13.50.0]\n', + currentReleaseSection: reviewedSection, + version: '13.51.0', + }), + /must begin with a Changelog heading/u, + ); +}); + test('ends the oldest release section before the link references', () => { const changelog = [ '# Changelog', diff --git a/.github/scripts/tests/release-version-utils.test.mts b/.github/scripts/tests/release-version-utils.test.mts new file mode 100644 index 00000000..272819bf --- /dev/null +++ b/.github/scripts/tests/release-version-utils.test.mts @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + compareSemver, + parseReleaseBranch, + selectHighestLowerVersion, +} from '../release-version-utils.mts'; + +test('parses supported native and OTA release branches', () => { + assert.deepEqual(parseReleaseBranch('release/13.50.3'), { + version: '13.50.3', + kind: 'native', + name: 'release/13.50.3', + }); + assert.deepEqual(parseReleaseBranch('release/13.50.3-ota'), { + version: '13.50.3', + kind: 'ota', + name: 'release/13.50.3-ota', + }); +}); + +test('rejects unsupported release branch names', () => { + assert.equal(parseReleaseBranch('release/13.50.3-fallback'), undefined); + assert.equal(parseReleaseBranch('release/13.50.3.1'), undefined); +}); + +test('compares all semantic version components numerically', () => { + assert.equal(compareSemver('13.50.3', '13.50.2'), 1); + assert.equal(compareSemver('13.50.3', '13.50.3'), 0); + assert.equal(compareSemver('13.50.3', '13.51.0'), -1); +}); + +test('selects the highest strict predecessor', () => { + assert.equal( + selectHighestLowerVersion('13.51.0', [ + '13.49.0', + '13.50.0', + '13.50.3', + '13.51.0', + '13.52.0', + ]), + '13.50.3', + ); + assert.equal( + selectHighestLowerVersion('13.50.4', ['13.50.2', '13.50.3']), + '13.50.3', + ); + assert.equal( + selectHighestLowerVersion('13.51.0', ['13.51.0', '13.51.1', '14.0.0']), + undefined, + ); +}); diff --git a/.github/scripts/tests/update-release-changelog-baseline.test.mts b/.github/scripts/tests/update-release-changelog-baseline.test.mts new file mode 100644 index 00000000..de0dca36 --- /dev/null +++ b/.github/scripts/tests/update-release-changelog-baseline.test.mts @@ -0,0 +1,237 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + removeNewerReleases, + selectChangelogBaseline, + type ChangelogBaselineDependencies, +} from '../update-release-changelog-baseline.mts'; + +/** + * Builds fake Git lookups for the baseline tests. + * + * @param options0 - The options. + * @param options0.headings - The `ref:version` pairs that have a heading. + * @param options0.stableVersions - The versions in the `stable` changelog. + * @param options0.releaseBranches - The release branches on origin. + * @returns The fake dependencies. + */ +function createDependencies({ + headings = new Set(), + stableVersions = [], + releaseBranches = [], +}: { + headings?: Set; + stableVersions?: string[]; + releaseBranches?: { version: string; kind: 'native' | 'ota'; name: string }[]; +}): ChangelogBaselineDependencies { + return { + hasReleaseHeading: (ref, version) => headings.has(`${ref}:${version}`), + getStableVersions: () => stableVersions, + getReleaseBranches: () => releaseBranches, + }; +} + +/** + * Selects the baseline for version 13.51.0 on its release branch. + * + * @param dependencies - The fake Git lookups. + * @returns The selected baseline. + */ +function selectBaseline(dependencies: ChangelogBaselineDependencies) { + return selectChangelogBaseline({ + version: '13.51.0', + releaseBranch: 'release/13.51.0', + dependencies, + }); +} + +test('uses the target release changelog after the changelog PR is merged', () => { + assert.deepEqual( + selectBaseline( + createDependencies({ + headings: new Set(['origin/release/13.51.0:13.51.0']), + }), + ), + { + kind: 'target', + ref: 'origin/release/13.51.0', + version: '13.51.0', + }, + ); +}); + +test('uses stable history even when an open changelog PR is reviewed', () => { + assert.deepEqual( + selectBaseline( + createDependencies({ + headings: new Set(['origin/stable:13.50.0']), + stableVersions: ['13.50.0'], + }), + ), + { + kind: 'stable', + ref: 'origin/stable', + version: '13.50.0', + }, + ); +}); + +test('prefers stable for the selected predecessor version', () => { + assert.deepEqual( + selectBaseline( + createDependencies({ + headings: new Set(['origin/stable:13.50.3']), + stableVersions: ['13.50.2', '13.50.3'], + releaseBranches: [ + { + version: '13.50.3', + kind: 'native', + name: 'release/13.50.3', + }, + ], + }), + ), + { kind: 'stable', ref: 'origin/stable', version: '13.50.3' }, + ); +}); + +test('uses the highest predecessor release branch when stable is behind', () => { + assert.deepEqual( + selectBaseline( + createDependencies({ + headings: new Set([ + 'origin/stable:13.50.2', + 'origin/release/13.50.3:13.50.3', + ]), + stableVersions: ['13.50.2'], + releaseBranches: [ + { + version: '13.50.2', + kind: 'native', + name: 'release/13.50.2', + }, + { + version: '13.50.3', + kind: 'native', + name: 'release/13.50.3', + }, + ], + }), + ), + { + kind: 'release', + ref: 'origin/release/13.50.3', + version: '13.50.3', + }, + ); +}); + +test('rejects a predecessor release branch whose changelog lacks its own section', () => { + assert.throws( + () => + selectBaseline( + createDependencies({ + headings: new Set(['origin/stable:13.50.2']), + stableVersions: ['13.50.2'], + releaseBranches: [ + { + version: '13.50.3', + kind: 'native', + name: 'release/13.50.3', + }, + ], + }), + ), + /origin\/release\/13\.50\.3 has no ## \[13\.50\.3\] changelog section/u, + ); +}); + +const stableChangelog = [ + '# Changelog', + '', + '## [Unreleased]', + '', + '## [13.51.0]', + '', + '### Added', + '', + '- Added c (#3)', + '', + '## [13.50.3]', + '', + '### Added', + '', + '- Added b (#2)', + '', + '[Unreleased]: https://example.com/compare/v13.51.0...HEAD', + '[13.51.0]: https://example.com/compare/v13.50.3...v13.51.0', + '[13.50.3]: https://example.com/releases/tag/v13.50.3', + '', +].join('\n'); + +test('drops releases newer than an older-line hotfix with their link references', () => { + assert.equal( + removeNewerReleases(stableChangelog, '13.50.4'), + [ + '# Changelog', + '', + '## [Unreleased]', + '', + '## [13.50.3]', + '', + '### Added', + '', + '- Added b (#2)', + '', + '[Unreleased]: https://example.com/compare/v13.51.0...HEAD', + '[13.50.3]: https://example.com/releases/tag/v13.50.3', + '', + ].join('\n'), + ); +}); + +test('leaves a changelog without newer releases byte for byte', () => { + assert.equal( + removeNewerReleases(stableChangelog, '13.51.0'), + stableChangelog, + ); + assert.equal(removeNewerReleases(stableChangelog, '14.0.0'), stableChangelog); +}); + +test('keeps CRLF line endings when dropping newer releases', () => { + const crlf = stableChangelog.replaceAll('\n', '\r\n'); + + assert.equal(removeNewerReleases(crlf, '13.50.4').includes('\r\r'), false); + assert.equal( + removeNewerReleases(crlf, '13.50.4').includes('13.51.0]'), + false, + ); + assert.equal( + removeNewerReleases(crlf, '13.50.4').split('\r\n').length, + removeNewerReleases(crlf, '13.50.4').split('\n').length, + ); +}); + +test('rejects ambiguous native and OTA predecessor branches', () => { + assert.throws( + () => + selectBaseline( + createDependencies({ + releaseBranches: [ + { + version: '13.50.3', + kind: 'native', + name: 'release/13.50.3', + }, + { + version: '13.50.3', + kind: 'ota', + name: 'release/13.50.3-ota', + }, + ], + }), + ), + /Expected exactly one release branch/u, + ); +}); diff --git a/.github/scripts/tests/update-release-changelog-git.test.mts b/.github/scripts/tests/update-release-changelog-git.test.mts new file mode 100644 index 00000000..62724e2f --- /dev/null +++ b/.github/scripts/tests/update-release-changelog-git.test.mts @@ -0,0 +1,588 @@ +import assert from 'node:assert/strict'; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, it } from 'node:test'; + +import { git } from './git-test-environment.mts'; +import { + assertRemoteRefUnchanged, + getRemoteBranchSha, + getChangelogBranchCommits, + hasSameChangelogAsRemoteBranch, + getReviewedCurrentReleaseSection, + pushChangelogBranch, + rebuildChangelogBranch, +} from '../update-release-changelog.mts'; + +const TARGET = 'release/13.51.0'; +const CHANGELOG_BRANCH = 'release-changelog/13.51.0'; +const STABLE_BASELINE = { + kind: 'stable' as const, + ref: 'origin/stable', + version: '13.50.0', +}; + +/** + * Commits files to a branch and force-pushes it to origin. + * + * @param cwd - The repository to commit in. + * @param branch - The branch to create or reset. + * @param files - The file contents by file name. + * @param options0 - The options. + * @param options0.from - A ref to branch from; when omitted, an orphan branch is + * created. + */ +function commitFiles( + cwd: string, + branch: string, + files: Record, + { from }: { from?: string } = {}, +): void { + if (from) { + git(cwd, 'checkout', '-B', branch, from); + } else { + git(cwd, 'checkout', '--orphan', branch); + git(cwd, 'rm', '-rf', '--ignore-unmatch', '.'); + } + for (const [name, content] of Object.entries(files)) { + writeFileSync(join(cwd, name), content); + git(cwd, 'add', name); + } + git(cwd, 'commit', '-m', `fixture ${branch}`); + git(cwd, 'push', '--force', 'origin', branch); +} + +const releaseChangelog = `# Changelog + +## [Unreleased] + +## [13.51.0] + +### Added + +- Added a reviewed feature (#101) + +## [13.49.0] + +### Fixed + +- Fixed an older issue (#90) +`; + +const stableChangelog = `# Changelog + +## [Unreleased] + +## [13.50.0] + +### Fixed + +- Fixed the predecessor issue (#100) +`; + +describe('release changelog Git behavior', () => { + let rootDirectory: string; + let seed: string; + let work: string; + const originalCwd = process.cwd(); + + beforeEach(() => { + rootDirectory = mkdtempSync(join(tmpdir(), 'release-changelog-test-')); + const origin = join(rootDirectory, 'origin.git'); + seed = join(rootDirectory, 'seed'); + work = join(rootDirectory, 'work'); + mkdirSync(seed); + git(rootDirectory, 'init', '--bare', '-b', 'main', origin); + git(seed, 'init', '-b', 'main'); + git(seed, 'remote', 'add', 'origin', origin); + + commitFiles(seed, 'main', { + 'CHANGELOG.md': 'main\n', + 'code.txt': 'main\n', + }); + commitFiles( + seed, + TARGET, + { + 'CHANGELOG.md': '# Changelog\n\n## [Unreleased]\n', + 'code.txt': 'target\n', + }, + { from: 'main' }, + ); + commitFiles( + seed, + 'stable', + { 'CHANGELOG.md': stableChangelog, 'code.txt': 'stable\n' }, + { from: 'main' }, + ); + commitFiles( + seed, + CHANGELOG_BRANCH, + { 'CHANGELOG.md': releaseChangelog, 'code.txt': 'stale\n' }, + { from: TARGET }, + ); + commitFiles( + seed, + 'no-changelog', + { 'code.txt': 'none\n' }, + { from: 'main' }, + ); + git(seed, 'rm', '-q', 'CHANGELOG.md'); + git(seed, 'commit', '-m', 'drop changelog'); + git(seed, 'push', '--force', 'origin', 'no-changelog'); + + git(rootDirectory, 'clone', '-c', 'core.autocrlf=false', origin, work); + process.chdir(work); + }); + + afterEach(() => { + process.chdir(originalCwd); + rmSync(rootDirectory, { force: true, maxRetries: 5, recursive: true }); + }); + + describe('rebuildChangelogBranch', () => { + it('starts at the target tip and restores only CHANGELOG.md from the baseline', () => { + rebuildChangelogBranch({ + changelogBranch: CHANGELOG_BRANCH, + releaseBranch: TARGET, + baseline: STABLE_BASELINE, + }); + + assert.equal(git(work, 'branch', '--show-current'), CHANGELOG_BRANCH); + assert.equal( + git(work, 'rev-parse', 'HEAD'), + git(work, 'rev-parse', `origin/${TARGET}`), + ); + assert.equal( + readFileSync(join(work, 'CHANGELOG.md'), 'utf8'), + stableChangelog, + ); + assert.equal(readFileSync(join(work, 'code.txt'), 'utf8'), 'target\n'); + assert.equal( + git(work, 'status', '--porcelain', '--untracked-files=no'), + 'M CHANGELOG.md', + ); + }); + + it('discards stale code from an existing changelog branch', () => { + git( + work, + 'checkout', + '-B', + CHANGELOG_BRANCH, + `origin/${CHANGELOG_BRANCH}`, + ); + assert.equal(readFileSync(join(work, 'code.txt'), 'utf8'), 'stale\n'); + + rebuildChangelogBranch({ + changelogBranch: CHANGELOG_BRANCH, + releaseBranch: TARGET, + baseline: STABLE_BASELINE, + }); + + assert.equal(readFileSync(join(work, 'code.txt'), 'utf8'), 'target\n'); + }); + + it('does not leave the rebuilt branch tracking the remote', () => { + rebuildChangelogBranch({ + changelogBranch: CHANGELOG_BRANCH, + releaseBranch: TARGET, + baseline: STABLE_BASELINE, + }); + + assert.throws(() => + git(work, 'config', '--get', `branch.${CHANGELOG_BRANCH}.remote`), + ); + }); + + it('fails when the baseline has no CHANGELOG.md', () => { + assert.throws( + () => + rebuildChangelogBranch({ + changelogBranch: CHANGELOG_BRANCH, + releaseBranch: TARGET, + baseline: { + kind: 'release', + ref: 'origin/no-changelog', + version: '13.50.0', + }, + }), + /does not contain CHANGELOG\.md/u, + ); + }); + }); + + describe('getReviewedCurrentReleaseSection', () => { + const input = { + changelogBranch: CHANGELOG_BRANCH, + releaseBranch: TARGET, + version: '13.51.0', + }; + + it('returns undefined on a first run', () => { + assert.equal( + getReviewedCurrentReleaseSection({ + ...input, + changelogBranchSha: undefined, + getOpenPrNumber: () => { + throw new Error('must not query PRs on a first run'); + }, + }), + undefined, + ); + }); + + it('refuses a stale changelog branch that has no open PR', () => { + assert.throws( + () => + getReviewedCurrentReleaseSection({ + ...input, + changelogBranchSha: getRemoteBranchSha(CHANGELOG_BRANCH), + getOpenPrNumber: () => undefined, + }), + /exists without an open PR/u, + ); + }); + + it('regenerates a branch without an open PR when only the updater committed to it', () => { + assert.equal( + getReviewedCurrentReleaseSection({ + ...input, + changelogBranchSha: getRemoteBranchSha(CHANGELOG_BRANCH), + isUpdaterOnlyBranch: () => true, + getOpenPrNumber: () => undefined, + }), + undefined, + ); + }); + + it('strips trailing whitespace from a reviewed section', () => { + commitFiles( + seed, + CHANGELOG_BRANCH, + { + 'CHANGELOG.md': releaseChangelog.replace('(#101)', '(#101) '), + }, + { from: TARGET }, + ); + git(work, 'fetch', 'origin', CHANGELOG_BRANCH); + assert.doesNotMatch( + getReviewedCurrentReleaseSection({ + ...input, + changelogBranchSha: getRemoteBranchSha(CHANGELOG_BRANCH), + getOpenPrNumber: () => '7', + }) ?? '', + /[ \t]$/mu, + ); + }); + + it('ignores a branch without an open PR when the target already has the section', () => { + assert.equal( + getReviewedCurrentReleaseSection({ + ...input, + changelogBranchSha: getRemoteBranchSha(CHANGELOG_BRANCH), + allowMissingOpenPr: true, + getOpenPrNumber: () => undefined, + }), + undefined, + ); + }); + + it('still preserves an open PR when the target already has the section', () => { + git(work, 'fetch', 'origin', CHANGELOG_BRANCH); + assert.match( + getReviewedCurrentReleaseSection({ + ...input, + changelogBranchSha: getRemoteBranchSha(CHANGELOG_BRANCH), + allowMissingOpenPr: true, + getOpenPrNumber: () => '7', + }) ?? '', + /Added a reviewed feature \(#101\)/u, + ); + }); + + it('refuses an open changelog branch that lacks the current heading', () => { + assert.throws( + () => + getReviewedCurrentReleaseSection({ + ...input, + version: '13.52.0', + changelogBranchSha: getRemoteBranchSha(CHANGELOG_BRANCH), + getOpenPrNumber: () => '7', + }), + /lacks 13\.52\.0/u, + ); + }); + + it('returns the reviewed section from an open changelog branch', () => { + git(work, 'fetch', 'origin', CHANGELOG_BRANCH); + assert.equal( + getReviewedCurrentReleaseSection({ + ...input, + changelogBranchSha: getRemoteBranchSha(CHANGELOG_BRANCH), + getOpenPrNumber: (head, base) => { + assert.equal(head, CHANGELOG_BRANCH); + assert.equal(base, TARGET); + return '7'; + }, + }), + `## [13.51.0] + +### Added + +- Added a reviewed feature (#101)`, + ); + }); + }); + + describe('pushChangelogBranch', () => { + /** + * Creates a local changelog branch with one local commit. + */ + function startLocalBranch(): void { + git(work, 'checkout', '-B', CHANGELOG_BRANCH, `origin/${TARGET}`); + writeFileSync(join(work, 'CHANGELOG.md'), 'local\n'); + git(work, 'add', 'CHANGELOG.md'); + git(work, 'commit', '-m', 'local changelog'); + } + + it('rejects when the changelog branch moved after the lease was recorded', () => { + const leasedSha = getRemoteBranchSha(CHANGELOG_BRANCH); + git(seed, 'checkout', CHANGELOG_BRANCH); + writeFileSync(join(seed, 'CHANGELOG.md'), 'concurrent edit\n'); + git(seed, 'commit', '-am', 'concurrent edit'); + git(seed, 'push', 'origin', CHANGELOG_BRANCH); + const movedSha = getRemoteBranchSha(CHANGELOG_BRANCH); + startLocalBranch(); + + assert.throws( + () => + pushChangelogBranch({ + changelogBranch: CHANGELOG_BRANCH, + expectedRemoteSha: leasedSha, + }), + /changed after this run began/u, + ); + assert.equal(getRemoteBranchSha(CHANGELOG_BRANCH), movedSha); + }); + + it('rewrites the branch when the lease still matches', () => { + const leasedSha = getRemoteBranchSha(CHANGELOG_BRANCH); + startLocalBranch(); + + pushChangelogBranch({ + changelogBranch: CHANGELOG_BRANCH, + expectedRemoteSha: leasedSha, + }); + + assert.equal( + getRemoteBranchSha(CHANGELOG_BRANCH), + git(work, 'rev-parse', 'HEAD'), + ); + }); + + it('creates a new branch and then refuses a racing creation', () => { + const newBranch = 'release-changelog/99.0.0'; + git(work, 'checkout', '-B', newBranch, `origin/${TARGET}`); + writeFileSync(join(work, 'CHANGELOG.md'), 'new\n'); + git(work, 'add', 'CHANGELOG.md'); + git(work, 'commit', '-m', 'new changelog'); + + pushChangelogBranch({ + changelogBranch: newBranch, + expectedRemoteSha: undefined, + }); + assert.equal( + getRemoteBranchSha(newBranch), + git(work, 'rev-parse', 'HEAD'), + ); + + git(work, 'commit', '--allow-empty', '-m', 'second'); + assert.throws( + () => + pushChangelogBranch({ + changelogBranch: newBranch, + expectedRemoteSha: undefined, + }), + /may have been created concurrently/u, + ); + }); + }); + + describe('hasSameChangelogAsRemoteBranch', () => { + it('ignores other files that changed on the release branch', () => { + git(work, 'fetch', 'origin', CHANGELOG_BRANCH); + const remoteSha = getRemoteBranchSha(CHANGELOG_BRANCH) as string; + git( + work, + 'checkout', + '-B', + CHANGELOG_BRANCH, + `origin/${CHANGELOG_BRANCH}`, + ); + writeFileSync(join(work, 'code.txt'), 'release moved\n'); + git(work, 'commit', '-am', 'same changelog, other file moved'); + + assert.equal( + hasSameChangelogAsRemoteBranch(CHANGELOG_BRANCH, remoteSha), + true, + ); + }); + + it('recognizes a rebuilt commit with identical content', () => { + git(work, 'fetch', 'origin', CHANGELOG_BRANCH); + const remoteSha = getRemoteBranchSha(CHANGELOG_BRANCH) as string; + git( + work, + 'checkout', + '-B', + CHANGELOG_BRANCH, + `origin/${CHANGELOG_BRANCH}`, + ); + git(work, 'commit', '--amend', '-m', 'rebuilt with a new hash'); + + assert.notEqual(git(work, 'rev-parse', 'HEAD'), remoteSha); + assert.equal( + hasSameChangelogAsRemoteBranch(CHANGELOG_BRANCH, remoteSha), + true, + ); + }); + + it('rejects changed content and a stale expected tip', () => { + git(work, 'fetch', 'origin', CHANGELOG_BRANCH); + const remoteSha = getRemoteBranchSha(CHANGELOG_BRANCH) as string; + git( + work, + 'checkout', + '-B', + CHANGELOG_BRANCH, + `origin/${CHANGELOG_BRANCH}`, + ); + + assert.equal( + hasSameChangelogAsRemoteBranch(CHANGELOG_BRANCH, 'f'.repeat(40)), + false, + ); + + writeFileSync(join(work, 'CHANGELOG.md'), 'edited\n'); + git(work, 'commit', '-am', 'edit'); + assert.equal( + hasSameChangelogAsRemoteBranch(CHANGELOG_BRANCH, remoteSha), + false, + ); + }); + }); + + describe('getChangelogBranchCommits', () => { + /** + * Pushes a changelog branch commit made with the given message arguments. + * + * @param messageArguments - The `-m` arguments for the commit. + */ + function pushCommit(...messageArguments: string[]): void { + git(work, 'checkout', '-B', CHANGELOG_BRANCH, `origin/${TARGET}`); + writeFileSync(join(work, 'CHANGELOG.md'), 'changed\n'); + git(work, 'add', 'CHANGELOG.md'); + git(work, 'commit', ...messageArguments); + git(work, 'push', '--force', 'origin', CHANGELOG_BRANCH); + git(work, 'fetch', 'origin', CHANGELOG_BRANCH); + } + + it('reads the unproofread marker from the commit trailer', () => { + pushCommit( + '-m', + 'update changelog for 13.51.0', + '-m', + 'Changelog-Proofread: failed', + ); + + assert.deepEqual( + getChangelogBranchCommits(CHANGELOG_BRANCH, TARGET).map( + ({ subject, unproofread }) => ({ subject, unproofread }), + ), + [{ subject: 'update changelog for 13.51.0', unproofread: true }], + ); + }); + + it('reports no marker on a commit without the trailer', () => { + pushCommit('-m', 'update changelog for 13.51.0'); + + assert.deepEqual( + getChangelogBranchCommits(CHANGELOG_BRANCH, TARGET).map( + ({ unproofread }) => unproofread, + ), + [false], + ); + }); + + it('reads the release tip a run generated through, next to the other trailer', () => { + const tip = git(work, 'rev-parse', `origin/${TARGET}`); + pushCommit( + '-m', + 'update changelog for 13.51.0', + '-m', + `Changelog-Generated-Through: ${tip}\nChangelog-Proofread: failed`, + ); + + assert.deepEqual( + getChangelogBranchCommits(CHANGELOG_BRANCH, TARGET).map( + ({ generatedThrough, unproofread }) => ({ + generatedThrough, + unproofread, + }), + ), + [{ generatedThrough: tip, unproofread: true }], + ); + }); + + it('ignores a generated-through value that is not a commit SHA', () => { + pushCommit( + '-m', + 'update changelog for 13.51.0', + '-m', + 'Changelog-Generated-Through: not-a-sha', + ); + + assert.equal( + getChangelogBranchCommits(CHANGELOG_BRANCH, TARGET)[0] + ?.generatedThrough, + undefined, + ); + }); + }); + + describe('assertRemoteRefUnchanged', () => { + it('accepts an unchanged ref and rejects a moved one', () => { + const sha = getRemoteBranchSha(TARGET); + assert.ok(sha); + assert.doesNotThrow(() => + assertRemoteRefUnchanged({ + ref: `origin/${TARGET}`, + expectedSha: sha, + description: 'Target release branch', + }), + ); + + git(seed, 'checkout', TARGET); + git(seed, 'commit', '--allow-empty', '-m', 'move target'); + git(seed, 'push', 'origin', TARGET); + + assert.throws( + () => + assertRemoteRefUnchanged({ + ref: `origin/${TARGET}`, + expectedSha: sha, + description: 'Target release branch', + }), + /changed during changelog generation/u, + ); + }); + }); +}); diff --git a/.github/scripts/tests/update-release-changelog.test.mts b/.github/scripts/tests/update-release-changelog.test.mts new file mode 100644 index 00000000..b6929e3d --- /dev/null +++ b/.github/scripts/tests/update-release-changelog.test.mts @@ -0,0 +1,587 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { + assertReleaseSectionHasEntries, + findReleaseVersionsContainingPrs, + getAutoChangelogCli, + getAcceptedPrCommits, + getChangelogValidationArguments, + getChangelogPushArguments, + getDeletedPrNumbers, + getChangelogPrPresentation, + getHistoricalSkippedPrNumbers, + isReleaseSectionEmpty, + hasOnlyUpdaterCommits, + isUntouchedUnproofreadChangelogPr, + parseUpdateChangelogArguments, + shouldKeepChangelogPrPresentation, + validateGeneratedReleaseSection, + writeFailedProofreadingReport, +} from '../update-release-changelog.mts'; + +test('records the failure stage in the proofread report', () => { + const directory = mkdtempSync(join(tmpdir(), 'proofread-report-')); + try { + const reportPath = join(directory, 'nested', 'report.json'); + writeFailedProofreadingReport(reportPath, 'no key', 'authentication'); + assert.deepEqual(JSON.parse(readFileSync(reportPath, 'utf8')), { + status: 'failed', + stage: 'authentication', + error: 'no key', + }); + writeFailedProofreadingReport(reportPath, 'boom'); + assert.equal( + (JSON.parse(readFileSync(reportPath, 'utf8')) as { stage: string }).stage, + 'execution', + ); + } finally { + rmSync(directory, { force: true, recursive: true }); + } +}); + +test('collects historical skipped PRs from auto-changelog diagnostics', () => { + assert.deepEqual( + getHistoricalSkippedPrNumbers(`[auto-changelog] skipped pr=35695 reason=historical-pr +[auto-changelog] commit=abc accepted source-pr=1 canonical-pr=1 cherry-pick=false +[auto-changelog] skipped pr=35716 reason=historical-pr +[auto-changelog] skipped pr=35695 reason=historical-pr +[auto-changelog] skipped pr=9 reason=cherry-pick-duplicate`), + ['35695', '35716'], + ); + assert.deepEqual(getHistoricalSkippedPrNumbers(''), []); +}); + +const COMMIT_A = 'a'.repeat(40); +const COMMIT_B = 'b'.repeat(40); +const COMMIT_C = 'c'.repeat(40); + +test('collects the accepted commits of each canonical PR', () => { + assert.deepEqual( + [ + ...getAcceptedPrCommits(`[auto-changelog] commit=${COMMIT_A} accepted source-pr=1 canonical-pr=1 cherry-pick=false +[auto-changelog] commit=${COMMIT_B} accepted source-pr=7 canonical-pr=1 cherry-pick=true +[auto-changelog] commit=${COMMIT_C} accepted source-pr=2 canonical-pr=2 cherry-pick=false +[auto-changelog] skipped pr=9 reason=cherry-pick-duplicate`), + ], + [ + ['1', [COMMIT_A, COMMIT_B]], + ['2', [COMMIT_C]], + ], + ); +}); + +test('treats a PR generated before the last tip but missing from the section as deleted', () => { + const options = { + acceptedCommitsByPr: new Map([ + ['1', [COMMIT_A]], + ['2', [COMMIT_B]], + ['3', [COMMIT_C]], + ['4', [COMMIT_A, COMMIT_C]], + ]), + generatedThrough: 'd'.repeat(40), + listReachableCommits: () => [COMMIT_A, COMMIT_B], + }; + + assert.deepEqual( + getDeletedPrNumbers({ + ...options, + existingPrNumbers: new Set(['1']), + }), + new Set(['2']), + ); + assert.deepEqual( + getDeletedPrNumbers({ + ...options, + existingPrNumbers: new Set(['1', '2']), + }), + new Set(), + ); +}); + +test('treats nothing as deleted when the last tip cannot be read', () => { + assert.deepEqual( + getDeletedPrNumbers({ + acceptedCommitsByPr: new Map([['1', [COMMIT_A]]]), + generatedThrough: 'd'.repeat(40), + existingPrNumbers: new Set(), + listReachableCommits: () => { + throw new Error('unknown revision'); + }, + }), + new Set(), + ); +}); + +test('finds the releases that already list a PR, oldest first, excluding the target', () => { + const changelog = `# Changelog + +## [Unreleased] + +- Mentions #35716 outside a release + +## [8.11.0] + +### Added + +- Added a notice (#35716) +- Updated dependencies ([#35695](https://example.test/pull/35695)) + +## [8.10.3] + +## [8.9.0] + +- Added an older feature (#35695) + +## [8.8.0] + +- Unrelated (#1) +`; + assert.deepEqual( + findReleaseVersionsContainingPrs(changelog, ['35695', '35716'], '8.10.3'), + ['8.9.0', '8.11.0'], + ); + assert.deepEqual( + findReleaseVersionsContainingPrs(changelog, ['35716'], '8.11.0'), + [], + ); +}); + +test('finds versions that document a PR with this repository long link', () => { + const changelog = `# Changelog + +## [8.9.0] + +- Added a feature ([#35695](https://github.com/MetaMask/metamask-mobile/pull/35695)) + +## [8.8.0] + +- Other repo ([#35695](https://github.com/MetaMask/core/pull/35695)) +`; + const repositoryUrl = 'https://github.com/MetaMask/metamask-mobile'; + + assert.deepEqual( + findReleaseVersionsContainingPrs( + changelog, + ['35695'], + '8.10.3', + repositoryUrl, + ), + ['8.9.0'], + ); + assert.deepEqual( + findReleaseVersionsContainingPrs(changelog, ['35695'], '8.10.3'), + [], + ); +}); + +test('detects a release section without entries', () => { + assert.equal( + isReleaseSectionEmpty( + '# Changelog\n\n## [8.10.3]\n\n## [8.10.2]\n', + '8.10.3', + ), + true, + ); + assert.equal( + isReleaseSectionEmpty( + '# Changelog\n\n## [8.10.3]\n\n### Fixed\n\n- Fixed it (#1)\n\n## [8.10.2]\n', + '8.10.3', + ), + false, + ); +}); + +test('asks for a manual edit when an OTA release has no new PRs', () => { + assert.deepEqual( + getChangelogPrPresentation({ + version: '8.10.3', + changelogBranch: 'release-changelog/8.10.3', + proofreadingStatus: 'no-new-prs', + presentInVersions: ['8.11.0', '8.12.0'], + }), + { + title: + 'release: release-changelog/8.10.3 (no new PRs, manual edit required)', + body: 'This PR updates the change log for 8.10.3.\n\n> There are no brand new PRs in 8.10.3, there are only cherry-picks that were already present in 8.11.0, 8.12.0. Please decide how you want to handle this and edit the changelog manually.', + }, + ); + assert.match( + getChangelogPrPresentation({ + version: '8.10.3', + changelogBranch: 'release-changelog/8.10.3', + proofreadingStatus: 'no-new-prs', + }).body, + /There are no brand new PRs in 8\.10\.3\. Please decide/u, + ); +}); + +test('parses dry-run without changing positional arguments', () => { + assert.deepEqual( + parseUpdateChangelogArguments([ + '--dry-run', + 'release/13.51.0', + 'extension', + 'https://github.com/MetaMask/metamask-extension', + ]), + { + dryRun: true, + positionalArguments: [ + 'release/13.51.0', + 'extension', + 'https://github.com/MetaMask/metamask-extension', + ], + }, + ); +}); + +test('requires a pinned auto-changelog source CLI', () => { + assert.equal( + getAutoChangelogCli({ AUTO_CHANGELOG_CLI: '/tmp/auto-changelog.mjs' }), + '/tmp/auto-changelog.mjs', + ); + assert.throws( + () => getAutoChangelogCli({}), + /AUTO_CHANGELOG_CLI must point/u, + ); +}); + +test('uses the standard validator when clean-room proofreading fails', () => { + assert.deepEqual(getChangelogValidationArguments('failed', '13.51.0'), [ + 'validate', + '--prettier', + '--currentVersion', + '13.51.0', + ]); + assert.deepEqual(getChangelogValidationArguments('succeeded', '13.51.0'), [ + 'validate', + '--prettier', + '--currentVersion', + '13.51.0', + '--rc', + ]); +}); + +test('protects first changelog branch creation with an empty lease', () => { + assert.deepEqual( + getChangelogPushArguments({ + changelogBranch: 'release-changelog/13.51.0', + expectedRemoteSha: undefined, + }), + [ + 'push', + '--force-with-lease=refs/heads/release-changelog/13.51.0:', + '--set-upstream', + 'origin', + 'HEAD:refs/heads/release-changelog/13.51.0', + ], + ); +}); + +const RUN_ENVIRONMENT_NAMES = [ + 'GITHUB_SERVER_URL', + 'GITHUB_REPOSITORY', + 'GITHUB_RUN_ID', +] as const; + +/** + * Runs a callback with the GitHub Actions run variables set or removed, so the + * result does not depend on whether the tests run in CI. + * + * @param values - The values to set, or undefined to remove them. + * @param callback - The code to run. + * @returns The callback's result. + */ +function withRunEnvironment( + values: Record<(typeof RUN_ENVIRONMENT_NAMES)[number], string> | undefined, + callback: () => Result, +): Result { + const original = Object.fromEntries( + RUN_ENVIRONMENT_NAMES.map((name) => [name, process.env[name]]), + ); + try { + for (const name of RUN_ENVIRONMENT_NAMES) { + if (values) { + process.env[name] = values[name]; + } else { + delete process.env[name]; + } + } + return callback(); + } finally { + for (const name of RUN_ENVIRONMENT_NAMES) { + if (original[name] === undefined) { + delete process.env[name]; + } else { + process.env[name] = original[name]; + } + } + } +} + +test('marks an unproofread changelog PR when AI proofreading fails', () => { + const presentation = withRunEnvironment(undefined, () => + getChangelogPrPresentation({ + version: '13.51.0', + changelogBranch: 'release-changelog/13.51.0', + proofreadingStatus: 'failed', + proofreadingStage: 'authentication', + }), + ); + + assert.deepEqual(presentation, { + title: + 'release: release-changelog/13.51.0 (AI automation failed, not proofread)', + body: 'This PR updates the change log for 13.51.0.\n\n> AI automation failed during authentication, not proofread. The deterministic changelog generation, reference validation, and changelog lint passed. Review the release section manually before merging.', + }); +}); + +test('links the proofreading report artifact when run in GitHub Actions', () => { + const { body } = withRunEnvironment( + { + GITHUB_SERVER_URL: 'https://github.com', + GITHUB_REPOSITORY: 'MetaMask/metamask-extension', + GITHUB_RUN_ID: '123', + }, + () => + getChangelogPrPresentation({ + version: '13.51.0', + changelogBranch: 'release-changelog/13.51.0', + proofreadingStatus: 'failed', + }), + ); + + assert.match( + body, + /\[View the proofreading report artifact\.\]\(https:\/\/github\.com\/MetaMask\/metamask-extension\/actions\/runs\/123#artifacts\)/u, + ); +}); + +test('preserves authentication failures for the unproofread PR warning', () => { + const authenticationFailure = { + status: 'failed' as const, + stage: 'authentication', + }; + assert.deepEqual( + getChangelogPrPresentation({ + version: '13.51.0', + changelogBranch: 'release-changelog/13.51.0', + proofreadingStatus: authenticationFailure.status, + proofreadingStage: authenticationFailure.stage, + }).title, + 'release: release-changelog/13.51.0 (AI automation failed, not proofread)', + ); +}); + +test('uses the plain presentation after a successful proofread', () => { + assert.deepEqual( + getChangelogPrPresentation({ + version: '13.51.0', + changelogBranch: 'release-changelog/13.51.0', + proofreadingStatus: 'succeeded', + }), + { + title: 'release: release-changelog/13.51.0', + body: 'This PR updates the change log for 13.51.0.', + }, + ); +}); + +test('explains an empty release section instead of deferring to the validator', () => { + assert.throws( + () => + assertReleaseSectionHasEntries( + '# Changelog\n\n## [8.10.3]\n\n## [8.10.2]\n', + '8.10.3', + ), + /## \[8\.10\.3\] has no entries/u, + ); + assert.doesNotThrow(() => + assertReleaseSectionHasEntries( + '# Changelog\n\n## [8.10.3]\n\n### Fixed\n\n- Fixed it (#1)\n', + '8.10.3', + ), + ); +}); + +test('leaves an existing changelog PR alone when nothing changed', () => { + assert.equal( + shouldKeepChangelogPrPresentation({ + existingTitle: 'release: release-changelog/13.51.0', + proofreadingStatus: 'unchanged', + }), + true, + ); +}); + +test('keeps the AI failure marker when a later run proofreads only new entries', () => { + const existingTitle = + 'release: release-changelog/13.51.0 (AI automation failed, not proofread)'; + + assert.equal( + shouldKeepChangelogPrPresentation({ + existingTitle, + proofreadingStatus: 'succeeded', + }), + true, + ); + assert.equal( + shouldKeepChangelogPrPresentation({ + existingTitle, + proofreadingStatus: 'failed', + }), + false, + ); +}); + +test('rewrites the AI failure marker when the whole section was proofread again', () => { + assert.equal( + shouldKeepChangelogPrPresentation({ + existingTitle: + 'release: release-changelog/13.51.0 (AI automation failed, not proofread)', + proofreadingStatus: 'succeeded', + replacesWholeSection: true, + }), + false, + ); +}); + +test("regenerates only an unproofread PR whose sole commit is the updater's own", () => { + const options = { + title: + 'release: release-changelog/13.51.0 (AI automation failed, not proofread)', + version: '13.51.0', + authorEmail: 'bot@example.com', + commits: [ + { + authorEmail: 'bot@example.com', + subject: 'update changelog for 13.51.0', + unproofread: true, + }, + ], + }; + + assert.equal(isUntouchedUnproofreadChangelogPr(options), true); + assert.equal( + isUntouchedUnproofreadChangelogPr({ + ...options, + commits: [ + { + authorEmail: 'bot@example.com', + subject: 'update changelog for 13.51.0', + }, + ], + }), + false, + ); + assert.equal( + isUntouchedUnproofreadChangelogPr({ + ...options, + title: 'release: release-changelog/13.51.0', + }), + false, + ); + assert.equal( + isUntouchedUnproofreadChangelogPr({ + ...options, + commits: [ + ...options.commits, + { authorEmail: 'person@example.com', subject: 'fix wording' }, + ], + }), + false, + ); + assert.equal( + isUntouchedUnproofreadChangelogPr({ + ...options, + commits: [ + { + authorEmail: 'person@example.com', + subject: 'update changelog for 13.51.0', + }, + ], + }), + false, + ); + assert.equal( + isUntouchedUnproofreadChangelogPr({ ...options, commits: [] }), + false, + ); +}); + +test("recovers a branch only when every commit is the updater's own", () => { + const own = { + authorEmail: 'bot@example.com', + subject: 'update changelog for 13.51.0', + }; + const options = { + version: '13.51.0', + authorEmail: 'bot@example.com', + commits: [own], + }; + + assert.equal(hasOnlyUpdaterCommits(options), true); + assert.equal( + hasOnlyUpdaterCommits({ + ...options, + commits: [ + own, + { authorEmail: 'person@example.com', subject: 'fix wording' }, + ], + }), + false, + ); +}); + +test('rewrites the PR presentation after a normal successful run', () => { + assert.equal( + shouldKeepChangelogPrPresentation({ + existingTitle: 'release: release-changelog/13.51.0', + proofreadingStatus: 'succeeded', + }), + false, + ); +}); + +test('rejects generated sections with missing or duplicate PR references', () => { + assert.throws( + () => + validateGeneratedReleaseSection( + `## [13.51.0] + +### Fixed + +- Fixed an issue`, + '13.51.0', + ), + /without a PR reference/u, + ); + assert.throws( + () => + validateGeneratedReleaseSection( + `## [13.51.0] + +### Fixed + +- Fixed an issue (#101) +- Fixed it again (#101)`, + '13.51.0', + ), + /duplicate PR references/u, + ); + assert.doesNotThrow(() => + validateGeneratedReleaseSection( + `## [13.51.0] + +### Fixed + +- Fixed an issue (#101) +- Fixed it again (#101)`, + '13.51.0', + true, + ), + ); +}); diff --git a/.github/scripts/update-release-changelog-baseline.mts b/.github/scripts/update-release-changelog-baseline.mts new file mode 100644 index 00000000..a34a7366 --- /dev/null +++ b/.github/scripts/update-release-changelog-baseline.mts @@ -0,0 +1,134 @@ +import { + detectLineEnding, + parseReleaseHeadingVersion, + splitLines, +} from './release-section.mts'; +import { + compareSemver, + type ReleaseBranch, + selectHighestLowerVersion, +} from './release-version-utils.mts'; + +const RELEASE_LINK_REFERENCE = /^\[(\d+\.\d+\.\d+)\]: /u; + +export type ChangelogBaseline = { + kind: 'target' | 'stable' | 'release'; + ref: string; + version: string; +}; + +export type ChangelogBaselineDependencies = { + hasReleaseHeading: (ref: string, version: string) => boolean; + getStableVersions: () => readonly string[]; + getReleaseBranches: () => readonly ReleaseBranch[]; +}; + +/** + * Selects the changelog the release section is generated on top of: the target + * branch once it has the section, otherwise the predecessor version on `stable` + * or on its release branch. + * + * @param options0 - The options. + * @param options0.version - The version being released. + * @param options0.releaseBranch - The target release branch name. + * @param options0.dependencies - The Git lookups, injected so tests can fake + * them. + * @returns The chosen baseline ref and version. + */ +export function selectChangelogBaseline({ + version, + releaseBranch, + dependencies, +}: { + version: string; + releaseBranch: string; + dependencies: ChangelogBaselineDependencies; +}): ChangelogBaseline { + if (dependencies.hasReleaseHeading(`origin/${releaseBranch}`, version)) { + return { kind: 'target', ref: `origin/${releaseBranch}`, version }; + } + + const releaseBranches = dependencies.getReleaseBranches(); + // Strictly lower, also for `-ota` targets: an OTA release has its own patch version, so no same-version native exists. + const predecessorVersion = selectHighestLowerVersion(version, [ + ...dependencies.getStableVersions(), + ...releaseBranches.map(({ version: branchVersion }) => branchVersion), + ]); + + if (!predecessorVersion) { + throw new Error(`No earlier release version exists for ${version}`); + } + + if (dependencies.hasReleaseHeading('origin/stable', predecessorVersion)) { + return { + kind: 'stable', + ref: 'origin/stable', + version: predecessorVersion, + }; + } + + const matchingBranches = releaseBranches.filter( + ({ version: branchVersion }) => branchVersion === predecessorVersion, + ); + if (matchingBranches.length !== 1) { + throw new Error( + `Expected exactly one release branch for ${predecessorVersion}, found ${matchingBranches.length}`, + ); + } + + const [matchingBranch] = matchingBranches; + if (!matchingBranch) { + throw new Error( + `Expected exactly one release branch for ${predecessorVersion}, found 0`, + ); + } + + // Without its own section, the predecessor's PRs would be attributed to the + // release being generated. + const ref = `origin/${matchingBranch.name}`; + if (!dependencies.hasReleaseHeading(ref, predecessorVersion)) { + throw new Error( + `${ref} has no ## [${predecessorVersion}] changelog section, so its PRs would be attributed to ${version}. Merge its changelog PR first, or delete the branch if it was abandoned.`, + ); + } + + return { kind: 'release', ref, version: predecessorVersion }; +} + +/** + * Drops the sections and link references of releases newer than the target. A + * hotfix on an older release line is restored from a baseline that may already + * hold newer releases, which do not belong on its branch. + * + * @param changelog - The changelog restored from the baseline. + * @param version - The version being released. + * @returns The changelog without releases newer than `version`. + */ +export function removeNewerReleases( + changelog: string, + version: string, +): string { + const isNewer = (candidate: string): boolean => + compareSemver(candidate, version) > 0; + let skipping = false; + + const keptLines = splitLines(changelog).filter((line) => { + const headingVersion = parseReleaseHeadingVersion(line); + if (headingVersion) { + skipping = isNewer(headingVersion); + return !skipping; + } + + // Another heading (such as Unreleased) or the link references end a section. + if (line.startsWith('## [') || /^\[[^\]]+\]: /u.test(line)) { + skipping = false; + } + const linkVersion = RELEASE_LINK_REFERENCE.exec(line)?.[1]; + if (linkVersion && isNewer(linkVersion)) { + return false; + } + return !skipping; + }); + + return keptLines.join(detectLineEnding(changelog)); +} diff --git a/.github/scripts/update-release-changelog.mts b/.github/scripts/update-release-changelog.mts new file mode 100644 index 00000000..f10547df --- /dev/null +++ b/.github/scripts/update-release-changelog.mts @@ -0,0 +1,1677 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import { + closeSync, + existsSync, + mkdirSync, + mkdtempSync, + openSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + extractReleaseSection, + getEntryLines, + getNewReleaseEntriesSection, + getPrNumbers, + getReleaseVersions, + hasSingleReleaseHeading, + mergeCurrentReleaseSection, + mergeNewReleaseEntries, + normalizeRepositoryPrLinks, + parseReleaseHeadingVersion, + replaceReleaseSection, + restoreGeneratedCategories, + splitLines, +} from './release-section.mts'; +import { compareSemver, parseReleaseBranch } from './release-version-utils.mts'; +import { getEnvironmentWithoutAiKey } from './shared/environment.mts'; +import { writeReport } from './shared/report.mts'; +import { + removeNewerReleases, + selectChangelogBaseline, + type ChangelogBaseline, +} from './update-release-changelog-baseline.mts'; + +type ProofreadingStatus = 'succeeded' | 'failed' | 'no-new-prs' | 'unchanged'; + +type ProofreadingResult = { + status: ProofreadingStatus; + stage?: string; + presentInVersions?: string[]; +}; + +type ChangelogPrPresentation = { + title: string; + body: string; +}; + +const SCRIPT_DIRECTORY = dirname(fileURLToPath(import.meta.url)); +const CLEANER_PATH = resolve(SCRIPT_DIRECTORY, 'clean-release-changelog.mts'); +const PROMPT_PATH = resolve( + SCRIPT_DIRECTORY, + '../prompts/auto-changelog-clean-room.md', +); + +// All Git and generator children use this environment. The cleaner is +// the sole process allowed to receive the raw LiteLLM key. +const deterministicEnvironment = getEnvironmentWithoutAiKey(); + +/** + * Runs a command and returns its trimmed standard output. + * + * @param command - The executable to run. + * @param args - The command arguments. + * @returns The trimmed standard output. + */ +function capture(command: string, args: string[]): string { + return execFileSync(command, args, { + encoding: 'utf8', + env: deterministicEnvironment, + // `git rev-list` over the extension's history is over the 1 MiB default. + maxBuffer: 64 * 1024 * 1024, + }).trim(); +} + +/** + * Reads `CHANGELOG.md` as committed at a Git ref. + * + * @param ref - The Git ref to read from. + * @returns The trimmed changelog content. + */ +function readChangelogAtRef(ref: string): string { + mkdirSync('.tmp', { recursive: true }); + const temporaryDirectory = mkdtempSync( + join('.tmp', 'release-changelog-read-'), + ); + const temporaryChangelogPath = join(temporaryDirectory, 'CHANGELOG.md'); + const descriptor = openSync(temporaryChangelogPath, 'w'); + + try { + try { + execFileSync('git', ['show', `${ref}:CHANGELOG.md`], { + env: deterministicEnvironment, + stdio: ['ignore', descriptor, 'inherit'], + }); + } finally { + closeSync(descriptor); + } + return readFileSync(temporaryChangelogPath, 'utf8').trim(); + } finally { + rmSync(temporaryDirectory, { force: true, recursive: true }); + } +} + +/** + * Reads one version's release section from the changelog at a Git ref. + * + * @param ref - The Git ref to read from. + * @param version - The release version. + * @returns The release section. + */ +function readReleaseSectionAtRef(ref: string, version: string): string { + return extractReleaseSection(readChangelogAtRef(ref), version).section; +} + +/** + * Runs a command with inherited output, throwing when it fails. + * + * @param command - The executable to run. + * @param args - The command arguments. + * @param environment - The child environment, which by default omits the AI key. + */ +function execute( + command: string, + args: string[], + environment = deterministicEnvironment, +): void { + execFileSync(command, args, { env: environment, stdio: 'inherit' }); +} + +/** + * Returns the path of the installed auto-changelog CLI. + * + * @param environment - The environment to read `AUTO_CHANGELOG_CLI` from. + * @returns The CLI path. + */ +export function getAutoChangelogCli( + environment: NodeJS.ProcessEnv = process.env, +): string { + const cli = environment.AUTO_CHANGELOG_CLI; + if (!cli) { + throw new Error( + 'AUTO_CHANGELOG_CLI must point to the installed auto-changelog CLI', + ); + } + return cli; +} + +/** + * Runs the auto-changelog CLI. + * + * @param args - The CLI arguments. + */ +function executeAutoChangelog(args: string[]): void { + execute('node', [getAutoChangelogCli(), ...args]); +} + +/** + * Runs a command and reports whether it exited with status 0. + * + * @param command - The executable to run. + * @param args - The command arguments. + * @returns `true` when the command succeeded. + */ +function succeeds(command: string, args: string[]): boolean { + return ( + spawnSync(command, args, { + env: deterministicEnvironment, + stdio: 'ignore', + }).status === 0 + ); +} + +/** + * Looks up a branch tip on origin without fetching it. + * + * @param branch - The branch name without the `origin/` prefix. + * @returns The SHA, or `undefined` when the branch does not exist. + */ +export function getRemoteBranchSha(branch: string): string | undefined { + const output = capture('git', [ + 'ls-remote', + '--heads', + 'origin', + `refs/heads/${branch}`, + ]); + const [sha] = output.split(/\s+/u); + return sha === '' ? undefined : sha; +} + +/** + * Looks up the origin tip of a ref given with or without the `origin/` prefix. + * + * @param ref - The ref to look up. + * @returns The SHA, or `undefined` when the branch does not exist. + */ +function getRemoteRefSha(ref: string): string | undefined { + const branch = ref.startsWith('origin/') ? ref.slice('origin/'.length) : ref; + return getRemoteBranchSha(branch); +} + +/** + * Throws when a remote ref moved since it was read, so stale output is never + * published. + * + * @param options0 - The options. + * @param options0.ref - The ref to check, with or without `origin/`. + * @param options0.expectedSha - The SHA read when the run began. + * @param options0.description - A label for the ref, used in the error message. + */ +export function assertRemoteRefUnchanged({ + ref, + expectedSha, + description, +}: { + ref: string; + expectedSha: string; + description: string; +}): void { + const currentSha = getRemoteRefSha(ref); + if (currentSha !== expectedSha) { + throw new Error( + `${description} ${ref} changed during changelog generation; refusing to publish stale output`, + ); + } +} + +/** + * Reports whether a ref's changelog has exactly one heading for a version. + * + * @param ref - The Git ref to read. + * @param version - The version to look for. + * @returns `true` when exactly one heading exists; `false` also when the + * changelog cannot be read. + */ +function hasReleaseHeading(ref: string, version: string): boolean { + try { + return hasSingleReleaseHeading(readChangelogAtRef(ref), version); + } catch { + return false; + } +} + +/** + * Lists the versions that have a heading in the `stable` changelog. + * + * @returns The versions. + */ +function getStableVersions(): string[] { + return getReleaseVersions(readChangelogAtRef('origin/stable')); +} + +/** + * Lists the release branches that exist on origin. + * + * @returns The parsed release branches. + */ +function getReleaseBranches() { + // strip=3 drops `refs/remotes/origin/`, leaving `release/X.Y.Z`. + return capture('git', [ + 'for-each-ref', + '--format=%(refname:strip=3)', + 'refs/remotes/origin/release/*', + ]) + .split(/\r?\n/u) + .filter(Boolean) + .flatMap((branchName) => { + const branch = parseReleaseBranch(branchName); + return branch ? [branch] : []; + }); +} + +/** + * Finds the open changelog PR from a changelog branch to a release branch. + * + * @param changelogBranch - The PR head branch. + * @param releaseBranch - The PR base branch. + * @returns The PR number and title, or `undefined` when no PR is open. + */ +function getOpenChangelogPr( + changelogBranch: string, + releaseBranch: string, +): { number: string; title: string } | undefined { + const output = capture('gh', [ + 'pr', + 'list', + '--head', + changelogBranch, + '--base', + releaseBranch, + '--state', + 'open', + '--json', + 'number,title', + '--jq', + '.[0] // empty', + ]); + if (!output) { + return undefined; + } + const { number, title } = JSON.parse(output) as { + number: number; + title: string; + }; + return { number: String(number), title }; +} + +/** + * Finds the number of the open changelog PR from a changelog branch to a + * release branch. + * + * @param changelogBranch - The PR head branch. + * @param releaseBranch - The PR base branch. + * @returns The PR number, or `undefined` when no PR is open. + */ +function getOpenChangelogPrNumber( + changelogBranch: string, + releaseBranch: string, +): string | undefined { + return getOpenChangelogPr(changelogBranch, releaseBranch)?.number; +} + +/** + * Sets the commit identity and fetches origin. + * + * @param authorName - The commit author and committer name. + * @param authorEmail - The commit author and committer email. + */ +function configureGit(authorName: string, authorEmail: string): void { + execute('git', ['config', 'user.name', authorName]); + execute('git', ['config', 'user.email', authorEmail]); + execute('git', ['fetch']); +} + +/** + * Checks out the release branch and discards any stale local state so the work + * starts from the remote tip. + * + * @param releaseBranch - The release branch name. + */ +function ensureReleaseBranch(releaseBranch: string): void { + execute('git', ['checkout', releaseBranch]); + execute('git', ['reset', '--hard', `origin/${releaseBranch}`]); +} + +/** + * Chooses the changelog branch name: the preferred name, or its `-fallback` + * variant when only that one exists on origin. + * + * @param version - The version being released. + * @returns The changelog branch name. + */ +function determineChangelogBranch(version: string): string { + const preferredBranch = `release-changelog/${version}`; + if (getRemoteBranchSha(preferredBranch)) { + return preferredBranch; + } + + const fallbackBranch = `${preferredBranch}-fallback`; + return getRemoteBranchSha(fallbackBranch) ? fallbackBranch : preferredBranch; +} + +/** + * Strips trailing spaces and tabs, which `git diff --check` rejects and the + * generator never emits. + * + * @param text - The text to clean. + * @returns The text without trailing whitespace on any line. + */ +function trimTrailingWhitespace(text: string): string { + return text.replace(/[ \t]+$/gmu, ''); +} + +/** + * Returns the release section a reviewer may have edited on an open changelog + * branch. Refuses to continue when the branch has no open PR or lacks the + * section, so reviewed work is never overwritten. + * + * @param options0 - The options. + * @param options0.changelogBranch - The changelog branch name. + * @param options0.changelogBranchSha - The branch tip on origin, or `undefined` + * when the branch does not exist. + * @param options0.releaseBranch - The target release branch. + * @param options0.version - The version being released. + * @param options0.allowMissingOpenPr - Whether a branch without an open PR is + * ignored instead of refused. A target baseline already holds the merged + * section, so only an open PR carries reviewed work worth keeping. + * @param options0.isUpdaterOnlyBranch - Reports whether the branch holds only + * the updater's own commits, so a failed PR creation can be recovered by + * regenerating it. + * @param options0.getOpenPrNumber - Looks up the open PR number; replaceable in + * tests. + * @returns The reviewed section, or `undefined` when the branch does not exist. + */ +export function getReviewedCurrentReleaseSection({ + changelogBranch, + changelogBranchSha, + releaseBranch, + version, + allowMissingOpenPr = false, + isUpdaterOnlyBranch = () => false, + getOpenPrNumber = getOpenChangelogPrNumber, +}: { + changelogBranch: string; + changelogBranchSha: string | undefined; + releaseBranch: string; + version: string; + allowMissingOpenPr?: boolean; + isUpdaterOnlyBranch?: () => boolean; + getOpenPrNumber?: typeof getOpenChangelogPrNumber; +}): string | undefined { + if (!changelogBranchSha) { + return undefined; + } + + if (!getOpenPrNumber(changelogBranch, releaseBranch)) { + if (allowMissingOpenPr || isUpdaterOnlyBranch()) { + return undefined; + } + throw new Error( + `${changelogBranch} exists without an open PR to ${releaseBranch}; refusing to overwrite it`, + ); + } + + try { + return trimTrailingWhitespace( + readReleaseSectionAtRef(`origin/${changelogBranch}`, version), + ); + } catch { + throw new Error( + `Open changelog branch ${changelogBranch} lacks ${version}; refusing to overwrite it`, + ); + } +} + +/** + * Recreates the changelog branch from the release branch with `CHANGELOG.md` + * restored from the baseline, and verifies the restore is exact. + * + * @param options0 - The options. + * @param options0.changelogBranch - The changelog branch name. + * @param options0.releaseBranch - The target release branch. + * @param options0.baseline - The baseline whose changelog is restored. + */ +export function rebuildChangelogBranch({ + changelogBranch, + releaseBranch, + baseline, +}: { + changelogBranch: string; + releaseBranch: string; + baseline: ChangelogBaseline; +}): void { + execute('git', [ + 'checkout', + '-B', + changelogBranch, + `origin/${releaseBranch}`, + ]); + // Branching from a remote ref sets tracking; this is expected to fail when + // none was set. + succeeds('git', ['branch', '--unset-upstream', changelogBranch]); + + if (!succeeds('git', ['cat-file', '-e', `${baseline.ref}:CHANGELOG.md`])) { + throw new Error(`${baseline.ref} does not contain CHANGELOG.md`); + } + + execute('git', [ + 'restore', + `--source=${baseline.ref}`, + '--staged', + '--worktree', + '--', + 'CHANGELOG.md', + ]); + + // Compare through Git so text attributes and core.autocrlf do not turn an + // exact restore into a false mismatch on Windows worktrees. + if ( + !succeeds('git', ['diff', '--quiet', baseline.ref, '--', 'CHANGELOG.md']) + ) { + throw new Error(`Restored CHANGELOG.md does not match ${baseline.ref}`); + } +} + +/** + * Runs auto-changelog and returns its standard error. Stderr is buffered so the + * verbose skip diagnostics can be parsed. + * + * @param args - The CLI arguments. + * @returns The captured standard error. + */ +function executeAutoChangelogCapturingDiagnostics(args: string[]): string { + const result = spawnSync('node', [getAutoChangelogCli(), ...args], { + env: deterministicEnvironment, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + stdio: ['ignore', 'inherit', 'pipe'], + }); + process.stderr.write(result.stderr); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error( + `auto-changelog exited with status ${String(result.status)}`, + ); + } + return result.stderr; +} + +/** + * Extracts the PRs the generator skipped because an earlier release already + * lists them. + * + * @param diagnostics - The generator's verbose standard error. + * @returns The distinct PR numbers. + */ +export function getHistoricalSkippedPrNumbers(diagnostics: string): string[] { + const prNumbers = [ + ...diagnostics.matchAll( + /\[auto-changelog\] skipped pr=(\d+) reason=historical-pr/gu, + ), + ].flatMap((match) => (match[1] ? [match[1]] : [])); + return [...new Set(prNumbers)]; +} + +/** + * Extracts the commits the generator accepted for each PR. + * + * @param diagnostics - The generator's verbose standard error. + * @returns The accepted commit SHAs by canonical PR number. + */ +export function getAcceptedPrCommits( + diagnostics: string, +): Map { + const commitsByPr = new Map(); + for (const match of diagnostics.matchAll( + /\[auto-changelog\] commit=([0-9a-f]{40}) accepted source-pr=\d+ canonical-pr=(\d+)/gu, + )) { + const [, commit = '', prNumber = ''] = match; + commitsByPr.set(prNumber, [...(commitsByPr.get(prNumber) ?? []), commit]); + } + return commitsByPr; +} + +/** + * Finds the other released versions whose sections mention any of the PRs. + * + * @param changelog - The full changelog text. + * @param prNumbers - The PR numbers to look for. + * @param excludedVersion - The version whose section is ignored. + * @param repositoryUrl - The repository's GitHub URL, so its long PR links + * count as references. + * @returns The matching versions, oldest first. + */ +export function findReleaseVersionsContainingPrs( + changelog: string, + prNumbers: readonly string[], + excludedVersion: string, + repositoryUrl?: string, +): string[] { + const wanted = new Set(prNumbers); + const versions = new Set(); + let currentVersion: string | undefined; + const normalizedChangelog = repositoryUrl + ? normalizeRepositoryPrLinks(changelog, repositoryUrl) + : changelog; + for (const line of splitLines(normalizedChangelog)) { + const headingVersion = parseReleaseHeadingVersion(line); + if (headingVersion) { + currentVersion = headingVersion; + } else if ( + currentVersion && + currentVersion !== excludedVersion && + getPrNumbers(line).some((prNumber) => wanted.has(prNumber)) + ) { + versions.add(currentVersion); + } + } + return [...versions].sort(compareSemver); +} + +/** + * Reports whether a version's section has no entries. + * + * @param changelogContent - The full changelog text. + * @param version - The version to check. + * @returns `true` when the section has no entries. + */ +export function isReleaseSectionEmpty( + changelogContent: string, + version: string, +): boolean { + const { section } = extractReleaseSection(changelogContent, version); + return getEntryLines(section).length === 0; +} + +/** + * Runs auto-changelog to generate the release section. + * + * @param options0 - The options. + * @param options0.repositoryUrl - The GitHub repository URL. + * @param options0.version - The version being released. + * @returns The PR numbers the generator skipped because an earlier release + * lists them, and the commits it accepted for each PR. + */ +function generateChangelog({ + repositoryUrl, + version, +}: { + repositoryUrl: string; + version: string; +}): { + skippedPrNumbers: string[]; + acceptedCommitsByPr: Map; +} { + const commonArguments = [ + 'update', + '--rc', + '--repo', + repositoryUrl, + '--currentVersion', + version, + '--autoCategorize', + '--useChangelogEntry', + '--useShortPrLink', + '--requirePrNumbers', + '--normalizeToPastTense', + '--excludeChoreWithoutChangelogEntry', + '--preventBackfill', + // Verbose output carries the skip diagnostics parsed after the run. + '--verbose', + ]; + + const diagnostics = executeAutoChangelogCapturingDiagnostics(commonArguments); + return { + skippedPrNumbers: getHistoricalSkippedPrNumbers(diagnostics), + acceptedCommitsByPr: getAcceptedPrCommits(diagnostics), + }; +} + +/** + * Throws unless the section has entries that each carry a PR reference and, + * unless allowed, no PR appears twice. + * + * @param changelogContent - The full changelog text. + * @param version - The version whose section is checked. + * @param allowDuplicatePrReferences - Whether repeated PR references are + * tolerated, because the proofreader can repair them. + */ +export function validateGeneratedReleaseSection( + changelogContent: string, + version: string, + allowDuplicatePrReferences = false, +): void { + const { section } = extractReleaseSection(changelogContent, version); + const entries = getEntryLines(section); + if (entries.length === 0) { + throw new Error(`Generated ## [${version}] section contains no entries`); + } + if (entries.some((entry) => getPrNumbers(entry).length === 0)) { + throw new Error( + `Generated ## [${version}] section contains an entry without a PR reference`, + ); + } + + const prNumbers = getPrNumbers(section); + if ( + !allowDuplicatePrReferences && + new Set(prNumbers).size !== prNumbers.length + ) { + throw new Error( + `Generated ## [${version}] section contains duplicate PR references`, + ); + } +} + +/** + * Builds the `auto-changelog validate` arguments for a proofreading outcome. + * + * @param proofreadingStatus - The proofreading outcome. + * @param version - The version being released. Passed explicitly because an + * OTA branch's `package.json` still carries the previous native version. + * @returns The CLI arguments. + */ +export function getChangelogValidationArguments( + proofreadingStatus: ProofreadingStatus, + version: string, +): string[] { + return [ + 'validate', + '--prettier', + '--currentVersion', + version, + ...(proofreadingStatus === 'succeeded' ? ['--rc'] : []), + ]; +} + +/** + * Throws a clear error when a release section has no entries, instead of the + * validator's generic one. + * + * @param changelogContent - The full changelog text. + * @param version - The version whose section is checked. + */ +export function assertReleaseSectionHasEntries( + changelogContent: string, + version: string, +): void { + if (isReleaseSectionEmpty(changelogContent, version)) { + throw new Error( + `## [${version}] has no entries. Add entries to the changelog PR; every push to the release branch fails until the section has one.`, + ); + } +} + +/** + * Validates the changelog with auto-changelog and checks the diff for + * whitespace errors. + * + * @param proofreading - The proofreading outcome. + * @param version - The version being released. + */ +function runChangelogValidation( + proofreading: ProofreadingResult, + version: string, +): void { + // The validator rejects a release with no entries, which is the expected + // state here until a human edits the draft PR. + if (proofreading.status !== 'no-new-prs') { + assertReleaseSectionHasEntries( + readFileSync('CHANGELOG.md', 'utf8'), + version, + ); + executeAutoChangelog( + getChangelogValidationArguments(proofreading.status, version), + ); + } + + execute('git', ['diff', '--check']); +} + +/** + * Records a failed proofreading run in the report file. + * + * @param reportPath - The report file path. + * @param error - The failure message. + * @param stage - The stage that failed. + */ +export function writeFailedProofreadingReport( + reportPath: string, + error: string, + stage = 'execution', +): void { + writeReport(reportPath, { status: 'failed', stage, error }); +} + +/** + * Reads the LiteLLM key from the process environment. + * + * @returns The key, or `undefined` when it is not set. + */ +function getAiAnalyzerLiteLlmKey(): string | undefined { + return process.env.AI_ANALYZER_LITELLM_KEY; +} + +/** + * Reads the failed stage from the proofreader's report. + * + * @param reportPath - The report file path. + * @returns The stage, or `execution` when the report is missing or unreadable. + */ +function getProofreadingFailureStage(reportPath: string): string { + try { + const report = JSON.parse(readFileSync(reportPath, 'utf8')) as { + stage?: unknown; + }; + return typeof report.stage === 'string' ? report.stage : 'execution'; + } catch { + return 'execution'; + } +} + +/** + * Runs the clean-room proofreader on the generated section, keeping the + * deterministic section when it fails. + * + * @param options0 - The options. + * @param options0.repositoryUrl - The GitHub repository URL. + * @param options0.version - The version being released. + * @returns The proofreading outcome. + */ +function runCleanRoomProofread({ + repositoryUrl, + version, +}: { + repositoryUrl: string; + version: string; +}): ProofreadingResult { + const reportPath = `.tmp/release-changelog-ai/${version}.json`; + const apiKey = getAiAnalyzerLiteLlmKey(); + if (!apiKey) { + writeFailedProofreadingReport( + reportPath, + 'LiteLLM credential is unavailable', + 'authentication', + ); + return { status: 'failed', stage: 'authentication' }; + } + + try { + execute( + 'node', + [ + CLEANER_PATH, + '--changelog', + 'CHANGELOG.md', + '--version', + version, + '--repository', + repositoryUrl, + '--prompt', + PROMPT_PATH, + '--report', + reportPath, + ], + { ...deterministicEnvironment, AI_ANALYZER_LITELLM_KEY: apiKey }, + ); + return { status: 'succeeded' }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!existsSync(reportPath)) { + writeFailedProofreadingReport(reportPath, message); + } + const stage = getProofreadingFailureStage(reportPath); + console.warn( + 'AI changelog proofread failed; keeping deterministic generated section.', + ); + return { + status: 'failed', + stage, + }; + } +} + +/** + * Builds the PR warning for a release that has no brand new PRs. + * + * @param version - The version being released. + * @param presentInVersions - The earlier versions that already list the + * cherry-picked PRs. + * @returns The warning as a Markdown quote. + */ +export function getNoNewPrsWarning( + version: string, + presentInVersions: readonly string[], +): string { + const cherryPicks = + presentInVersions.length > 0 + ? `, there are only cherry-picks that were already present in ${presentInVersions.join(', ')}` + : ''; + return `> There are no brand new PRs in ${version}${cherryPicks}. Please decide how you want to handle this and edit the changelog manually.`; +} + +/** + * Builds the PR warning for a failed proofreading run. + * + * @param proofreadingStage - The stage that failed. + * @returns The warning as a Markdown quote. + */ +function getAiFailureWarning(proofreadingStage?: string): string { + const stageDescription = proofreadingStage + ? ` during ${proofreadingStage}` + : ''; + const artifactsUrl = + process.env.GITHUB_SERVER_URL && + process.env.GITHUB_REPOSITORY && + process.env.GITHUB_RUN_ID + ? `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}#artifacts` + : undefined; + const artifactsLink = artifactsUrl + ? ` [View the proofreading report artifact.](${artifactsUrl})` + : ''; + + return `> AI automation failed${stageDescription}, not proofread. The deterministic changelog generation, reference validation, and changelog lint passed. Review the release section manually before merging.${artifactsLink}`; +} + +const AI_FAILURE_TITLE_SUFFIX = ' (AI automation failed, not proofread)'; + +// A later run folds reviewer commits into its own commit, so only this marker +// shows that the whole section was generated and its proofreading failed. +const UNPROOFREAD_TRAILER_KEY = 'Changelog-Proofread'; +const UNPROOFREAD_TRAILER = `${UNPROOFREAD_TRAILER_KEY}: failed`; + +// Records the release tip a run generated from, so a later run can tell a PR +// a reviewer deleted from one that merged after the last run. +const GENERATED_THROUGH_TRAILER_KEY = 'Changelog-Generated-Through'; + +type ChangelogBranchCommit = { + authorEmail: string; + subject: string; + unproofread?: boolean; + generatedThrough?: string; +}; + +/** + * Decides whether an existing PR's title and body are left as they are. A run + * that adds nothing never rewrites them, and a later success only proofreads + * the new entries, so it cannot clear a failure marker from an earlier run, + * unless that run proofread the whole section again. + * + * @param options0 - The options. + * @param options0.existingTitle - The open PR's current title. + * @param options0.proofreadingStatus - The proofreading outcome of this run. + * @param options0.replacesWholeSection - Whether this run regenerated and + * proofread the whole section instead of only new entries. + * @returns `true` when the PR must not be edited. + */ +export function shouldKeepChangelogPrPresentation({ + existingTitle, + proofreadingStatus, + replacesWholeSection = false, +}: { + existingTitle: string; + proofreadingStatus: ProofreadingStatus; + replacesWholeSection?: boolean; +}): boolean { + return ( + proofreadingStatus === 'unchanged' || + (proofreadingStatus === 'succeeded' && + !replacesWholeSection && + existingTitle.endsWith(AI_FAILURE_TITLE_SUFFIX)) + ); +} + +/** + * Reports whether every commit is this updater's own, so none carries a + * reviewer's work. + * + * @param options0 - The options. + * @param options0.version - The version being released. + * @param options0.authorEmail - The updater's commit author email. + * @param options0.commits - The commits the changelog branch has over the + * release branch. + * @returns `true` when all commits are the updater's. + */ +export function hasOnlyUpdaterCommits({ + version, + authorEmail, + commits, +}: { + version: string; + authorEmail: string; + commits: ChangelogBranchCommit[]; +}): boolean { + return commits.every( + (commit) => + commit.authorEmail === authorEmail && + commit.subject === `update changelog for ${version}`, + ); +} + +/** + * Decides whether an open changelog PR can be regenerated from scratch: an + * earlier AI failure left it marked as not proofread, and its only change is + * this updater's own commit, so no reviewer edit can be lost. + * + * @param options0 - The options. + * @param options0.title - The open PR's title. + * @param options0.version - The version being released. + * @param options0.authorEmail - The updater's commit author email. + * @param options0.commits - The commits the changelog branch has over the + * release branch. + * @returns `true` when the PR is unproofread and untouched by anyone else. + */ +export function isUntouchedUnproofreadChangelogPr({ + title, + version, + authorEmail, + commits, +}: { + title: string; + version: string; + authorEmail: string; + commits: ChangelogBranchCommit[]; +}): boolean { + return ( + title.endsWith(AI_FAILURE_TITLE_SUFFIX) && + commits.length === 1 && + commits[0]?.unproofread === true && + hasOnlyUpdaterCommits({ version, authorEmail, commits }) + ); +} + +/** + * Lists the commits a changelog branch has over its release branch. + * + * @param changelogBranch - The changelog branch name. + * @param releaseBranch - The target release branch. + * @returns Each commit's author email, subject, whether it carries the + * unproofread marker, and the release tip it was generated through, newest + * commit first. + */ +export function getChangelogBranchCommits( + changelogBranch: string, + releaseBranch: string, +): ChangelogBranchCommit[] { + const trailer = (key: string): string => `%(trailers:key=${key},valueonly)`; + return capture('git', [ + 'log', + `--format=%ae%x09%s%x09${trailer(UNPROOFREAD_TRAILER_KEY)}%x09${trailer(GENERATED_THROUGH_TRAILER_KEY)}%x1e`, + `origin/${releaseBranch}..origin/${changelogBranch}`, + ]) + .split('\x1e') + .map((record) => record.trim()) + .filter(Boolean) + .map((record) => { + const [authorEmail = '', subject = '', proofread = '', through = ''] = + record.split('\t'); + const generatedThrough = through.trim(); + return { + authorEmail, + subject, + unproofread: proofread.trim() === 'failed', + ...(/^[0-9a-f]{40}$/u.test(generatedThrough) + ? { generatedThrough } + : {}), + }; + }); +} + +/** + * Finds the PRs an earlier run already generated that a reviewer has since + * removed from the section: every commit of the PR is reachable from the + * release tip that run generated through, yet the PR is not in the section. + * + * @param options0 - The options. + * @param options0.acceptedCommitsByPr - The commits the generator accepted for + * each PR in this run. + * @param options0.generatedThrough - The release tip the last run generated + * through. + * @param options0.existingPrNumbers - The PRs the reviewed section lists. + * @param options0.listReachableCommits - Lists the commits reachable from a + * ref; replaceable in tests. + * @returns The deleted PR numbers; empty when the tip cannot be read. + */ +export function getDeletedPrNumbers({ + acceptedCommitsByPr, + generatedThrough, + existingPrNumbers, + listReachableCommits = (ref) => + capture('git', ['rev-list', ref]).split(/\r?\n/u), +}: { + acceptedCommitsByPr: ReadonlyMap; + generatedThrough: string; + existingPrNumbers: ReadonlySet; + listReachableCommits?: (ref: string) => string[]; +}): Set { + let reachable: Set; + try { + reachable = new Set(listReachableCommits(generatedThrough)); + } catch { + return new Set(); + } + return new Set( + [...acceptedCommitsByPr].flatMap(([prNumber, commits]) => + !existingPrNumbers.has(prNumber) && + commits.every((commit) => reachable.has(commit)) + ? [prNumber] + : [], + ), + ); +} + +/** + * Builds the changelog PR title and body for the run's outcome. + * + * @param options0 - The options. + * @param options0.version - The version being released. + * @param options0.changelogBranch - The changelog branch name. + * @param options0.proofreadingStatus - The proofreading outcome. + * @param options0.proofreadingStage - The stage that failed, if any. + * @param options0.presentInVersions - The earlier versions that already list the + * cherry-picked PRs. + * @returns The PR title and body. + */ +export function getChangelogPrPresentation({ + version, + changelogBranch, + proofreadingStatus, + proofreadingStage, + presentInVersions = [], +}: { + version: string; + changelogBranch: string; + proofreadingStatus: ProofreadingStatus; + proofreadingStage?: string; + presentInVersions?: readonly string[]; +}): ChangelogPrPresentation { + let title = `release: ${changelogBranch}`; + let body = `This PR updates the change log for ${version}.`; + if (proofreadingStatus === 'failed') { + title = `${title}${AI_FAILURE_TITLE_SUFFIX}`; + body = `${body}\n\n${getAiFailureWarning(proofreadingStage)}`; + } + if (proofreadingStatus === 'no-new-prs') { + title = `${title} (no new PRs, manual edit required)`; + body = `${body}\n\n${getNoNewPrsWarning(version, presentInVersions)}`; + } + return { title, body }; +} + +/** + * Builds the `git push` arguments, leased to the branch tip read at the start + * of the run. An empty lease value requires that the branch doesn't exist yet. + * + * @param options0 - The options. + * @param options0.changelogBranch - The changelog branch name. + * @param options0.expectedRemoteSha - The branch tip read at the start, or + * `undefined` when the branch must not exist yet. + * @returns The `git push` arguments. + */ +export function getChangelogPushArguments({ + changelogBranch, + expectedRemoteSha, +}: { + changelogBranch: string; + expectedRemoteSha: string | undefined; +}): string[] { + const lease = expectedRemoteSha + ? `--force-with-lease=refs/heads/${changelogBranch}:${expectedRemoteSha}` + : `--force-with-lease=refs/heads/${changelogBranch}:`; + + return [ + 'push', + lease, + '--set-upstream', + 'origin', + `HEAD:refs/heads/${changelogBranch}`, + ]; +} + +/** + * Splits `--dry-run` from the positional arguments. + * + * @param argumentsList - The command-line arguments. + * @returns The dry-run flag and the remaining positional arguments. + */ +export function parseUpdateChangelogArguments(argumentsList: string[]): { + dryRun: boolean; + positionalArguments: string[]; +} { + return { + dryRun: argumentsList.includes('--dry-run'), + positionalArguments: argumentsList.filter( + (argument) => argument !== '--dry-run', + ), + }; +} + +/** + * Force-pushes the changelog branch with a lease and explains a lost race. + * + * @param options0 - The options. + * @param options0.changelogBranch - The changelog branch name. + * @param options0.expectedRemoteSha - The branch tip read at the start, or + * `undefined` when the branch must not exist yet. + */ +export function pushChangelogBranch({ + changelogBranch, + expectedRemoteSha, +}: { + changelogBranch: string; + expectedRemoteSha: string | undefined; +}): void { + try { + // The branch is rebuilt on every rerun, so history may change. The lease + // protects a concurrent human or bot edit; with no expected SHA the push + // must create the branch and fails if another actor already did. + execute( + 'git', + getChangelogPushArguments({ changelogBranch, expectedRemoteSha }), + ); + } catch (error) { + if (expectedRemoteSha) { + throw new Error( + `${changelogBranch} changed after this run began; refusing to overwrite it`, + { cause: error }, + ); + } + throw new Error( + `Failed to create ${changelogBranch}; it may have been created concurrently`, + { cause: error }, + ); + } +} + +/** + * Reports whether the remote changelog branch already has this run's + * `CHANGELOG.md`. Pushing anyway would only replace the commit with one that + * differs by the release branch's other files, which can dismiss approvals and + * rerun CI on the open PR. + * + * @param changelogBranch - The changelog branch name. + * @param expectedRemoteSha - The branch tip read at the start of the run. + * @returns `true` when the remote branch is at the expected tip and its + * `CHANGELOG.md` matches `HEAD`'s. + */ +export function hasSameChangelogAsRemoteBranch( + changelogBranch: string, + expectedRemoteSha: string, +): boolean { + const remoteRef = `origin/${changelogBranch}`; + try { + return ( + capture('git', ['rev-parse', remoteRef]) === expectedRemoteSha && + capture('git', ['rev-parse', 'HEAD:CHANGELOG.md']) === + capture('git', ['rev-parse', `${remoteRef}:CHANGELOG.md`]) + ); + } catch { + return false; + } +} + +/** + * Edits the open changelog PR, or creates a draft one when none is open. + * + * @param options0 - The options. + * @param options0.changelogBranch - The PR head branch. + * @param options0.releaseBranch - The PR base branch. + * @param options0.presentation - The PR title and body. + * @param options0.proofreadingStatus - The proofreading outcome of this run. + * @param options0.replacesWholeSection - Whether this run proofread the whole + * section again. + */ +function createOrUpdateChangelogPr({ + changelogBranch, + releaseBranch, + presentation, + proofreadingStatus, + replacesWholeSection, +}: { + changelogBranch: string; + releaseBranch: string; + presentation: ChangelogPrPresentation; + proofreadingStatus: ProofreadingStatus; + replacesWholeSection: boolean; +}): void { + const existingPr = getOpenChangelogPr(changelogBranch, releaseBranch); + if (existingPr) { + if ( + shouldKeepChangelogPrPresentation({ + existingTitle: existingPr.title, + proofreadingStatus, + replacesWholeSection, + }) + ) { + return; + } + execute('gh', [ + 'pr', + 'edit', + existingPr.number, + '--title', + presentation.title, + '--body', + presentation.body, + ]); + return; + } + + execute('gh', [ + 'pr', + 'create', + '--draft', + '--title', + presentation.title, + '--body', + presentation.body, + '--base', + releaseBranch, + '--head', + changelogBranch, + ]); +} + +/** + * Commits `CHANGELOG.md`, then pushes the branch and creates or updates the PR. + * Does nothing when the branch already matches the release branch. + * + * @param options0 - The options. + * @param options0.version - The version being released. + * @param options0.changelogBranch - The changelog branch name. + * @param options0.releaseBranch - The target release branch. + * @param options0.expectedRemoteSha - The changelog branch tip read at the start, + * or `undefined` when it does not exist. + * @param options0.proofreading - The proofreading outcome. + * @param options0.replacesWholeSection - Whether this run proofread the whole + * section again. + * @param options0.markUnproofread - Whether the commit records that the whole + * section was generated and its proofreading failed. + * @param options0.generatedThrough - The release tip the commit was generated + * from. + * @param options0.assertSourcesUnchanged - Throws when a source ref moved during + * the run. + */ +function commitAndPushChangelog({ + version, + changelogBranch, + releaseBranch, + expectedRemoteSha, + proofreading, + replacesWholeSection, + markUnproofread, + generatedThrough, + assertSourcesUnchanged, +}: { + version: string; + changelogBranch: string; + releaseBranch: string; + expectedRemoteSha: string | undefined; + proofreading: ProofreadingResult; + replacesWholeSection: boolean; + markUnproofread: boolean; + generatedThrough: string; + assertSourcesUnchanged: () => void; +}): void { + const unexpectedChanges = capture('git', [ + 'status', + '--porcelain', + '--untracked-files=no', + ]) + .split(/\r?\n/u) + .filter(Boolean) + .filter((line) => !line.endsWith('CHANGELOG.md')); + if (unexpectedChanges.length > 0) { + throw new Error( + `Refusing to commit unexpected tracked changes:\n${unexpectedChanges.join('\n')}`, + ); + } + + execute('git', ['add', '--', 'CHANGELOG.md']); + const changesCommitted = !succeeds('git', [ + 'diff', + '--cached', + '--quiet', + '--', + 'CHANGELOG.md', + ]); + if (changesCommitted) { + const trailers = [ + `${GENERATED_THROUGH_TRAILER_KEY}: ${generatedThrough}`, + ...(markUnproofread ? [UNPROOFREAD_TRAILER] : []), + ]; + execute('git', [ + 'commit', + '-m', + `update changelog for ${version}`, + '-m', + trailers.join('\n'), + ]); + } else { + console.log('Changelog branch and release branch are already in sync.'); + return; + } + + assertSourcesUnchanged(); + if ( + expectedRemoteSha && + hasSameChangelogAsRemoteBranch(changelogBranch, expectedRemoteSha) + ) { + console.log( + `origin/${changelogBranch} already has this content; not pushing.`, + ); + } else { + pushChangelogBranch({ changelogBranch, expectedRemoteSha }); + } + createOrUpdateChangelogPr({ + changelogBranch, + releaseBranch, + proofreadingStatus: proofreading.status, + replacesWholeSection, + presentation: getChangelogPrPresentation({ + version, + changelogBranch, + proofreadingStatus: proofreading.status, + ...(proofreading.stage ? { proofreadingStage: proofreading.stage } : {}), + ...(proofreading.presentInVersions + ? { presentInVersions: proofreading.presentInVersions } + : {}), + }), + }); +} + +/** + * Updates the changelog PR for a release branch, or only checks that it could + * with `--dry-run`. + */ +function main(): void { + const { dryRun, positionalArguments } = parseUpdateChangelogArguments( + process.argv.slice(2), + ); + const [ + releaseBranch, + platform = 'extension', + repositoryUrl, + changelogBranchInput = '', + versionInput = '', + ] = positionalArguments; + if (!releaseBranch || !repositoryUrl) { + throw new Error( + 'Usage: update-release-changelog.mts [--dry-run] [platform] [changelog-branch] [version]', + ); + } + + const parsedReleaseBranch = parseReleaseBranch(releaseBranch); + const version = versionInput || parsedReleaseBranch?.version; + if (!version) { + throw new Error( + `Release branch ${releaseBranch} does not match a supported release branch name and no version was provided`, + ); + } + + const readChangelog = () => readFileSync('CHANGELOG.md', 'utf8'); + const writeChangelog = (content: string) => + writeFileSync('CHANGELOG.md', content); + const readCurrentReleaseSection = () => + extractReleaseSection(readChangelog(), version).section; + const writeCurrentReleaseSection = (section: string) => + writeChangelog(replaceReleaseSection(readChangelog(), version, section)); + + const authorEmail = + process.env.GIT_AUTHOR_EMAIL ?? 'metamaskbot@users.noreply.github.com'; + configureGit(process.env.GIT_AUTHOR_NAME ?? 'metamaskbot', authorEmail); + // The proofreader checks commits against origin/main. + for (const branch of [releaseBranch, 'stable', 'main']) { + execute('git', ['fetch', 'origin', branch]); + } + // Every release branch is a candidate predecessor for the baseline. + execute('git', [ + 'fetch', + '--prune', + 'origin', + '+refs/heads/release/*:refs/remotes/origin/release/*', + ]); + ensureReleaseBranch(releaseBranch); + const targetReleaseSha = capture('git', [ + 'rev-parse', + `origin/${releaseBranch}`, + ]); + + const changelogBranch = + changelogBranchInput || determineChangelogBranch(version); + const changelogBranchSha = getRemoteBranchSha(changelogBranch); + if (changelogBranchSha) { + execute('git', ['fetch', 'origin', changelogBranch]); + } + + const baseline = selectChangelogBaseline({ + version, + releaseBranch, + dependencies: { + hasReleaseHeading, + getStableVersions, + getReleaseBranches, + }, + }); + console.log( + `Using ${baseline.kind} changelog baseline ${baseline.ref} for ${baseline.version}.`, + ); + + const baselineSha = capture('git', ['rev-parse', baseline.ref]); + if (!targetReleaseSha || !baselineSha) { + throw new Error('Unable to resolve the selected target or baseline SHA'); + } + + // Once the changelog PR has merged, the release branch holds the section. + const existingTargetReleaseSection = hasReleaseHeading( + `origin/${releaseBranch}`, + version, + ) + ? trimTrailingWhitespace( + readReleaseSectionAtRef(`origin/${releaseBranch}`, version), + ) + : undefined; + + const reviewedCurrentReleaseSection = getReviewedCurrentReleaseSection({ + changelogBranch, + changelogBranchSha, + releaseBranch, + version, + allowMissingOpenPr: baseline.kind === 'target', + isUpdaterOnlyBranch: () => + hasOnlyUpdaterCommits({ + version, + authorEmail, + commits: getChangelogBranchCommits(changelogBranch, releaseBranch), + }), + }); + + // An unproofread PR nobody has edited is regenerated whole, so a transient + // AI failure does not leave its entries unproofread for good. + const openChangelogPr = reviewedCurrentReleaseSection + ? getOpenChangelogPr(changelogBranch, releaseBranch) + : undefined; + const regenerateWholeSection = + openChangelogPr !== undefined && + isUntouchedUnproofreadChangelogPr({ + title: openChangelogPr.title, + version, + authorEmail, + commits: getChangelogBranchCommits(changelogBranch, releaseBranch), + }); + const preservedReviewedSection = regenerateWholeSection + ? undefined + : reviewedCurrentReleaseSection; + + if (regenerateWholeSection) { + console.log( + `Open changelog PR for ${version} is unproofread and unedited; proofreading the whole section again.`, + ); + } else if (reviewedCurrentReleaseSection) { + console.log( + `Preserving reviewed ${version} section from open changelog branch origin/${changelogBranch}.`, + ); + } + + rebuildChangelogBranch({ changelogBranch, releaseBranch, baseline }); + writeChangelog(removeNewerReleases(readChangelog(), version)); + if (preservedReviewedSection) { + writeChangelog( + mergeCurrentReleaseSection({ + changelog: readChangelog(), + currentReleaseSection: preservedReviewedSection, + version, + }), + ); + } + console.log(`Generating changelog for ${platform} ${version}.`); + const { skippedPrNumbers, acceptedCommitsByPr } = generateChangelog({ + repositoryUrl, + version, + }); + + // Lines already on the release branch, or reviewed in the open changelog PR, + // are never reworded; only newly generated PRs go to the proofreader. + const preservedSection = + preservedReviewedSection ?? existingTargetReleaseSection; + const generatedSection = readCurrentReleaseSection(); + // A PR the last run generated but the reviewer removed stays removed. + const generatedThrough = preservedReviewedSection + ? getChangelogBranchCommits(changelogBranch, releaseBranch).find( + (commit) => commit.generatedThrough, + )?.generatedThrough + : undefined; + const deletedPrNumbers = + preservedReviewedSection && generatedThrough + ? getDeletedPrNumbers({ + acceptedCommitsByPr, + generatedThrough, + existingPrNumbers: new Set(getPrNumbers(preservedReviewedSection)), + }) + : undefined; + const newEntriesSection = preservedSection + ? getNewReleaseEntriesSection({ + existingSection: preservedSection, + generatedSection, + version, + repositoryUrl, + ...(deletedPrNumbers ? { deletedPrNumbers } : {}), + }) + : generatedSection; + + if (preservedSection) { + writeCurrentReleaseSection(newEntriesSection ?? preservedSection); + } + + let proofreading: ProofreadingResult; + // Checked before `unchanged`: a later push to such a release finds nothing + // new either, and must keep asking for the manual edit instead of failing. + if (isReleaseSectionEmpty(readChangelog(), version)) { + // A hotfix or OTA release may start with no PRs, or only cherry-picks that + // an earlier release already lists; the draft PR asks a human to write the + // section. + const presentInVersions = findReleaseVersionsContainingPrs( + readChangelog(), + skippedPrNumbers, + version, + repositoryUrl, + ); + console.warn( + `No new PRs for ${version}; skipping the clean-room proofread and requesting a manual edit.`, + ); + proofreading = { status: 'no-new-prs', presentInVersions }; + } else if (newEntriesSection === undefined) { + proofreading = { status: 'unchanged' }; + } else { + // A generator duplicate is repairable by the clean-room step, but a missing + // PR reference is never safe to send to it or commit. + validateGeneratedReleaseSection(readChangelog(), version, true); + + proofreading = runCleanRoomProofread({ repositoryUrl, version }); + // The fallback must still meet the final one-reference-per-PR contract. + // With a preserved section, only the new entries are checked: the + // reviewed ones are the reviewer's to word and organize. + validateGeneratedReleaseSection(readChangelog(), version); + const restoredSection = restoreGeneratedCategories({ + generatedSection: newEntriesSection, + cleanedSection: readCurrentReleaseSection(), + version, + }); + writeCurrentReleaseSection( + preservedSection + ? mergeNewReleaseEntries({ + existingSection: preservedSection, + newEntriesSection: restoredSection, + version, + }) + : restoredSection, + ); + } + runChangelogValidation(proofreading, version); + + const assertSourcesUnchanged = () => { + assertRemoteRefUnchanged({ + ref: `origin/${releaseBranch}`, + expectedSha: targetReleaseSha, + description: 'Target release branch', + }); + assertRemoteRefUnchanged({ + ref: baseline.ref, + expectedSha: baselineSha, + description: 'Selected baseline', + }); + if (reviewedCurrentReleaseSection && changelogBranchSha) { + assertRemoteRefUnchanged({ + ref: `origin/${changelogBranch}`, + expectedSha: changelogBranchSha, + description: 'Reviewed changelog branch', + }); + } + }; + + if (dryRun) { + // Preflight the same source snapshots used for publication, but leave all + // commits, remote branches, and pull requests unchanged. + assertSourcesUnchanged(); + console.log( + `Dry run passed for ${releaseBranch}; no commit, push, or PR update was performed.`, + ); + return; + } + commitAndPushChangelog({ + version, + changelogBranch, + releaseBranch, + expectedRemoteSha: changelogBranchSha, + proofreading, + replacesWholeSection: regenerateWholeSection, + markUnproofread: + proofreading.status === 'failed' && !preservedReviewedSection, + generatedThrough: targetReleaseSha, + assertSourcesUnchanged, + }); +} + +// Run only when executed directly, not when the tests import this module. +if ( + process.argv[1] && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + main(); +} diff --git a/.github/scripts/update-release-changelog.sh b/.github/scripts/update-release-changelog.sh old mode 100755 new mode 100644 index 8d4754b4..e756212b --- a/.github/scripts/update-release-changelog.sh +++ b/.github/scripts/update-release-changelog.sh @@ -1,167 +1,50 @@ #!/usr/bin/env bash -# Updates (or creates) the changelog branch/PR for a given release branch. -# This script duplicates the logic of create_changelog_pr() from create-platform-release-pr.sh -# so it can run standalone (e.g. from automation) without sourcing the original script. +# Deprecated: forwards the old positional interface to update-release-changelog.mts +# for callers that still run this script from a github-tools checkout (for example +# Mobile's wrapper). Use the `update-release-changelog` action instead. # -# Required arguments: -# 1. release_branch - Name of the release branch (e.g., release/6.20.0) -# 2. platform - Target platform (extension | mobile). Defaults to extension if empty. -# 3. repository_url - Full HTTPS URL for the invoking repository. +# Arguments, unchanged from before: +# 1. release_branch - Release branch name (required) +# 2. platform - extension | mobile (defaults to extension) +# 3. repository_url - Full HTTPS URL of the invoking repository (required) +# 4. previous_version_ref - Accepted and ignored +# 5. changelog_branch - Changelog branch name (optional) +# 6. version - Semantic version (optional, derived from the branch) # -# Optional arguments: -# 4. previous_version_ref - Previous version reference (branch/tag/SHA). Defaults to "null" -# (Hotfix mode) if omitted. If provided as an empty string, -# it indicates a regular release. -# 5. changelog_branch - Specific name for the changelog branch. If not provided, it will be -# determined automatically (checking for existing release/ or chore/ branches). -# 6. version - The semantic version (e.g., 6.20.0). If not provided, it will be -# derived from the release_branch name. -# -# Environment (optional): -# GITHUB_TOKEN - Token for GitHub CLI operations (falls back to gh auth config) -# GIT_AUTHOR_NAME - Commit author name (defaults to metamaskbot) -# GIT_AUTHOR_EMAIL - Commit author email (defaults to metamaskbot@users.noreply.github.com) -# TEST_ONLY - When set to "true" the helper mirrors release automation test mode. +# Runs in the current directory, which must be a checkout of the invoking repository. set -euo pipefail -# Sourcing helper functions -SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -# shellcheck source=.github/scripts/utils.sh -source "${SCRIPT_DIR}/utils.sh" - RELEASE_BRANCH="${1:?release branch is required}" PLATFORM="${2:-extension}" REPOSITORY_URL="${3:?repository url is required}" -PREVIOUS_VERSION_REF="${4-null}" CHANGELOG_BRANCH_INPUT="${5:-}" VERSION_INPUT="${6:-}" -AUTHOR_NAME="${GIT_AUTHOR_NAME:-metamaskbot}" -AUTHOR_EMAIL="${GIT_AUTHOR_EMAIL:-metamaskbot@users.noreply.github.com}" -TEST_ONLY="${TEST_ONLY:-false}" - -# ----------------------------------------------------------------- -# ----------------------------------------------------------------- - -# --- Local helper functions specific to this script --- - -ensure_release_branch() { - # Sync the release branch to the latest remote state so changelog commits - # build off the current release tip. - local release_branch="$1" - git fetch origin "${release_branch}" - git checkout "${release_branch}" - git reset --hard "origin/${release_branch}" -} - -determine_changelog_branch() { - # Prefer an existing remote changelog branch (release-changelog/), - # falling back to the -fallback naming or the preferred default if none exist. - local version="$1" - local preferred="release-changelog/${version}" - if git ls-remote --exit-code origin "${preferred}" > /dev/null 2>&1; then - echo "${preferred}" - elif git ls-remote --exit-code origin "release-changelog/${version}-fallback" > /dev/null 2>&1; then - echo "release-changelog/${version}-fallback" - else - echo "${preferred}" - fi -} - - -commit_and_push_changelog() { - # Commit changelog updates (with the same messaging as the release script), - # push the branch, and ensure a draft PR exists targeting the release branch. - local version="$1" - local previous_version_ref="$2" - local changelog_branch="$3" - local release_branch="$4" - - echo "Adding and committing changes.." - local commit_msg="update changelog for ${version}" - if [[ "${previous_version_ref,,}" == "null" ]]; then - commit_msg="${commit_msg} (hotfix - no test plan)" - fi - - local changes_committed=false - if git commit -am "${commit_msg}"; then - changes_committed=true - else - echo "No changes detected; skipping commit." - fi - - # Check if there are any differences between the changelog branch and release branch - if ! ${changes_committed}; then - echo "Checking for differences between ${changelog_branch} and ${release_branch}.." - if ! git diff --quiet "origin/${release_branch}" "HEAD"; then - echo "Differences found between branches; proceeding with PR creation." - else - echo "No differences between ${changelog_branch} and ${release_branch}." - echo "Branches are already in sync; skipping PR creation." - echo "Changelog workflow completed successfully (no updates needed)." - return 0 - fi - fi - - local pr_body="This PR updates the change log for ${version}." - if [[ "${previous_version_ref,,}" == "null" ]]; then - pr_body="${pr_body} (Hotfix - no test plan generated.)" - fi +GITHUB_TOOLS_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" - push_branch_with_handling "${changelog_branch}" - create_pr_if_not_exists "${changelog_branch}" "release: ${changelog_branch}" "${pr_body}" "${release_branch}" "" "search" - echo "Changelog PR Ready" -} - -# ----------------------------------------------------------------- - -# Derive the semantic version from the branch naming convention (release/x.y.z only). -if [[ -n "${VERSION_INPUT}" ]]; then - VERSION="${VERSION_INPUT}" -else - if [[ "${RELEASE_BRANCH}" =~ ^release/([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then - VERSION="${BASH_REMATCH[1]}" - else - echo "Release branch '${RELEASE_BRANCH}' does not match known patterns and no version was provided." >&2 - exit 1 - fi -fi - -GITHUB_REPOSITORY_URL="${REPOSITORY_URL}" - -configure_git "${AUTHOR_NAME}" "${AUTHOR_EMAIL}" - -ensure_release_branch "${RELEASE_BRANCH}" - -if [[ -n "${CHANGELOG_BRANCH_INPUT}" ]]; then - CHANGELOG_BRANCH="${CHANGELOG_BRANCH_INPUT}" -else - CHANGELOG_BRANCH=$(determine_changelog_branch "${VERSION}") -fi - -checkout_or_create_branch "${CHANGELOG_BRANCH}" "${RELEASE_BRANCH}" - -echo "Generating changelog for ${PLATFORM} ${VERSION}.." - -# Use the calling repository's changelog policy when it defines the script. -# Preserve the legacy invocation for consumers that have not adopted it yet. -if yarn run --silent update-changelog --help >/dev/null 2>&1; then - yarn update-changelog \ - --repo "${GITHUB_REPOSITORY_URL}" \ - --currentVersion "${VERSION}" -else - echo "No update-changelog script found; using legacy auto-changelog invocation." >&2 - yarn auto-changelog update --rc \ - --repo "${GITHUB_REPOSITORY_URL}" \ - --currentVersion "${VERSION}" \ - --autoCategorize \ - --useChangelogEntry \ - --useShortPrLink \ - --requirePrNumbers +# The updater is TypeScript that Node runs directly, which needs type stripping. +node_version="$(node --version)" +IFS=. read -r node_major node_minor _ <<<"${node_version#v}" +if (( node_major < 22 || (node_major == 22 && node_minor < 18) )); then + echo "Error: update-release-changelog needs Node 22.18 or newer, found ${node_version}" >&2 + exit 1 fi -# commits.csv generation removed (no longer required) - -commit_and_push_changelog "${VERSION}" "${PREVIOUS_VERSION_REF}" "${CHANGELOG_BRANCH}" "${RELEASE_BRANCH}" +# Corepack resolves Yarn from the working directory, so install from the +# github-tools root to use its own pinned Yarn and lockfile. +( + cd "$GITHUB_TOOLS_ROOT" + corepack enable + corepack yarn install --immutable +) + +export AUTO_CHANGELOG_CLI="${GITHUB_TOOLS_ROOT}/node_modules/@metamask/auto-changelog/dist/cli.js" + +exec node "${GITHUB_TOOLS_ROOT}/.github/scripts/update-release-changelog.mts" \ + "$RELEASE_BRANCH" \ + "$PLATFORM" \ + "$REPOSITORY_URL" \ + "$CHANGELOG_BRANCH_INPUT" \ + "$VERSION_INPUT" diff --git a/package.json b/package.json index aa3fe5b4..65f15faf 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "update-release-sheet": "node .github/scripts/update-release-sheet.mjs" }, "dependencies": { - "@metamask/auto-changelog": "^6.2.0", + "@metamask/auto-changelog": "^7.0.1", "@metamask/utils": "^7.1.0", "@octokit/graphql": "^7.0.1", "@octokit/request": "^8.1.1", diff --git a/yarn.lock b/yarn.lock index ccaf96e5..7debf608 100644 --- a/yarn.lock +++ b/yarn.lock @@ -941,15 +941,15 @@ __metadata: languageName: node linkType: hard -"@metamask/auto-changelog@npm:^6.2.0": - version: 6.2.0 - resolution: "@metamask/auto-changelog@npm:6.2.0" +"@metamask/auto-changelog@npm:^7.0.1": + version: 7.0.1 + resolution: "@metamask/auto-changelog@npm:7.0.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" + execa: "npm:^8.0.1" + semver: "npm:^7.8.5" + yargs: "npm:^17.7.2" peerDependencies: oxfmt: ^0.45.0 prettier: ">=3.0.0" @@ -959,8 +959,8 @@ __metadata: prettier: optional: true bin: - auto-changelog: dist/cli.mjs - checksum: 10/9cefcf494dd68d0ee689b54f159a2b78ed41ceb5964288f7237a470f88a1beabb4d4f6c28c596ae4960af2285c95713bea3b75931e632d0aa8923c89d2484b6d + auto-changelog: dist/cli.js + checksum: 10/ac0ee2077708ad3d7413c78330984d626ce4f101a6862256d9c2c54fd87bb876bd0f3419ca04dc53bb94f0724bbbe9c257bbe00c5c175f16e97989f81c924ea3 languageName: node linkType: hard @@ -1020,7 +1020,7 @@ __metadata: dependencies: "@lavamoat/allow-scripts": "npm:^2.3.1" "@lavamoat/preinstall-always-fail": "npm:^1.0.0" - "@metamask/auto-changelog": "npm:^6.2.0" + "@metamask/auto-changelog": "npm:^7.0.1" "@metamask/eslint-config": "npm:^12.0.0" "@metamask/eslint-config-jest": "npm:^12.0.0" "@metamask/eslint-config-nodejs": "npm:^12.0.0" @@ -4080,7 +4080,7 @@ __metadata: languageName: node linkType: hard -"execa@npm:^5.0.0, execa@npm:^5.1.1": +"execa@npm:^5.0.0": version: 5.1.1 resolution: "execa@npm:5.1.1" dependencies: @@ -4097,6 +4097,23 @@ __metadata: languageName: node linkType: hard +"execa@npm:^8.0.1": + version: 8.0.1 + resolution: "execa@npm:8.0.1" + dependencies: + cross-spawn: "npm:^7.0.3" + get-stream: "npm:^8.0.1" + human-signals: "npm:^5.0.0" + is-stream: "npm:^3.0.0" + merge-stream: "npm:^2.0.0" + npm-run-path: "npm:^5.1.0" + onetime: "npm:^6.0.0" + signal-exit: "npm:^4.1.0" + strip-final-newline: "npm:^3.0.0" + checksum: 10/d2ab5fe1e2bb92b9788864d0713f1fce9a07c4594e272c0c97bc18c90569897ab262e4ea58d27a694d288227a2e24f16f5e2575b44224ad9983b799dc7f1098d + languageName: node + linkType: hard + "execa@npm:^9.5.2": version: 9.5.2 resolution: "execa@npm:9.5.2" @@ -4573,6 +4590,13 @@ __metadata: languageName: node linkType: hard +"get-stream@npm:^8.0.1": + version: 8.0.1 + resolution: "get-stream@npm:8.0.1" + checksum: 10/dde5511e2e65a48e9af80fea64aff11b4921b14b6e874c6f8294c50975095af08f41bfb0b680c887f28b566dd6ec2cb2f960f9d36a323359be324ce98b766e9e + languageName: node + linkType: hard + "get-stream@npm:^9.0.0": version: 9.0.1 resolution: "get-stream@npm:9.0.1" @@ -4919,6 +4943,13 @@ __metadata: languageName: node linkType: hard +"human-signals@npm:^5.0.0": + version: 5.0.0 + resolution: "human-signals@npm:5.0.0" + checksum: 10/30f8870d831cdcd2d6ec0486a7d35d49384996742052cee792854273fa9dd9e7d5db06bb7985d4953e337e10714e994e0302e90dc6848069171b05ec836d65b0 + languageName: node + linkType: hard + "human-signals@npm:^8.0.0": version: 8.0.1 resolution: "human-signals@npm:8.0.1" @@ -5256,6 +5287,13 @@ __metadata: languageName: node linkType: hard +"is-stream@npm:^3.0.0": + version: 3.0.0 + resolution: "is-stream@npm:3.0.0" + checksum: 10/172093fe99119ffd07611ab6d1bcccfe8bc4aa80d864b15f43e63e54b7abc71e779acd69afdb854c4e2a67fdc16ae710e370eda40088d1cfc956a50ed82d8f16 + languageName: node + linkType: hard + "is-stream@npm:^4.0.1": version: 4.0.1 resolution: "is-stream@npm:4.0.1" @@ -6239,6 +6277,13 @@ __metadata: languageName: node linkType: hard +"mimic-fn@npm:^4.0.0": + version: 4.0.0 + resolution: "mimic-fn@npm:4.0.0" + checksum: 10/995dcece15ee29aa16e188de6633d43a3db4611bcf93620e7e62109ec41c79c0f34277165b8ce5e361205049766e371851264c21ac64ca35499acb5421c2ba56 + languageName: node + linkType: hard + "mimic-response@npm:^1.0.0": version: 1.0.1 resolution: "mimic-response@npm:1.0.1" @@ -6550,6 +6595,15 @@ __metadata: languageName: node linkType: hard +"npm-run-path@npm:^5.1.0": + version: 5.3.0 + resolution: "npm-run-path@npm:5.3.0" + dependencies: + path-key: "npm:^4.0.0" + checksum: 10/ae8e7a89da9594fb9c308f6555c73f618152340dcaae423e5fb3620026fefbec463618a8b761920382d666fa7a2d8d240b6fe320e8a6cdd54dc3687e2b659d25 + languageName: node + linkType: hard + "npm-run-path@npm:^6.0.0": version: 6.0.0 resolution: "npm-run-path@npm:6.0.0" @@ -6650,6 +6704,15 @@ __metadata: languageName: node linkType: hard +"onetime@npm:^6.0.0": + version: 6.0.0 + resolution: "onetime@npm:6.0.0" + dependencies: + mimic-fn: "npm:^4.0.0" + checksum: 10/0846ce78e440841335d4e9182ef69d5762e9f38aa7499b19f42ea1c4cd40f0b4446094c455c713f9adac3f4ae86f613bb5e30c99e52652764d06a89f709b3788 + languageName: node + linkType: hard + "optionator@npm:^0.9.3": version: 0.9.3 resolution: "optionator@npm:0.9.3" @@ -7419,12 +7482,12 @@ __metadata: languageName: node linkType: hard -"semver@npm:7.x, semver@npm:^7.0.0, semver@npm:^7.3.2, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.3.8, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.7.1, semver@npm:^7.7.2": - version: 7.7.2 - resolution: "semver@npm:7.7.2" +"semver@npm:7.x, semver@npm:^7.0.0, semver@npm:^7.3.2, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.3.8, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.7.1, semver@npm:^7.7.2, semver@npm:^7.8.5": + version: 7.8.5 + resolution: "semver@npm:7.8.5" bin: semver: bin/semver.js - checksum: 10/7a24cffcaa13f53c09ce55e05efe25cd41328730b2308678624f8b9f5fc3093fc4d189f47950f0b811ff8f3c3039c24a2c36717ba7961615c682045bf03e1dda + checksum: 10/9b01d2ff11e6e4a4539b7ca3c5f280c8704cb397a28504469f2ed4f00ad2194748d756647362a9712fff30984d15772ab7f083108c2fb508e2096ae9e708f22c languageName: node linkType: hard @@ -7850,6 +7913,13 @@ __metadata: languageName: node linkType: hard +"strip-final-newline@npm:^3.0.0": + version: 3.0.0 + resolution: "strip-final-newline@npm:3.0.0" + checksum: 10/23ee263adfa2070cd0f23d1ac14e2ed2f000c9b44229aec9c799f1367ec001478469560abefd00c5c99ee6f0b31c137d53ec6029c53e9f32a93804e18c201050 + languageName: node + linkType: hard + "strip-final-newline@npm:^4.0.0": version: 4.0.0 resolution: "strip-final-newline@npm:4.0.0" @@ -8583,9 +8653,9 @@ __metadata: languageName: node linkType: hard -"yargs@npm:^17.0.1, yargs@npm:^17.3.1": - version: 17.7.2 - resolution: "yargs@npm:17.7.2" +"yargs@npm:^17.3.1, yargs@npm:^17.7.2": + version: 17.7.3 + resolution: "yargs@npm:17.7.3" dependencies: cliui: "npm:^8.0.1" escalade: "npm:^3.1.1" @@ -8594,7 +8664,7 @@ __metadata: string-width: "npm:^4.2.3" y18n: "npm:^5.0.5" yargs-parser: "npm:^21.1.1" - checksum: 10/abb3e37678d6e38ea85485ed86ebe0d1e3464c640d7d9069805ea0da12f69d5a32df8e5625e370f9c96dd1c2dc088ab2d0a4dd32af18222ef3c4224a19471576 + checksum: 10/a3826798c03b159e139d0580a3b2733953889a9a1bac8e4e1ca7a1a249b55315b213c323a6a1dbdb305f6e59496a9eaa810742c87e34abcf1a0584d8f59212a1 languageName: node linkType: hard