From 3b2abaa1ff82d4d3d6d6286cf14c5a98bb289675 Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:48:13 -0700 Subject: [PATCH] ci: validate CHANGELOG sections match base branch on PRs Adds scripts/validate-changelog.py: each ## [version] block on the base branch must appear unchanged in CHANGELOG.md so full git-cliff regenerations cannot drop already-shipped release notes. --- .github/workflows/ci.yml | 14 +++++++ scripts/validate-changelog.py | 73 +++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100755 scripts/validate-changelog.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be831aa..cc24ee9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,20 @@ on: branches: [main] jobs: + changelog: + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + - name: Validate released changelog sections + env: + BASE_REF: ${{ github.base_ref }} + run: | + git fetch origin "$BASE_REF" + python3 scripts/validate-changelog.py "origin/$BASE_REF" + test: runs-on: ubuntu-latest steps: diff --git a/scripts/validate-changelog.py b/scripts/validate-changelog.py new file mode 100755 index 0000000..aa60f50 --- /dev/null +++ b/scripts/validate-changelog.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Fail if CHANGELOG.md alters any release section that already exists on the base ref. + +git-cliff full regenerations can drop bullets from older versions; this catches that +by requiring each ## [version] block from the base to match exactly in the working tree. +""" + +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + +SECTION_START = re.compile(r"^## \[([^\]]+)\]", re.MULTILINE) + + +def split_sections(text: str) -> dict[str, str]: + matches = list(SECTION_START.finditer(text)) + sections: dict[str, str] = {} + for i, m in enumerate(matches): + version = m.group(1) + start = m.start() + end = matches[i + 1].start() if i + 1 < len(matches) else len(text) + sections[version] = text[start:end].rstrip() + "\n" + return sections + + +def git_show_changelog(ref: str) -> str: + return subprocess.check_output( + ["git", "show", f"{ref}:CHANGELOG.md"], + text=True, + ) + + +def main() -> None: + base = sys.argv[1] if len(sys.argv) > 1 else "origin/main" + current = Path("CHANGELOG.md").read_text() + + try: + base_text = git_show_changelog(base) + except subprocess.CalledProcessError as e: + print(f"error: could not read {base}:CHANGELOG.md ({e})", file=sys.stderr) + sys.exit(1) + + base_sections = split_sections(base_text) + cur_sections = split_sections(current) + + failed = False + for ver, body in base_sections.items(): + if ver not in cur_sections: + print( + f"CHANGELOG.md: missing section [{ver}] that exists on {base}", + file=sys.stderr, + ) + failed = True + elif cur_sections[ver] != body: + print( + f"CHANGELOG.md: section [{ver}] differs from {base} " + "(released sections must not be rewritten)", + file=sys.stderr, + ) + failed = True + + if failed: + sys.exit(1) + print( + f"changelog ok: {len(base_sections)} section(s) from {base} preserved unchanged", + ) + + +if __name__ == "__main__": + main()