Files
LearningCircuit b964d32829 ci(changelog): fail loud on undeclared towncrier fragment categories (#5045)
The advisory recommend-release-notes hook detected invalid fragment
names but always exited 0, and it only inspects the staged index — so
CI's `pre-commit run --all-files` validated nothing. That is how
#5023's `.refactor.md` fragment (undeclared category) reached main
with every gate green; towncrier build silently drops such fragments
from the rendered release notes, and #5041 had to rename it after the
fact.

Adds a blocking check-changelog-fragments hook that validates every
file in changelog.d/ (README.md and dotfiles exempt) against the
fragment grammar and the [[tool.towncrier.type]] categories read from
pyproject.toml, failing on any mismatch — including under --all-files
in CI. No category fallback in the blocking path: an unreadable
towncrier config fails the hook. The shared grammar/category logic
moves to _changelog_fragments.py, which the advisory hook now imports
(its warn-only behavior and degraded-mode fallback are unchanged).
2026-07-11 10:02:12 +02:00

172 lines
5.8 KiB
Python
Executable File

#!/usr/bin/env python3
"""Remind contributors about news fragments.
Always exits 0 (non-blocking). Two messages:
- When any file under changelog.d/ is staged: confirm that the fragment
will be rolled into the next release's notes by towncrier and validate
the filename matches the expected pattern.
- When source changes are substantial but no fragment is staged: nudge
the contributor to add one.
Replaces the old shared docs/release_notes/<version>.md model — see
changelog.d/README.md for the rationale and conventions.
"""
import subprocess
import sys
import tomllib
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from _changelog_fragments import classify_fragment, load_categories
from _commit_analysis import analyze_commit
# Minimum added source lines before the nudge fires
MIN_SOURCE_ADDED = 20
REPO_ROOT = Path(__file__).resolve().parent.parent
PYPROJECT = REPO_ROOT / "pyproject.toml"
def _load_categories():
"""Load the canonical category list, falling back to a sensible
default if pyproject.toml or its towncrier section is unreadable —
this hook is non-blocking, so a degraded mode is preferable to a
hard failure here (the blocking check-changelog-fragments hook is
the one that fails loud on a broken config)."""
try:
return load_categories(PYPROJECT)
except (
OSError,
tomllib.TOMLDecodeError,
KeyError,
TypeError,
ValueError,
):
return ("breaking", "security", "feature", "bugfix", "removal", "misc")
CATEGORIES = _load_categories()
# Color helpers: only emit ANSI when stdout is a TTY. CI logs and Windows
# terminals without VT processing render the raw escape sequences as
# visible garbage.
_USE_COLOR = sys.stdout.isatty()
_CYAN = "\033[36m" if _USE_COLOR else ""
_YELLOW = "\033[33m" if _USE_COLOR else ""
_RESET = "\033[0m" if _USE_COLOR else ""
def _fragments_staged():
"""Return the list of staged files under changelog.d/, excluding
README.md and other non-fragment files."""
result = subprocess.run(
["git", "diff", "--cached", "--name-only", "--", "changelog.d/"],
capture_output=True,
text=True,
)
files = [line for line in result.stdout.strip().splitlines() if line]
return [
f for f in files if f.endswith(".md") and Path(f).name != "README.md"
]
def _print_staged_notice(staged):
"""Inform the committer that a news fragment was staged."""
print()
print(f" {_CYAN}News Fragment Staged{_RESET}")
print(" " + "-" * 40)
for f in staged:
print(f" - {f}")
print()
print(" Files under changelog.d/ are rendered into")
print(" docs/release_notes/<version>.md at release prep time by")
print(" `pdm run towncrier build --version <X.Y.Z> --yes`, then")
print(" surfaced in the GitHub release body by")
print(" .github/workflows/release.yml.")
# Validate filenames — non-blocking, but a typo'd category silently
# falls through towncrier's "no fragments matched" branch and the
# contributor's note vanishes from the release.
issues = []
for f in staged:
kind, value = classify_fragment(Path(f).name, CATEGORIES)
if kind != "ok":
issues.append((f, kind, value))
if issues:
print()
print(f" {_YELLOW}⚠ Fragment filename problems:{_RESET}")
for f, kind, value in issues:
if kind == "bad-name":
print(
f" {f} — does not match `<id>.<category>.md` or "
f"`+<slug>.<category>.md`"
)
else:
print(
f" {f} — unknown category `{value}`. Use one of: "
f"{', '.join(CATEGORIES)}"
)
print()
print(" See changelog.d/README.md for the convention.")
print()
print(" Format tips:")
print(" - One sentence is usually enough; longer prose is fine for")
print(" breaking changes that need a 'what to do' line.")
print(" - Markdown is supported. The PR/issue link is auto-appended")
print(" based on the fragment id (no need to add `(#NNNN)`).")
print(" - Skip dependency bumps, internal CI tweaks, and refactors")
print(" with no user-visible behavior — the auto-PR-list catches")
print(" those without a fragment.")
print()
def _print_missing_notice(analysis):
"""Nudge the committer to add a news fragment for a substantial change."""
print()
print(f" {_CYAN}News Fragment Reminder{_RESET}")
print(" " + "-" * 40)
print(
f" You're adding {analysis.total_source_added} lines across "
f"{len(analysis.source_files)} source file(s)"
)
print(" but no changelog.d/ fragment is staged.")
print()
print(" Changed source files:")
for f in analysis.source_files:
print(f" - {f.path} (+{f.added})")
print()
print(" If this change is user-facing, drop a fragment under")
print(" changelog.d/ named `<PR-number>.<category>.md` (categories:")
print(f" {', '.join(CATEGORIES)}). See changelog.d/README.md.")
print()
def main():
staged = _fragments_staged()
# Always inform when a fragment is staged — contributors should know
# the file gets rendered into the release, and any naming mistakes
# need to surface before the fragment silently goes ignored.
if staged:
_print_staged_notice(staged)
return 0
analysis = analyze_commit()
# Silent exit: no source files staged
if not analysis.source_files:
return 0
# Silent exit: trivial change (less than MIN_SOURCE_ADDED added lines)
if analysis.total_source_added < MIN_SOURCE_ADDED:
return 0
_print_missing_notice(analysis)
return 0
if __name__ == "__main__":
sys.exit(main())