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