11#! /bin/bash
2- set -euo pipefail
2+ set -uo pipefail
33
44# ==============================================================================
55# Executes When:
66# - Run by GitHub Actions workflow: .github/workflows/pr-check-changelog.yml
7- # - Triggers: workflow_dispatch (manual) and pull_request (opened, edited, synch) .
7+ # - Triggers: pull_request events (opened, edited, synchronized) or manual runs .
88#
99# Goal:
10- # It acts as a gatekeeper for Pull Requests, blocking any merge unless the user
11- # has added a new entry to CHANGELOG.md and correctly placed it under the
12- # [Unreleased] section with a proper category subtitle.
10+ # This script enforces CHANGELOG discipline in Pull Requests.
11+ # It blocks merging unless:
12+ # 1) A new changelog entry is added.
13+ # 2) The entry is placed under [Unreleased].
14+ # 3) The entry is under a valid subsection (e.g., ### Added, ### Fixed).
15+ # 4) No previous changelog entries are deleted.
1316#
1417# ------------------------------------------------------------------------------
15- # Flow: Basic Idea
16- # 1. Grabs the official blueprints (upstream/main) to compare against current work.
17- # 2. Checks if anything new was written. If not, fails immediately.
18- # 3. Walks through the file line-by-line to ensure new notes are strictly filed
19- # under [Unreleased] and organized under a category (e.g., "Added", "Fixed").
20- # 4. If notes are missing, misplaced, or dangling, it fails the build.
21- # If filed correctly, it approves the build.
18+ # Flow: High-Level Overview
19+ # 1. Fetch upstream main branch to compare against PR changes.
20+ # 2. Compute diff for CHANGELOG.md.
21+ # 3. Detect added and deleted bullet entries.
22+ # 4. Walk through CHANGELOG.md to verify correct placement.
23+ # 5. Fail CI if entries are missing, misplaced, or removed.
2224#
2325# ------------------------------------------------------------------------------
2426# Flow: Detailed Technical Steps
2527#
26- # 1️⃣ Network Setup & Fetch
27- # - Action: Sets up a remote connection to GitHub and runs 'git fetch upstream main'.
28- # - Why: Needs the "Source of Truth" to compare the Pull Request against.
28+ # 1️⃣ Upstream Setup & Fetch
29+ # - Ensures 'upstream' remote exists (points to GITHUB_REPOSITORY).
30+ # - Runs: git fetch upstream main
31+ # - Purpose: Compare PR against canonical main branch.
2932#
30- # 2️⃣ Diff Analysis & Visualization
31- # - Action: Runs 'git diff upstream/main -- CHANGELOG.md'.
32- # - UX/Display: Prints raw diff with colors (Green=Additions, Red=Deletions)
33- # strictly for human readability in logs; logic does not rely on colors.
34- # - Logic: Extracts two lists:
35- # * added_bullets: Every line starting with '+' (new text).
36- # * deleted_bullets: Every line starting with '-' (removed text).
37- # - Immediate Fail Check: If 'added_bullets' is empty, sets failed=1 and exits.
38- # (You cannot merge code without a changelog entry).
33+ # 2️⃣ Diff Extraction & Logging
34+ # - Runs: git diff upstream/main -- CHANGELOG.md
35+ # - Displays color-coded diff:
36+ # Green = Added lines
37+ # Red = Removed lines
38+ # - Extracts:
39+ # * added_bullets -> new bullet lines (+)
40+ # * deleted_bullets -> removed bullet lines (-)
3941#
40- # 3️⃣ Context Tracking
41- # As the script reads the file line-by-line, it tracks:
42- # - current_release: Main version header (e.g., [Unreleased] or [1.0.0]).
43- # - current_subtitle: Sub-category (e.g., ### Added, ### Fixed).
44- # - in_unreleased: Flag (0 or 1).
45- # * 1 (True) -> Currently inside [Unreleased] (Safe Zone).
46- # * 0 (False) -> Reading an old version (Danger Zone).
42+ # 3️⃣ Mandatory Entry Check
43+ # - If no new bullet entries are found:
44+ # → FAIL (You must update the changelog in every PR).
4745#
48- # 4️⃣ Sorting
49- # Flag is ON (1) AND Subtitle is Set -> correctly_placed -> PASS ✅
50- # Flag is ON (1) BUT Subtitle is Empty -> orphan_entries -> FAIL ❌ (It's dangling, not under a category)
51- # Flag is OFF (0) -> wrong_release_entries -> FAIL ❌ (edited old history)
46+ # 4️⃣ Context Tracking While Parsing File
47+ # Tracks current parsing state:
48+ # - current_release -> version header (e.g., [Unreleased], [1.2.0])
49+ # - current_subtitle -> section header (### Added, ### Fixed, etc.)
50+ # - in_unreleased -> boolean flag (1 if inside [Unreleased])
5251#
53- # 5️⃣ Final Result
54- # Aggregates failures from Step 4. If any FAIL buckets are not empty, exit 1.
52+ # 5️⃣ Classification Rules
53+ # Condition → Result
54+ # -------------------------------------------------------------
55+ # In [Unreleased] AND under subtitle → correctly_placed (PASS)
56+ # In [Unreleased] BUT no subtitle → orphan_entries (FAIL)
57+ # Under released version section → wrong_release_entries (FAIL)
58+ #
59+ # 6️⃣ Deletion Detection
60+ # - If any existing changelog bullet lines are removed:
61+ # → FAIL (History must never be rewritten).
62+ #
63+ # 7️⃣ Exit Behavior
64+ # - If any failure condition is detected:
65+ # exit 1 (CI blocks merge)
66+ # - Otherwise:
67+ # exit 0 (PR passes changelog gate)
5568#
5669# ------------------------------------------------------------------------------
5770# Parameters:
58- # None. (The script accepts no command-line arguments).
71+ # None ( script does not accept CLI arguments).
5972#
60- # Environment Variables (Required) :
61- # - GITHUB_REPOSITORY: Used to fetch the upstream 'main' branch for comparison .
73+ # Required Environment Variables:
74+ # - GITHUB_REPOSITORY : Used to configure upstream remote URL .
6275#
6376# Dependencies:
64- # - git (must be able to fetch upstream)
65- # - grep, sed (standard Linux utilities)
66- # - CHANGELOG.md (file must exist in the root directory)
77+ # - git
78+ # - grep, sed (POSIX utilities)
79+ # - CHANGELOG.md must exist in repository root.
6780#
6881# Permissions:
69- # - ' contents: read' (to access the file structure).
70- # - Network access (to run ' git fetch upstream').
82+ # - contents: read
83+ # - network access (for git fetch)
7184#
7285# Returns:
73- # 0 (Success) - Changes are valid and correctly placed.
74- # 1 (Failure) - Missing entries, wrong placement (e.g. under released version),
75- # orphan entries (no subtitle), or accidental deletions.
86+ # 0 → Changelog entries valid and correctly placed.
87+ # 1 → Missing entries, wrong placement, orphan entries, or deleted history.
7688# ==============================================================================
7789
7890CHANGELOG=" CHANGELOG.md"
@@ -86,13 +98,13 @@ RESET="\033[0m"
8698failed=0
8799
88100# Fetch upstream
89- git remote add upstream https://github.com/${GITHUB_REPOSITORY} .git
101+ git remote get-url upstream & > /dev/null || git remote add upstream https://github.com/${GITHUB_REPOSITORY} .git
90102git fetch upstream main > /dev/null 2>&1
91103
92- # Get raw diff
93- raw_diff=$( git diff upstream/main -- " $CHANGELOG " )
104+ # Get raw diff (git diff may legitimately return non-zero)
105+ raw_diff=$( git diff upstream/main -- " $CHANGELOG " || true )
94106
95- # 1️⃣ Show raw diff with colors
107+ # Show raw diff with colors
96108echo " === Raw git diff of $CHANGELOG against upstream/main ==="
97109while IFS= read -r line; do
98110 if [[ $line =~ ^\+ && ! $line =~ ^\+\+\+ ]]; then
@@ -105,37 +117,68 @@ while IFS= read -r line; do
105117done <<< " $raw_diff"
106118echo " ================================="
107119
108- # 2️⃣ Extract added bullet lines
109- added_bullets=()
120+ # Extract added bullet lines
121+ declare -A added_bullets=()
122+ file_line=0
123+ in_hunk=0
124+
110125while IFS= read -r line; do
111- [[ -n " $line " ]] && added_bullets+=(" $line " )
112- done < <( echo " $raw_diff " | sed -n ' s/^+//p' | grep -E ' ^[[:space:]]*[-*]' | sed ' /^[[:space:]]*$/d' )
126+ if [[ $line =~ ^\@\@ ]]; then
127+ # Extract starting line number of the new file
128+ file_line=$( echo " $line " | sed -E ' s/.*\+([0-9]+).*/\1/' )
129+ in_hunk=1
130+ continue
131+ fi
132+
133+ # Ignore lines until we are inside a hunk
134+ if [[ $in_hunk -eq 0 ]]; then
135+ continue
136+ fi
137+
138+ if [[ $line =~ ^\+ && ! $line =~ ^\+\+\+ ]]; then
139+ content=" ${line# +} "
140+ if [[ $content =~ ^[[:space:]]* [-* ] ]]; then
141+ added_bullets[$file_line ]=" $content "
142+ fi
143+ (( file_line++ ))
144+ elif [[ ! $line =~ ^\- ]]; then
145+ (( file_line++ ))
146+ fi
147+ done <<< " $raw_diff"
113148
114- # 2️⃣a Extract deleted bullet lines
149+ # Extract deleted bullet lines
115150deleted_bullets=()
116151while IFS= read -r line; do
117152 [[ -n " $line " ]] && deleted_bullets+=(" $line " )
118- done < <( echo " $raw_diff " | grep ' ^\-' | grep -vE ' ^(--- |\+\+\+ |@@ )' | sed ' s/^-//' )
119-
120- # 2️⃣b Warn if no added entries
153+ done < <(
154+ echo " $raw_diff " \
155+ | grep ' ^\-' \
156+ | grep -vE ' ^(--- |\+\+\+ |@@ )' \
157+ | sed ' s/^-//' \
158+ || true
159+ )
160+
161+ # Warn if no added entries
121162if [[ ${# added_bullets[@]} -eq 0 ]]; then
122163 echo -e " ${RED} ❌ No new changelog entries detected in this PR.${RESET} "
123- echo -e " ${YELLOW} ⚠️ Please add an entry in [UNRELEASED ] under the appropriate subheading.${RESET} "
164+ echo -e " ${YELLOW} ⚠️ Please add an entry in [Unreleased ] under the appropriate subheading.${RESET} "
124165 failed=1
125166fi
126167
127- # 3️⃣ Initialize results
168+ # Initialize results
128169correctly_placed=" "
129170orphan_entries=" "
130171wrong_release_entries=" "
131172
132- # 4️⃣ Walk through changelog to classify entries
173+ # Walk through changelog to classify entries
174+ line_no=0
133175current_release=" "
134176current_subtitle=" "
135177in_unreleased=0
136178
137179while IFS= read -r line; do
138- # Track release sections
180+ (( line_no++ ))
181+
139182 if [[ $line =~ ^# #\ \[Unreleased\] ]]; then
140183 current_release= " Unreleased"
141184 in_unreleased= 1
@@ -151,21 +194,22 @@ while IFS= read -r line; do
151194 continue
152195 fi
153196
154- # Check each added bullet
155- for added in " ${added_bullets[@]} " ; do
156- if [[ " $line " == " $added " ]]; then
157- if [[ " $in_unreleased " -eq 1 && -n " $current_subtitle " ]]; then
158- correctly_placed+=" $added (placed under $current_subtitle )" $' \n '
159- elif [[ " $in_unreleased " -eq 1 && -z " $current_subtitle " ]]; then
160- orphan_entries+=" $added (NOT under a subtitle)" $' \n '
161- elif [[ " $in_unreleased " -eq 0 ]]; then
162- wrong_release_entries+=" $added (added under released version $current_release )" $' \n '
163- fi
197+ if [[ -n " ${added_bullets[$line_no]:- } " ]]; then
198+ added=" ${added_bullets[$line_no]} "
199+
200+ if [[ " $in_unreleased " -eq 0 ]]; then
201+ wrong_release_entries+=" $added (added under released version $current_release )" $' \n '
202+ failed=1
203+ elif [[ -z " $current_subtitle " ]]; then
204+ orphan_entries+=" $added (NOT under a subtitle)" $' \n '
205+ failed=1
206+ else
207+ correctly_placed+=" $added (placed under $current_subtitle )" $' \n '
164208 fi
165- done
209+ fi
166210done < " $CHANGELOG "
167211
168- # 5️⃣ Display results
212+ # Display results
169213if [[ -n " $orphan_entries " ]]; then
170214 echo -e " ${RED} ❌ Some CHANGELOG entries are not under a subtitle in [Unreleased]:${RESET} "
171215 echo " $orphan_entries "
@@ -183,16 +227,17 @@ if [[ -n "$correctly_placed" ]]; then
183227 echo " $correctly_placed "
184228fi
185229
186- # 6️⃣ Display deleted entries
230+ # Display deleted entries
187231if [[ ${# deleted_bullets[@]} -gt 0 ]]; then
188232 echo -e " ${RED} ❌ Changelog entries removed in this PR:${RESET} "
189233 for deleted in " ${deleted_bullets[@]} " ; do
190234 echo -e " - ${RED} $deleted ${RESET} "
191235 done
192236 echo -e " ${YELLOW} ⚠️ Please add these entries back under the appropriate sections${RESET} "
237+ failed=1
193238fi
194239
195- # 7️⃣ Exit with failure if any bad entries exist
240+ # Exit
196241if [[ $failed -eq 1 ]]; then
197242 echo -e " ${RED} ❌ Changelog check failed.${RESET} "
198243 exit 1
0 commit comments