Skip to content

Commit 3ec9228

Browse files
authored
Add a script that auto-adds release notes (#21)
1 parent 95b6ae0 commit 3ec9228

File tree

5 files changed

+215
-1
lines changed

5 files changed

+215
-1
lines changed

.github/workflows/deploy.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@ jobs:
3535
node-version: 20.x
3636
- name: Install MyST Markdown
3737
run: npm install -g mystmd
38+
- name: Setup Python
39+
uses: actions/setup-python@v4
40+
with:
41+
python-version: '3.11'
42+
- name: Generate Release Notes from GitHub Releases
43+
run: python src/generate_release_notes.py
44+
env:
45+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
3846
- name: Build HTML Assets
3947
run: myst build --html
4048
- name: Upload artifact

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
# MyST build outputs
22
_build
3-
node_modules
3+
node_modules
4+
posts/releases/*.md
5+
!posts/releases/README.md

README.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,49 @@ To run it locally, take these steps:
1919
```shell
2020
$ myst start
2121
```
22+
23+
## Release Posts
24+
25+
The `posts/releases/` folder contains automatically generated release posts for all repositories in the Jupyter Book organization.
26+
27+
### To add release notes to the blog
28+
29+
1. Make the release on GitHub
30+
2. Re-build the blog
31+
32+
The blog action will catch the latest release and include it in the list, sorted by dates.
33+
34+
### How it works
35+
36+
The release posts are generated automatically using the `src/generate_release_notes.py` script, which:
37+
38+
1. **Fetches all repositories** from the `jupyter-book` GitHub organization
39+
2. **Retrieves all releases** from each repository using the GitHub API
40+
3. **Sorts releases by date** (newest first)
41+
4. **Generates numbered markdown files** with proper frontmatter
42+
5. **Formats @mentions** with backticks for better readability
43+
6. **Adds formatted dates** to titles (e.g., "July 6th, 2025")
44+
45+
### File Naming Convention
46+
47+
Files are named with a numbered prefix to ensure proper ordering until we make it possible to list blog posts sorted by date:
48+
- `001-{repo-name}-{release-title}.md` (newest release)
49+
- `002-{repo-name}-{release-title}.md`
50+
- ...
51+
- `224-{repo-name}-{release-title}.md` (oldest release)
52+
53+
### Generating Release Posts
54+
55+
To generate the release posts locally:
56+
57+
```shell
58+
$ python src/generate_release_notes.py
59+
```
60+
61+
**Requirements:**
62+
- GitHub CLI (`gh`) must be installed and authenticated
63+
- Python 3 for processing JSON data
64+
65+
### Automation
66+
67+
The release posts are automatically generated during the CI/CD build process (see `.github/workflows/deploy.yml`). This ensures that new releases are always included in the blog without manual intervention.

myst.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,13 @@ project:
4848
github: https://github.com/jupyter-book/blog
4949
# To autogenerate a Table of Contents, run "myst init --write-toc"
5050
license: CC0-1.0
51+
exclude:
52+
- posts/releases/README.md
5153
toc:
5254
- file: index.md
55+
- title: Releases
56+
children:
57+
- pattern: posts/releases/*.md
5358
- title: "2025"
5459
children:
5560
- pattern: posts/2025-*.md

src/generate_release_notes.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Script to generate release notes from GitHub releases in the jupyter-book org.
4+
"""
5+
6+
import json
7+
import re
8+
import subprocess
9+
import sys
10+
import shutil
11+
from datetime import datetime
12+
from pathlib import Path
13+
14+
15+
def format_date(date_str):
16+
"""Convert ISO date string to readable format like 'June 17th, 2025'"""
17+
date_obj = datetime.strptime(date_str, "%Y-%m-%d")
18+
19+
# Get month name
20+
month = date_obj.strftime("%B")
21+
22+
# Get day with ordinal suffix
23+
day = date_obj.day
24+
if 4 <= day <= 20 or 24 <= day <= 30:
25+
suffix = "th"
26+
else:
27+
suffix = ["st", "nd", "rd"][day % 10 - 1]
28+
29+
# Get year
30+
year = date_obj.year
31+
32+
return f"{month} {day}{suffix}, {year}"
33+
34+
35+
def main():
36+
# Check if gh command exists
37+
try:
38+
subprocess.run(["gh", "--version"], capture_output=True, check=True)
39+
except (subprocess.CalledProcessError, FileNotFoundError):
40+
print("Error: GitHub CLI (gh) is not installed or not available in PATH")
41+
print("Please install it from: https://cli.github.com/")
42+
sys.exit(1)
43+
44+
# Configuration
45+
org = "jupyter-book"
46+
releases_dir = Path("posts/releases")
47+
temp_dir = Path("_build/release_notes")
48+
49+
# Clean and ensure directories exist
50+
if releases_dir.exists():
51+
shutil.rmtree(releases_dir)
52+
releases_dir.mkdir(parents=True, exist_ok=True)
53+
temp_dir.mkdir(parents=True, exist_ok=True)
54+
55+
print(f"Fetching all repositories from {org} organization...")
56+
57+
# Fetch all repositories
58+
try:
59+
result = subprocess.run(
60+
["gh", "api", f"orgs/{org}/repos", "--paginate"],
61+
capture_output=True,
62+
text=True,
63+
check=True,
64+
)
65+
repos = json.loads(result.stdout)
66+
except subprocess.CalledProcessError as e:
67+
print(f"Error fetching repositories: {e}")
68+
sys.exit(1)
69+
70+
print("Fetching releases from all repositories...")
71+
72+
all_releases = []
73+
74+
for repo in repos:
75+
repo_name = repo["name"]
76+
print(f"Fetching releases from {repo_name}...")
77+
78+
try:
79+
result = subprocess.run(
80+
["gh", "api", f"repos/{org}/{repo_name}/releases", "--paginate"],
81+
capture_output=True,
82+
text=True,
83+
check=True,
84+
)
85+
releases = json.loads(result.stdout)
86+
87+
for release in releases:
88+
release["repo_name"] = repo_name
89+
all_releases.append(release)
90+
91+
except subprocess.CalledProcessError:
92+
print(f"No releases found for {repo_name}")
93+
except json.JSONDecodeError:
94+
print(f"Error parsing releases for {repo_name}")
95+
96+
# Sort releases by publication date so the largest numbers are the newest.
97+
# this forces latest releases to the top until we have a proper sorting system.
98+
all_releases.sort(key=lambda x: x["published_at"], reverse=True)
99+
100+
total = len(all_releases)
101+
print(f"Found {total} total releases")
102+
103+
for ii, release in enumerate(all_releases):
104+
number = ii + 1
105+
title = release["name"] or release["tag_name"]
106+
repo_name = release["repo_name"]
107+
108+
# Add repository name to title if it's not already present
109+
# Normalize both strings by replacing hyphens, underscores, and spaces
110+
normalized_repo = (
111+
repo_name.lower().replace("-", "").replace("_", "").replace(" ", "")
112+
)
113+
normalized_title = (
114+
title.lower().replace("-", "").replace("_", "").replace(" ", "")
115+
)
116+
117+
if normalized_repo not in normalized_title:
118+
title = f"{repo_name} {title}"
119+
120+
date = release["published_at"][:10]
121+
formatted_date = format_date(date)
122+
title = f"{title} - {formatted_date}"
123+
body = release["body"] or ""
124+
125+
# Wrap @mentions in backticks (only if preceded by space, (, comma, or [, and not already wrapped)
126+
body = re.sub(r"(?<=[\s(,\[])@(\w+)(?!`)", r"`@\1`", body)
127+
128+
# Create filename
129+
safe_title = re.sub(r"[^a-zA-Z0-9-]", "-", title.lower())
130+
filename = releases_dir / f"{number:03d}-{repo_name}-{safe_title}.md"
131+
132+
# Write the markdown file
133+
with open(filename, "w") as f:
134+
f.write("---\n")
135+
f.write(f"title: {title}\n")
136+
f.write(f"date: {date}\n")
137+
f.write("author: The Jupyter Book Team\n")
138+
f.write("tags:\n")
139+
f.write(" - release\n")
140+
f.write("---\n\n")
141+
f.write(
142+
f"{{button}}`Release Source <{release['html_url']}>`\n\n"
143+
)
144+
f.write(body)
145+
f.write("\n")
146+
147+
print(f"Generated: {filename}")
148+
149+
print("Release posts generated successfully!")
150+
151+
152+
if __name__ == "__main__":
153+
main()

0 commit comments

Comments
 (0)