Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
73 changes: 73 additions & 0 deletions scripts/validate-changelog.py
Original file line number Diff line number Diff line change
@@ -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()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: read_text() is called outside the try/except, so a missing working-tree CHANGELOG.md produces a raw FileNotFoundError traceback instead of a clean error. Wrapping it gives a friendlier failure:

Suggested change
current = Path("CHANGELOG.md").read_text()
try:
current = Path("CHANGELOG.md").read_text()
except OSError as e:
print(f"error: could not read CHANGELOG.md ({e})", file=sys.stderr)
sys.exit(1)

(not blocking)


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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

super nit: str(CalledProcessError) reports the return code but drops git's stderr (e.g. "path 'CHANGELOG.md' does not exist in 'origin/main'"). Adding stderr=subprocess.PIPE to check_output and forwarding e.stderr here makes failures easier to diagnose. (not blocking)

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()
Loading