Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions .github/actions/check-merge-queue-changelogs/action.yml
Original file line number Diff line number Diff line change
@@ -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
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a plain JS file so we can just call node check-changelog-diff.cjs without installing dependencies etc.

Original file line number Diff line number Diff line change
@@ -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 <base-file> <pr-file> <merged-file>',
);

// 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<string>} 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<string>} oldLines - The base changelog content.
* @param {Set<string>} 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;
}
11 changes: 11 additions & 0 deletions .github/workflows/lint-build-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down