Skip to content

Commit fc38e95

Browse files
authored
ci: Add action to validate changelog diffs after merging (#7525)
## Explanation This fixes an issue in changelogs where changelog entries may be incorrectly moved after merging into the main branch. Given the following (partial) changelog for example: ```md ## [Unreleased] ### Changed - Changelog entry A ([#1234](...)) - Changelog entry B ([#5678](...)) - Changelog entry C ([#1337](...)) ``` After a release, this changelog may look like this: ```diff ## [Unreleased] + ## [1.0.0] ### Changed - Changelog entry A ([#1234](...)) - Changelog entry B ([#5678](...)) - Changelog entry C ([#1337](...)) ``` But if another PR based on the original changelog also adds an entry to the unreleased section like this: ```diff ## [Unreleased] ### Changed - Changelog entry A ([#1234](...)) - Changelog entry B ([#5678](...)) - Changelog entry C ([#1337](...)) + - Changelog entry D ([#0000](...)) ``` The final changelog will look like this, since Git can merge the entry based on the other entries around it: ```md ## [Unreleased] ## [1.0.0] ### Changed - Changelog entry A ([#1234](...)) - Changelog entry B ([#5678](...)) - Changelog entry C ([#1337](...)) - Changelog entry D ([#0000](...)) ``` Here it appears that the release included "entry D", but in reality that was meant to be under the unreleased section. To address this issue, I've added a simple workflow that checks for changelog diffs. It uses a minimal algorithm to parse the markdown files, find entries that are included in unreleased originally, and checks if they still are after merging. If not, the workflow will fail and show an error such as this: > Checking changelog file: packages/some-package/CHANGELOG.md > The following lines added in the PR are missing from the "Unreleased" section after merge: > > - Changelog entry D ([#0000](...)) > > Please update your pull request and ensure that new changelog entries remain in the "Unreleased" section. Long term we should solve this problem by using changesets instead, in which case the unreleased changes will never be merged or conflict with each other, but until we figure out how to implement those in our release workflows, we can use this workflow. ## References <!-- Are there any issues that this pull request is tied to? Are there other links that reviewers should consult to understand these changes better? Are there client or consumer pull requests to adopt any breaking changes? For example: * Fixes #12345 * Related to #67890 --> ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Low risk CI-only change that adds a new workflow gate; main failure mode is false positives/negatives that could block merges. > > **Overview** > Adds a new composite GitHub Action (`check-merge-queue-changelogs`) and Node script to detect when changelog lines introduced in a PR get moved out of the `## [Unreleased]` section after merge (a common merge-queue conflict artifact). > > Updates `lint-build-test.yml` to run a new `validate-changelog-diffs` job on `pull_request` and `merge_group` events, failing the workflow when modified `CHANGELOG.md` files lose newly-added Unreleased entries in the merged result. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit ef5a189. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 9f69185 commit fc38e95

3 files changed

Lines changed: 209 additions & 0 deletions

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
name: Check merge queue changelogs
2+
description: Check if the changelog was incorrectly merged in a merge queue
3+
pull request.
4+
5+
inputs:
6+
github-token:
7+
description: The GitHub token to use for authentication.
8+
required: false
9+
default: ${{ github.token }}
10+
11+
runs:
12+
using: composite
13+
steps:
14+
- name: Checkout repository
15+
uses: actions/checkout@v6
16+
with:
17+
fetch-depth: 0
18+
19+
- name: Get pull request number
20+
id: pr-number
21+
uses: actions/github-script@v8
22+
env:
23+
HEAD_REF: ${{ github.event.pull_request.head.ref || github.event.merge_group.head_ref }}
24+
with:
25+
github-token: ${{ inputs.github-token }}
26+
script: |
27+
const { HEAD_REF } = process.env;
28+
29+
if (context.eventName === 'pull_request') {
30+
const prNumber = context.payload.pull_request.number;
31+
return core.setOutput('pr-number', prNumber);
32+
}
33+
34+
const match = HEAD_REF.match(/\/pr-([0-9]+)-/u);
35+
if (!match) {
36+
return core.setFailed(`Could not extract pull request number from head ref: "${HEAD_REF}".`);
37+
}
38+
39+
const number = parseInt(match[1], 10);
40+
core.setOutput('pr-number', number);
41+
42+
- name: Get pull request branch
43+
id: pr-branch
44+
shell: bash
45+
env:
46+
REPOSITORY: ${{ github.repository }}
47+
PR_NUMBER: ${{ steps.pr-number.outputs.pr-number }}
48+
GH_TOKEN: ${{ inputs.github-token }}
49+
run: |
50+
BRANCH=$(gh api "/repos/${REPOSITORY}/pulls/${PR_NUMBER}" --jq=.head.ref)
51+
echo "pr-branch=$BRANCH" >> "$GITHUB_OUTPUT"
52+
53+
- name: Check changelog changes
54+
id: changelog-check
55+
shell: bash
56+
env:
57+
BASE_REF: ${{ github.event.pull_request.base.ref || github.event.merge_group.base_ref }}
58+
PR_BRANCH: ${{ steps.pr-branch.outputs.pr-branch }}
59+
ACTION_PATH: ${{ github.action_path }}
60+
run: |
61+
set -euo pipefail
62+
63+
# Strip invalid prefix from `BASE_REF`
64+
# It comes prefixed with `refs/heads/`, but the branch is not checked out in this context
65+
# We need to express it as a remote branch
66+
PREFIXED_REF_REGEX='refs/heads/(.+)'
67+
if [[ "$BASE_REF" =~ $PREFIXED_REF_REGEX ]]; then
68+
BASE_REF="${BASH_REMATCH[1]}"
69+
fi
70+
71+
TARGET_REF=$(git merge-base "origin/$BASE_REF" "origin/$PR_BRANCH")
72+
git fetch origin "$TARGET_REF"
73+
74+
UPDATED_CHANGELOGS=$(git diff --name-only "$TARGET_REF" "origin/$PR_BRANCH" | grep -E 'CHANGELOG\.md$' || true)
75+
if [ -n "$UPDATED_CHANGELOGS" ]; then
76+
for FILE in $UPDATED_CHANGELOGS; do
77+
if [ ! -f "$FILE" ]; then
78+
echo "Changelog file \"$FILE\" was deleted in this PR. Skipping."
79+
continue
80+
fi
81+
82+
if ! git cat-file -e "$TARGET_REF":"$FILE" 2>/dev/null; then
83+
echo "Changelog file \"$FILE\" is new in this PR. Skipping."
84+
continue
85+
fi
86+
87+
echo "Checking changelog file: $FILE"
88+
git show "$TARGET_REF":"$FILE" > /tmp/base-changelog.md
89+
git show origin/"$PR_BRANCH":"$FILE" > /tmp/pr-changelog.md
90+
91+
node "${ACTION_PATH}/check-changelog-diff.cjs" \
92+
/tmp/base-changelog.md \
93+
/tmp/pr-changelog.md \
94+
"$FILE"
95+
done
96+
else
97+
echo "No CHANGELOG.md files were modified in this PR."
98+
fi
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// This script checks that any new changelog entries added in a PR
2+
// remain in the [Unreleased] section after the PR is merged.
3+
4+
const fs = require('fs');
5+
6+
if (process.argv.length < 5) {
7+
console.error(
8+
'Usage: node check-changelog-diff.cjs <base-file> <pr-file> <merged-file>',
9+
);
10+
11+
// eslint-disable-next-line n/no-process-exit
12+
process.exit(1);
13+
}
14+
15+
/* eslint-disable n/no-sync */
16+
// The type of these is inferred as `Buffer` when using "utf-8" directly instead
17+
// of an options object. Even though it's a plain JavaScript file, it's nice to
18+
// keep the types correct.
19+
const baseContent = fs.readFileSync(process.argv[2], {
20+
encoding: 'utf-8',
21+
});
22+
23+
const prContent = fs.readFileSync(process.argv[3], {
24+
encoding: 'utf-8',
25+
});
26+
27+
const mergedContent = fs.readFileSync(process.argv[4], {
28+
encoding: 'utf-8',
29+
});
30+
/* eslint-enable n/no-sync */
31+
32+
/**
33+
* Extract the "[Unreleased]" section from the changelog content.
34+
*
35+
* This doesn't actually parse the Markdown, it just looks for the section
36+
* header and collects lines until the next section header.
37+
*
38+
* @param {string} content - The changelog content.
39+
* @returns {Set<string>} The lines in the "[Unreleased]" section as a
40+
* {@link Set}.
41+
*/
42+
function getUnreleasedSection(content) {
43+
const lines = content.split('\n');
44+
45+
let inUnreleased = false;
46+
const sectionLines = new Set();
47+
48+
for (const line of lines) {
49+
// Find unreleased header.
50+
if (line.trim().match(/^##\s+\[Unreleased\]/u)) {
51+
inUnreleased = true;
52+
continue;
53+
}
54+
55+
// Stop if we hit the next version header (## [x.x.x]).
56+
if (inUnreleased && line.trim().match(/^##\s+\[/u)) {
57+
break;
58+
}
59+
60+
// If inside the unreleased header, add lines to the set.
61+
if (inUnreleased) {
62+
sectionLines.add(line.trim());
63+
}
64+
}
65+
66+
return sectionLines;
67+
}
68+
69+
/**
70+
* Get the lines that were added in the PR content compared to the base content.
71+
*
72+
* @param {Set<string>} oldLines - The base changelog content.
73+
* @param {Set<string>} newLines - The PR changelog content.
74+
* @returns {string[]} The added lines as an array of strings.
75+
*/
76+
function getAddedLines(oldLines, newLines) {
77+
return Array.from(newLines).filter(
78+
(line) => line.length > 0 && !oldLines.has(line) && !line.startsWith('#'),
79+
);
80+
}
81+
82+
const mergedUnreleased = getUnreleasedSection(mergedContent);
83+
const addedLines = getAddedLines(
84+
getUnreleasedSection(baseContent),
85+
getUnreleasedSection(prContent),
86+
);
87+
88+
const missingLines = [];
89+
for (const line of addedLines) {
90+
if (!mergedUnreleased.has(line)) {
91+
missingLines.push(line);
92+
}
93+
}
94+
95+
if (missingLines.length > 0) {
96+
console.error(
97+
`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.`,
98+
);
99+
100+
process.exitCode = 1;
101+
}

.github/workflows/lint-build-test.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,16 @@ jobs:
7070
exit 1
7171
fi
7272
73+
validate-changelog-diffs:
74+
name: Validate changelog diffs
75+
if: github.event_name == 'pull_request' || github.event_name == 'merge_group'
76+
runs-on: ubuntu-latest
77+
steps:
78+
- name: Checkout repository
79+
uses: actions/checkout@v6
80+
- name: Validate changelog diffs
81+
uses: ./.github/actions/check-merge-queue-changelogs
82+
7383
build:
7484
name: Build
7585
runs-on: ubuntu-latest

0 commit comments

Comments
 (0)