chore(scripts): add release-changelog.py

Adds a script to unfill (single-line) entry paragraphs in CHANGELOG.md
while keeping `(PR [...])` on its own continuation line.
This commit is contained in:
Aleix Conchillo Flaqué
2026-04-27 14:43:11 -07:00
parent 9697abe559
commit ea3585146c

96
scripts/release-changelog.py Executable file
View File

@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Generate release notes from CHANGELOG.md for use in GitHub Releases (or anywhere).
Usage::
python scripts/release-changelog.py VERSION
python scripts/release-changelog.py 1.1.0
python scripts/release-changelog.py --file path/to/CHANGELOG.md 1.1.0
Extracts the requested ``## [VERSION]`` section (heading and ``### …``
subheadings included) and prints it to stdout. The only transformation
applied is collapsing each entry paragraph onto a single line, so it is
suitable for pasting into release notes that don't need 80-column wrapping.
``(PR [...])`` references stay on their own two-space-indented continuation
line. The input file is never modified.
Every paragraph is unfilled. Indentation is preserved — each logical line
(bullets, sub-bullets, and the ``(PR [...])`` continuation) keeps its
leading whitespace; only the wrapped continuation lines that follow them
get joined back. Code-block paragraphs (triple-backtick fences, or deeply
indented blocks with no list markers around) are passed through untouched.
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
DEFAULT_CHANGELOG = Path(__file__).resolve().parent.parent / "CHANGELOG.md"
PARAGRAPH_SPLIT_RE = re.compile(r"(\n[ \t]*\n+)")
CONTINUATION_RE = re.compile(r"\n(?![ \t]*[-*+] )[ \t]+")
PR_RE = re.compile(r" \(PR \[")
CODE_FENCE_RE = re.compile(r"^\s*```")
DEEP_INDENT_RE = re.compile(r"^ {4,}")
LIST_MARKER_RE = re.compile(r"^[ \t]*[-*+] ")
SECTION_HEADING_RE = re.compile(r"^## \[", re.MULTILINE)
def is_code_block_paragraph(paragraph: str) -> bool:
lines = paragraph.splitlines()
if any(CODE_FENCE_RE.match(line) for line in lines):
return True
# In a list paragraph, 4-space indent is a sub-bullet continuation, not
# code. Only treat deep-indented lines as code when no bullets are around
# to claim them.
if any(LIST_MARKER_RE.match(line) for line in lines):
return False
return any(DEEP_INDENT_RE.match(line) for line in lines)
def unfill_entry(paragraph: str) -> str:
joined = CONTINUATION_RE.sub(" ", paragraph)
return PR_RE.sub("\n (PR [", joined, count=1)
def process(text: str) -> str:
parts = PARAGRAPH_SPLIT_RE.split(text)
for i in range(0, len(parts), 2):
para = parts[i]
if para and not is_code_block_paragraph(para):
parts[i] = unfill_entry(para)
return "".join(parts)
def extract_section(text: str, version: str) -> str:
head_re = re.compile(rf"^## \[{re.escape(version)}\]", re.MULTILINE)
m = head_re.search(text)
if not m:
sys.exit(f"error: version {version!r} not found")
nxt = SECTION_HEADING_RE.search(text, m.end())
end = nxt.start() if nxt else len(text)
return text[m.start() : end]
def main() -> None:
summary = (__doc__ or "").splitlines()[0]
parser = argparse.ArgumentParser(description=summary)
parser.add_argument("version", help="Version to extract (e.g. 1.1.0).")
parser.add_argument(
"--file",
type=Path,
default=DEFAULT_CHANGELOG,
help=f"Path to the changelog (default: {DEFAULT_CHANGELOG}).",
)
args = parser.parse_args()
text = args.file.read_text()
section = extract_section(text, args.version)
sys.stdout.write(process(section))
if __name__ == "__main__":
main()