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.
119name : Changelog Fragment Check
220
321on :
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