Skip to content

Commit 4bbc17e

Browse files
committed
ci: unify the changelog fragment rules
Replace the six divergent changelog-check variants with one implementation that encodes the documented merge rules. feature/*|fix/* -> release/*|maint/*|main: - the PR must add at least one changelog.d/<number>.<type>.md - every added fragment's <number> must equal the PR number - fragments already present from previously merged branches are ignored, since only files ADDED by this PR are considered - escape hatches ([trivial] title, skip-changelog label, docs: update CHANGELOG) waive only the "at least one" requirement; the number-must-match rule is always enforced on fragments that were added release/* -> main: - CHANGELOG.md must have changed (content is not inspected) - changelog.d/ must hold no fragments at the merge commit - no escape hatches apply to this path The number-must-match rule was the one documented requirement that none of the previous variants implemented. Two latent bugs are also fixed: the paths-ignore of "**.md" meant a CHANGELOG.md-only push never ran the check at all, and three backends skipped release merges outright instead of applying the release rules.
1 parent 5539ccb commit 4bbc17e

1 file changed

Lines changed: 190 additions & 60 deletions

File tree

Lines changed: 190 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,31 @@
1+
# .github/workflows/changelog-check.yml
2+
#
3+
# Enforces the rhosocial changelog fragment rules.
4+
#
5+
# feature/* or fix/* -> release/* | maint/*| main
6+
# - the PR must add at least one changelog.d/<number>.<type>.md fragment
7+
# - every added fragment's <number> must equal the PR number
8+
# - fragments already present in changelog.d/ from previously merged
9+
# branches are ignored; only files ADDED by this PR are considered
10+
#
11+
# release/* -> main
12+
# - CHANGELOG.md must have changed (content is not inspected)
13+
# - changelog.d/ must contain no fragments at the merge commit
14+
#
15+
# Escape hatches (all three retained): a [trivial] marker in the PR title,
16+
# the skip-changelog label, or a "docs: update CHANGELOG" title. An escape
17+
# hatch waives the "at least one fragment" requirement only -- the
18+
# number-must-match-PR rule is always enforced on fragments that were added.
119
name: Changelog Fragment Check
220

321
on:
422
pull_request:
5-
branches:
23+
branches:
24+
- main
25+
- 'release/v**'
26+
- 'maint/**'
27+
push:
28+
branches:
629
- main
730
- 'release/v**'
831
- 'maint/**'
@@ -17,72 +40,179 @@ jobs:
1740
steps:
1841
- uses: actions/checkout@v6
1942
with:
20-
fetch-depth: 0 # Ensure full history is fetched
43+
fetch-depth: 0
2144

22-
- name: Check for changelog fragment
45+
- name: Validate changelog fragments
2346
env:
24-
PR_TITLE: ${{ github.event.pull_request.title }}
25-
HEAD_REF: ${{ github.head_ref }}
47+
EVENT_NAME: ${{ github.event_name }}
48+
HEAD_REF: ${{ github.head_ref || '' }}
49+
BASE_REF: ${{ github.base_ref || '' }}
50+
PR_NUMBER: ${{ github.event.pull_request.number || '' }}
51+
PR_TITLE: ${{ github.event.pull_request.title || '' }}
52+
PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
2653
run: |
27-
# Fetch the base branch explicitly to ensure it's available for comparison
28-
git fetch origin ${{ github.base_ref }}
29-
30-
# --- EXEMPTION FOR RELEASE BRANCH MERGES ---
31-
# Release branches already contain changelog fragments from development
32-
# Skip fragment check for PRs from release/* branches to main
33-
if [[ "$HEAD_REF" =~ ^release/ ]]; then
34-
echo "✓ Release branch PR, fragment check skipped (fragments already in branch history)"
54+
set -uo pipefail
55+
56+
# Towncrier fragment types. Advisory only: a project may legitimately
57+
# configure a different set in pyproject.toml [tool.towncrier.type].
58+
KNOWN_TYPES="added changed deprecated removed fixed performance security docs internal"
59+
60+
# Files that live in changelog.d/ but are not fragments.
61+
is_boilerplate() {
62+
case "${1##*/}" in
63+
README.md|template.md|.gitkeep) return 0 ;;
64+
*) return 1 ;;
65+
esac
66+
}
67+
68+
# Validate <path> against <expected_number> (empty = don't care).
69+
# Prints the fragment type on success.
70+
validate_fragment() {
71+
local path="$1" expected="$2"
72+
local base num type
73+
74+
base="${path##*/}"
75+
76+
if [[ ! "$base" =~ ^([0-9]+)\.([a-zA-Z]+)\.md$ ]]; then
77+
echo "::error file=$path::fragment must be named <number>.<type>.md, got '$base'"
78+
return 1
79+
fi
80+
num="${BASH_REMATCH[1]}"
81+
type="${BASH_REMATCH[2]}"
82+
83+
if [[ -n "$expected" && "$num" != "$expected" ]]; then
84+
echo "::error file=$path::fragment number '$num' does not match PR #$expected"
85+
echo " Rename it to changelog.d/$expected.$type.md, or open a PR for #$num."
86+
return 1
87+
fi
88+
89+
if [[ " $KNOWN_TYPES " != *" $type "* ]]; then
90+
echo "::warning file=$path::'$type' is not a standard towncrier type" >&2
91+
fi
92+
93+
echo "$type"
94+
return 0
95+
}
96+
97+
is_exempt() {
98+
[[ "$PR_TITLE" == *"[trivial]"* ]] && return 0
99+
[[ ",$PR_LABELS," == *",skip-changelog,"* ]] && return 0
100+
[[ "$PR_TITLE" == "docs: update CHANGELOG"* ]] && return 0
101+
return 1
102+
}
103+
104+
# ------------------------------------------------------------------
105+
# release/* -> main : CHANGELOG must change, changelog.d/ must be empty
106+
# ------------------------------------------------------------------
107+
check_release_merge() {
108+
local scope="$1" changed remaining failed=0
109+
110+
changed=$(git diff --name-only "origin/main...HEAD" -- CHANGELOG.md)
111+
if [[ -z "$changed" ]]; then
112+
echo "::error::CHANGELOG.md was not updated by this $scope."
113+
echo " A release merge must regenerate the changelog (e.g. \`towncrier build --yes --keep\`)"
114+
echo " before the fragments under changelog.d/ are discarded."
115+
failed=1
116+
else
117+
echo "OK: CHANGELOG.md updated"
118+
fi
119+
120+
# Checked against the merge result, not the diff: leftover fragments
121+
# from earlier in the release cycle must be gone by this point.
122+
remaining=$(git ls-tree --name-only HEAD changelog.d/ \
123+
| while read -r f; do
124+
if ! is_boilerplate "$f" \
125+
&& [[ "${f##*/}" =~ ^[0-9]+\.[a-zA-Z]+\.md$ ]]; then
126+
echo "$f"
127+
fi
128+
done)
129+
if [[ -n "$remaining" ]]; then
130+
echo "::error::changelog.d/ still contains fragments after the release merge:"
131+
echo "$remaining" | while read -r f; do echo " - $f"; done
132+
echo " Delete them; their content is already folded into CHANGELOG.md."
133+
failed=1
134+
else
135+
echo "OK: changelog.d/ is clear of fragments"
136+
fi
137+
138+
return $failed
139+
}
140+
141+
# ------------------------------------------------------------------
142+
# push event: no PR number available, so only structural checks
143+
# ------------------------------------------------------------------
144+
if [[ "$EVENT_NAME" == "push" ]]; then
145+
if [[ "$GITHUB_REF_NAME" == "main" ]]; then
146+
touched=$(git diff-tree --no-commit-id --name-only -r HEAD)
147+
if ! echo "$touched" | grep -qE '^(CHANGELOG\.md|changelog\.d/|src/)'; then
148+
echo "OK: no changelog-relevant changes in this push"
149+
exit 0
150+
fi
151+
check_release_merge "push to main" || exit 1
152+
exit $?
153+
fi
154+
echo "OK: push to $GITHUB_REF_NAME (fragment numbering is validated on the PR)"
35155
exit 0
36156
fi
37-
# --- END RELEASE BRANCH EXEMPTION ---
38-
39-
# Compare the head of the PR (current HEAD) with the base branch's remote tracking ref
40-
# This compares the changes introduced by the PR branch relative to its base.
41-
# Use --diff-filter=A to only consider added files for fragment check
42-
CHANGED_FILES=$(git diff --name-only --diff-filter=A origin/${{ github.base_ref }}...HEAD)
43-
44-
# Debugging: Print the diff command and its output
45-
echo "Executing: git diff --name-only --diff-filter=A origin/${{ github.base_ref }}...HEAD"
46-
echo "Changed files (added only): $CHANGED_FILES"
47-
48-
# --- EXEMPTION FOR CHANGELOG BUILD PRS ---
49-
# Check if PR title indicates a changelog update (e.g., "docs: update CHANGELOG")
50-
# This exempts PRs that are specifically for building the changelog (which remove fragments)
51-
if [[ "$PR_TITLE" =~ ^docs:\ update\ CHANGELOG ]]; then
52-
echo "✓ PR title indicates changelog update, fragment check skipped"
157+
158+
# ------------------------------------------------------------------
159+
# pull_request
160+
# ------------------------------------------------------------------
161+
echo "head=$HEAD_REF base=$BASE_REF pr=#$PR_NUMBER"
162+
163+
if [[ "$HEAD_REF" == release/* && "$BASE_REF" == "main" ]]; then
164+
git fetch origin main --quiet
165+
echo "== release merge into main =="
166+
check_release_merge "release merge" || exit 1
53167
exit 0
54168
fi
55-
# --- END EXEMPTION ---
56-
57-
# Check if any changelog fragment was added
58-
if echo "$CHANGED_FILES" | grep -q "^changelog.d/[0-9].*\.md$"; then
59-
echo "✓ Changelog fragment found"
60-
exit 0
169+
170+
git fetch origin "$BASE_REF" --quiet
171+
172+
# Fragments ADDED by this PR. Fragments that were already in
173+
# changelog.d/ (carried in by previously merged branches) are not in
174+
# this list and are therefore ignored.
175+
added=$(git diff --name-only --diff-filter=A "origin/$BASE_REF...HEAD" -- 'changelog.d/*.md')
176+
177+
failed=0
178+
types=""
179+
if [[ -n "$added" ]]; then
180+
while read -r f; do
181+
[[ -z "$f" ]] && continue
182+
is_boilerplate "$f" && continue
183+
t=$(validate_fragment "$f" "$PR_NUMBER") || failed=1
184+
[[ -n "$t" ]] && types="$types $t"
185+
done <<< "$added"
61186
fi
62-
63-
# Check if this is an internal change (exempt from fragment requirement)
64-
if echo "$CHANGED_FILES" | grep -qE "^(tests/|docs/|\.github/)"; then
65-
echo "✓ Internal change, fragment not required"
66-
exit 0
187+
188+
# The number-must-match rule is enforced even for exempt PRs; only
189+
# the presence requirement is waived.
190+
if [[ "$failed" -ne 0 ]]; then
191+
echo ""
192+
echo "✗ Fragment numbering does not match PR #$PR_NUMBER"
193+
exit 1
67194
fi
68-
69-
# Check if PR is marked as trivial
70-
if [[ "$PR_TITLE" =~ \[trivial\] ]]; then
71-
echo "✓ PR marked as trivial, fragment not required"
72-
exit 0
195+
196+
if [[ -z "$added" ]]; then
197+
if is_exempt; then
198+
echo "OK: no fragment required (exempt)"
199+
exit 0
200+
fi
201+
if git diff --name-only "origin/$BASE_REF...HEAD" \
202+
| grep -qE '^(tests/|docs/|\.github/)' \
203+
&& ! git diff --name-only "origin/$BASE_REF...HEAD" | grep -qE '^src/'; then
204+
echo "OK: internal-only change, no fragment required"
205+
exit 0
206+
fi
207+
echo "::error::No changelog fragment added by this PR."
208+
echo " Add at least one changelog.d/$PR_NUMBER.<type>.md"
209+
echo ""
210+
echo "Types: added | changed | deprecated | removed | fixed |"
211+
echo " performance | security | docs | internal"
212+
echo ""
213+
echo "Or waive it: add [trivial] to the PR title, apply the"
214+
echo "skip-changelog label, or title the PR 'docs: update CHANGELOG'."
215+
exit 1
73216
fi
74-
75-
echo "✗ Missing changelog fragment"
76-
echo "Please add a changelog fragment in changelog.d/"
77-
echo "Example: changelog.d/${{ github.event.number }}.fixed.md"
78-
echo ""
79-
echo "Fragment types:"
80-
echo " - added.md - New features"
81-
echo " - fixed.md - Bug fixes"
82-
echo " - changed.md - Behavior changes"
83-
echo " - deprecated.md - Deprecations"
84-
echo " - removed.md - Removed features"
85-
echo " - security.md - Security fixes"
86-
echo ""
87-
echo "If this PR does not need a changelog entry, add [trivial] to the title."
88-
exit 1
217+
218+
echo "OK: fragment(s) added for PR #$PR_NUMBER ->$types"

0 commit comments

Comments
 (0)