Skip to content

Commit 01e74df

Browse files
sderevAI-sderev
andcommitted
Auto-populate GitHub release notes from CHANGELOG.md
Add `.ci/changelog-notes` (Python) to extract a version's section from `CHANGELOG.md`, converting RST underlined headers to Markdown `###`. Add a `release-notes` job to `release.yml` that runs on `release: published`, extracts the matching changelog section, and updates the release body via `gh release edit`. The job runs in parallel with the existing build/publish pipeline and requires only `contents: write`. Co-authored-by: AI <ai@sderev.com>
1 parent 33ac08f commit 01e74df

2 files changed

Lines changed: 80 additions & 0 deletions

File tree

.ci/changelog-notes

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
#!/usr/bin/env python3
2+
"""Extract release notes for a version from CHANGELOG.md.
3+
4+
Reads the matching ``## <version>`` section, converts RST-style underlined
5+
category headers to Markdown ``###`` headers, and prints the result.
6+
7+
Usage::
8+
9+
.ci/changelog-notes <version> [changelog-path]
10+
.ci/changelog-notes 0.1.1
11+
"""
12+
13+
import re
14+
import sys
15+
from pathlib import Path
16+
17+
18+
def extract_notes(changelog: str, version: str) -> str | None:
19+
"""Return the release-notes section for *version*, or ``None``."""
20+
header = re.compile(rf"^## {re.escape(version)}\b[^\n]*$", re.MULTILINE)
21+
match = header.search(changelog)
22+
if not match:
23+
return None
24+
25+
start = match.end() + 1 # skip past header line's newline
26+
27+
# The section ends at the next version header or ``<a id=`` anchor.
28+
boundary = re.compile(r"^(?:## |<a id=)", re.MULTILINE)
29+
end_match = boundary.search(changelog, start)
30+
raw = changelog[start : end_match.start()] if end_match else changelog[start:]
31+
32+
# Convert RST underlined headers (``Category\n-----``) → ``### Category``
33+
notes = re.sub(r"^(.+)\n-{3,}$", r"### \1", raw, flags=re.MULTILINE)
34+
return notes.strip()
35+
36+
37+
def main() -> int:
38+
if len(sys.argv) < 2:
39+
print("usage: changelog-notes <version> [changelog]", file=sys.stderr)
40+
return 2
41+
42+
version = sys.argv[1]
43+
path = Path(sys.argv[2]) if len(sys.argv) > 2 else Path("CHANGELOG.md")
44+
45+
if not path.is_file():
46+
print(f"error: {path} not found", file=sys.stderr)
47+
return 1
48+
49+
notes = extract_notes(path.read_text(), version)
50+
if notes is None:
51+
print(f"error: version {version} not found in {path}", file=sys.stderr)
52+
return 1
53+
54+
print(notes)
55+
return 0
56+
57+
58+
if __name__ == "__main__":
59+
sys.exit(main())

.github/workflows/release.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,24 @@ permissions:
88
contents: read
99

1010
jobs:
11+
release-notes:
12+
name: Populate release notes from CHANGELOG.md
13+
runs-on: ubuntu-latest
14+
permissions:
15+
contents: write
16+
steps:
17+
- name: Checkout code
18+
uses: actions/checkout@v4
19+
20+
- name: Update release body
21+
env:
22+
GH_TOKEN: ${{ github.token }}
23+
TAG: ${{ github.event.release.tag_name }}
24+
run: |
25+
version="${TAG#v}"
26+
python3 .ci/changelog-notes "$version" > /tmp/release-notes.md
27+
gh release edit "$TAG" --notes-file /tmp/release-notes.md
28+
1129
build:
1230
name: Build distribution packages
1331
runs-on: ubuntu-latest
@@ -40,6 +58,9 @@ jobs:
4058
permissions:
4159
contents: read
4260
id-token: write
61+
environment:
62+
name: release
63+
url: https://pypi.org/project/wslshot/
4364
steps:
4465
- name: Download build artifacts
4566
uses: actions/download-artifact@v4

0 commit comments

Comments
 (0)