diff --git a/.github/actions/check-merge-queue-changelogs/action.yml b/.github/actions/check-merge-queue-changelogs/action.yml new file mode 100644 index 00000000000..8d9be178285 --- /dev/null +++ b/.github/actions/check-merge-queue-changelogs/action.yml @@ -0,0 +1,100 @@ +name: Check merge queue changelogs +description: Check if the changelog was incorrectly merged in a merge queue + pull request. + +inputs: + github-token: + description: The GitHub token to use for authentication. + required: false + default: ${{ github.token }} + +runs: + using: composite + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Get pull request number + id: pr-number + uses: actions/github-script@v8 + env: + HEAD_REF: ${{ github.event.pull_request.head.ref || github.event.merge_group.head_ref }} + with: + github-token: ${{ inputs.github-token }} + script: | + const { EVENT_NAME, HEAD_REF } = process.env; + + if (context.eventName === 'pull_request') { + const prNumber = context.payload.pull_request.number; + return core.setOutput('pr-number', prNumber); + } + + const match = HEAD_REF.match(/\/pr-([0-9]+)-/u); + if (!match) { + return core.setFailed(`Could not extract pull request number from head ref: "${HEAD_REF}".`); + } + + const number = parseInt(match[1], 10); + core.setOutput('pr-number', number); + + - name: Get pull request branch + id: pr-branch + shell: bash + env: + REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ steps.pr-number.outputs.pr-number }} + GH_TOKEN: ${{ inputs.github-token }} + run: | + BRANCH=$(gh api "/repos/${REPOSITORY}/pulls/${PR_NUMBER}" --jq=.head.ref) + echo "pr-branch=$BRANCH" >> "$GITHUB_OUTPUT" + + - name: Check changelog changes + id: changelog-check + shell: bash + env: + HEAD_REF: ${{ github.event.pull_request.head.ref || github.event.merge_group.head_ref }} + BASE_REF: ${{ github.event.pull_request.base.ref || github.event.merge_group.base_ref }} + PR_BRANCH: ${{ steps.pr-branch.outputs.pr-branch }} + REPOSITORY: ${{ github.repository }} + ACTION_PATH: ${{ github.action_path }} + run: | + set -euo pipefail + + # Strip invalid prefix from `BASE_REF` + # It comes prefixed with `refs/heads/`, but the branch is not checked out in this context + # We need to express it as a remote branch + PREFIXED_REF_REGEX='refs/heads/(.+)' + if [[ "$BASE_REF" =~ $PREFIXED_REF_REGEX ]]; then + BASE_REF="${BASH_REMATCH[1]}" + fi + + TARGET_REF=$(git merge-base "origin/$BASE_REF" "origin/$PR_BRANCH") + git fetch origin "$TARGET_REF" + + UPDATED_CHANGELOGS=$(git diff --name-only "$TARGET_REF" "origin/$PR_BRANCH" | grep -E 'CHANGELOG\.md$' || true) + if [ -n "$UPDATED_CHANGELOGS" ]; then + for FILE in $UPDATED_CHANGELOGS; do + if [ ! -f "$FILE" ]; then + echo "Changelog file \"$FILE\" was deleted in this PR. Skipping." + continue + fi + + if ! git cat-file -e "$TARGET_REF":"$FILE" 2>/dev/null; then + echo "Changelog file \"$FILE\" is new in this PR. Skipping." + continue + fi + + echo "Checking changelog file: $FILE" + git show "$TARGET_REF":"$FILE" > /tmp/base-changelog.md + git show origin/"$PR_BRANCH":"$FILE" > /tmp/pr-changelog.md + + node "${ACTION_PATH}/check-changelog-diff.cjs" \ + /tmp/base-changelog.md \ + /tmp/pr-changelog.md \ + "$FILE" + done + else + echo "No CHANGELOG.md files were modified in this PR." + fi diff --git a/.github/actions/check-merge-queue-changelogs/check-changelog-diff.cjs b/.github/actions/check-merge-queue-changelogs/check-changelog-diff.cjs new file mode 100644 index 00000000000..857ec8a7510 --- /dev/null +++ b/.github/actions/check-merge-queue-changelogs/check-changelog-diff.cjs @@ -0,0 +1,105 @@ +// This script checks that any new changelog entries added in a PR +// remain in the [Unreleased] section after the PR is merged. + +const fs = require('fs'); + +if (process.argv.length < 5) { + console.error( + 'Usage: tsx check-changelog-diff.mts ', + ); + + // eslint-disable-next-line n/no-process-exit + process.exit(1); +} + +/* eslint-disable n/no-sync */ +// The type of these is inferred as `Buffer` when using "utf-8" directly instead +// of an options object. Even though it's a plain JavaScript file, it's nice to +// keep the types correct. +const baseContent = fs.readFileSync(process.argv[2], { + encoding: 'utf-8', +}); + +const prContent = fs.readFileSync(process.argv[3], { + encoding: 'utf-8', +}); + +const mergedContent = fs.readFileSync(process.argv[4], { + encoding: 'utf-8', +}); +/* eslint-enable n/no-sync */ + +/** + * Extract the "[Unreleased]" section from the changelog content. + * + * This doesn't actually parse the Markdown, it just looks for the section + * header and collects lines until the next section header. + * + * @param {string} content - The changelog content. + * @returns {Set} The lines in the "[Unreleased]" section as a + * {@link Set}. + */ +function getUnreleasedSection(content) { + const lines = content.split('\n'); + + let inUnreleased = false; + const sectionLines = new Set(); + + for (const line of lines) { + // Find unreleased header. + if (line.trim().match(/^##\s+\[Unreleased\]/u)) { + inUnreleased = true; + continue; + } + + // Stop if we hit the next version header (## [x.x.x]). + if (inUnreleased && line.trim().match(/^##\s+\[/u)) { + break; + } + + // If inside the unreleased header, add lines to the set. + if (inUnreleased) { + sectionLines.add(line.trim()); + } + } + + return sectionLines; +} + +/** + * Get the lines that were added in the PR content compared to the base content. + * + * @param {Set} oldLines - The base changelog content. + * @param {Set} newLines - The PR changelog content. + * @returns {string[]} The added lines as an array of strings. + */ +function getAddedLines(oldLines, newLines) { + return Array.from(newLines).filter( + (line) => + line.length > 0 && + !oldLines.has(line) && + !line.startsWith('#') && + !line.startsWith('['), + ); +} + +const mergedUnreleased = getUnreleasedSection(mergedContent); +const addedLines = getAddedLines( + getUnreleasedSection(baseContent), + getUnreleasedSection(prContent), +); + +const missingLines = []; +for (const line of addedLines) { + if (!mergedUnreleased.has(line)) { + missingLines.push(line); + } +} + +if (missingLines.length > 0) { + console.error( + `The following lines added in the PR are missing from the "Unreleased" section after merge:\n\n ${missingLines.join('\n ')}\n\nPlease update your pull request and ensure that new changelog entries remain in the "Unreleased" section.`, + ); + + process.exitCode = 1; +} diff --git a/.github/workflows/lint-build-test.yml b/.github/workflows/lint-build-test.yml index 70ac9fe5cb7..770799adbca 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -70,6 +70,17 @@ jobs: exit 1 fi + validate-changelog-diffs: + name: Validate changelog diffs + if: github.event_name == 'pull_request' || github.event_name == 'merge_group' + runs-on: ubuntu-latest + needs: prepare + steps: + - name: Checkout repository + uses: actions/checkout@v6 + - name: Validate changelog diffs + uses: ./.github/actions/check-merge-queue-changelogs + build: name: Build runs-on: ubuntu-latest