|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Extract GitHub release notes for one version out of CHANGELOG.md. |
| 3 | +
|
| 4 | +Used by .github/workflows/release.yml so the GitHub release body is the changelog |
| 5 | +entry itself, rather than prose written by hand after the tag is already pushed. |
| 6 | +Historically that manual step was skipped and the releases page went stale while |
| 7 | +PyPI was current. |
| 8 | +
|
| 9 | +The extracted notes are the body of the `## [X.Y.Z]` section, with a compare link |
| 10 | +to the preceding released version appended when one exists. |
| 11 | +
|
| 12 | +Usage: |
| 13 | + python scripts/changelog_notes.py 2.19.0 |
| 14 | + Print the notes for 2.19.0 to stdout. |
| 15 | + python scripts/changelog_notes.py 2.19.0 --output release-notes.md |
| 16 | + Write the notes to a file. |
| 17 | +""" |
| 18 | + |
| 19 | +import argparse |
| 20 | +import re |
| 21 | +import sys |
| 22 | +from pathlib import Path |
| 23 | + |
| 24 | +REPO_ROOT = Path(__file__).resolve().parent.parent |
| 25 | +CHANGELOG = REPO_ROOT / "CHANGELOG.md" |
| 26 | +DEFAULT_REPO_URL = "https://github.com/K-Dense-AI/claude-scientific-writer" |
| 27 | + |
| 28 | +SECTION_HEADING = re.compile(r"^##\s+\[([^\]]+)\]", re.MULTILINE) |
| 29 | +SEMVER = re.compile(r"^\d+\.\d+\.\d+$") |
| 30 | + |
| 31 | + |
| 32 | +class ChangelogError(RuntimeError): |
| 33 | + """Raised when a version has no usable changelog entry.""" |
| 34 | + |
| 35 | + |
| 36 | +def iter_sections(text: str) -> list[tuple[str, str]]: |
| 37 | + """Return (label, body) for every `## [label]` section, in document order.""" |
| 38 | + matches = list(SECTION_HEADING.finditer(text)) |
| 39 | + sections = [] |
| 40 | + for index, match in enumerate(matches): |
| 41 | + end = matches[index + 1].start() if index + 1 < len(matches) else len(text) |
| 42 | + body = text[match.end() : end] |
| 43 | + # Drop the remainder of the heading line (the ` - YYYY-MM-DD` date suffix). |
| 44 | + _, _, body = body.partition("\n") |
| 45 | + sections.append((match.group(1), _strip_separators(body))) |
| 46 | + return sections |
| 47 | + |
| 48 | + |
| 49 | +def _strip_separators(body: str) -> str: |
| 50 | + """Strip surrounding whitespace and the `---` rules that divide changelog entries.""" |
| 51 | + lines = body.strip().splitlines() |
| 52 | + while lines and lines[-1].strip() in {"", "---"}: |
| 53 | + lines.pop() |
| 54 | + while lines and lines[0].strip() in {"", "---"}: |
| 55 | + lines.pop(0) |
| 56 | + return "\n".join(lines).strip() |
| 57 | + |
| 58 | + |
| 59 | +def previous_version(text: str, version: str) -> str | None: |
| 60 | + """Return the released version documented directly below `version`, if any.""" |
| 61 | + labels = [label for label, _ in iter_sections(text)] |
| 62 | + if version not in labels: |
| 63 | + return None |
| 64 | + for label in labels[labels.index(version) + 1 :]: |
| 65 | + if SEMVER.match(label): |
| 66 | + return label |
| 67 | + return None |
| 68 | + |
| 69 | + |
| 70 | +def build_notes(text: str, version: str, repo_url: str = DEFAULT_REPO_URL) -> str: |
| 71 | + """Build the release body for `version`, appending a compare link when possible. |
| 72 | +
|
| 73 | + Raises |
| 74 | + ------ |
| 75 | + ChangelogError |
| 76 | + If the version has no section, or its section has no content. Failing here |
| 77 | + keeps the workflow from publishing a release with an empty body. |
| 78 | + """ |
| 79 | + if not SEMVER.match(version): |
| 80 | + raise ChangelogError(f"not a semantic version: {version!r}") |
| 81 | + |
| 82 | + sections = dict(iter_sections(text)) |
| 83 | + if version not in sections: |
| 84 | + raise ChangelogError(f"{CHANGELOG.name} has no '## [{version}]' section") |
| 85 | + |
| 86 | + body = sections[version] |
| 87 | + if not body: |
| 88 | + raise ChangelogError(f"the '## [{version}]' section in {CHANGELOG.name} is empty") |
| 89 | + |
| 90 | + if "Full Changelog" in body: |
| 91 | + return body |
| 92 | + |
| 93 | + previous = previous_version(text, version) |
| 94 | + if previous is None: |
| 95 | + return body |
| 96 | + compare = f"{repo_url}/compare/v{previous}...v{version}" |
| 97 | + return f"{body}\n\n**Full Changelog**: {compare}" |
| 98 | + |
| 99 | + |
| 100 | +def main() -> int: |
| 101 | + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) |
| 102 | + parser.add_argument("version", help="version to extract, without a leading 'v'") |
| 103 | + parser.add_argument("--output", type=Path, help="write to this file instead of stdout") |
| 104 | + parser.add_argument( |
| 105 | + "--changelog", |
| 106 | + type=Path, |
| 107 | + default=CHANGELOG, |
| 108 | + help=f"changelog to read (default: {CHANGELOG.name})", |
| 109 | + ) |
| 110 | + parser.add_argument( |
| 111 | + "--repo-url", |
| 112 | + default=DEFAULT_REPO_URL, |
| 113 | + help="repository URL used to build the compare link", |
| 114 | + ) |
| 115 | + args = parser.parse_args() |
| 116 | + |
| 117 | + version = args.version.removeprefix("v") |
| 118 | + try: |
| 119 | + notes = build_notes( |
| 120 | + args.changelog.read_text(encoding="utf-8"), version, repo_url=args.repo_url |
| 121 | + ) |
| 122 | + except (ChangelogError, OSError) as error: |
| 123 | + print(f"Error: {error}", file=sys.stderr) |
| 124 | + return 1 |
| 125 | + |
| 126 | + if args.output: |
| 127 | + args.output.write_text(notes + "\n", encoding="utf-8") |
| 128 | + print(f"Wrote {len(notes.splitlines())} line(s) of release notes to {args.output}") |
| 129 | + else: |
| 130 | + print(notes) |
| 131 | + return 0 |
| 132 | + |
| 133 | + |
| 134 | +if __name__ == "__main__": |
| 135 | + sys.exit(main()) |
0 commit comments