Skip to content

Commit be73961

Browse files
authored
docs: add changelog page and generation task (#671)
- Add changelog generation task to fetch releases from GitHub and format them for documentation - Integrate changelog into reference navigation and upgrade script - Update search highlight colors to purple matching active state Generated-by: aiautocommit
1 parent 0841242 commit be73961

5 files changed

Lines changed: 1202 additions & 8 deletions

File tree

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
#!/usr/bin/env python3
2+
#MISE description="Generate docs/src/content/docs/changelog.md from GitHub releases"
3+
"""Fetch GitHub releases and write a Starlight-ready changelog page.
4+
5+
Requires `gh` (authenticated for higher rate limits, public API works unauthenticated
6+
with lower limits). Skips draft and prerelease releases.
7+
8+
Usage:
9+
mise run docs-generate-changelog
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import json
15+
import re
16+
import subprocess
17+
import sys
18+
from datetime import datetime, timedelta, timezone
19+
from pathlib import Path
20+
21+
REPO = "railwayapp/railpack"
22+
RELEASES_URL = f"https://github.com/{REPO}/releases"
23+
OUTPUT = Path("docs/src/content/docs/changelog.md")
24+
# Only include releases published within this window on the docs page.
25+
CHANGELOG_YEARS = 1
26+
27+
# Match full GitHub PR/issue URLs so we can shorten them in prose.
28+
PR_URL_RE = re.compile(
29+
r"https://github\.com/railwayapp/railpack/(?:pull|issues)/(\d+)"
30+
)
31+
# Bare "Full Changelog" compare/commits links from release templates.
32+
FULL_CHANGELOG_RE = re.compile(
33+
r"\*\*Full Changelog\*\*:\s+(https://github\.com/railwayapp/railpack/"
34+
r"(?:compare|commits)/[^\s]+)"
35+
)
36+
# Boilerplate footer lines from the release notes template.
37+
INTERNAL_LINE_RE = re.compile(
38+
r"^\*?Internal (improvements|build maintenance|snapshot maintenance)\b",
39+
re.IGNORECASE,
40+
)
41+
# Demote ATX headings one level so each release can own the ## slot.
42+
HEADING_RE = re.compile(r"^(#{1,5})\s", re.MULTILINE)
43+
44+
45+
def fetch_releases() -> list[dict]:
46+
"""Return non-draft, non-prerelease releases newest-first.
47+
48+
Stream one JSON object per release so gh --paginate cannot drop pages
49+
the way an outer array expression sometimes does.
50+
"""
51+
result = subprocess.run(
52+
[
53+
"gh",
54+
"api",
55+
f"repos/{REPO}/releases",
56+
"--paginate",
57+
"--jq",
58+
(
59+
".[] | select(.draft == false and .prerelease == false) | "
60+
"{tag_name, name, published_at, html_url, body}"
61+
),
62+
],
63+
capture_output=True,
64+
text=True,
65+
check=False,
66+
)
67+
if result.returncode != 0:
68+
print(
69+
result.stderr.strip() or "failed to fetch releases via gh",
70+
file=sys.stderr,
71+
)
72+
sys.exit(1)
73+
74+
text = result.stdout.strip()
75+
if not text:
76+
return []
77+
78+
releases: list[dict] = []
79+
decoder = json.JSONDecoder()
80+
idx = 0
81+
while idx < len(text):
82+
while idx < len(text) and text[idx].isspace():
83+
idx += 1
84+
if idx >= len(text):
85+
break
86+
obj, offset = decoder.raw_decode(text, idx)
87+
idx = offset
88+
if isinstance(obj, dict):
89+
releases.append(obj)
90+
91+
# API returns newest-first per page; re-sort to be safe
92+
releases.sort(key=lambda r: r.get("published_at") or "", reverse=True)
93+
return releases
94+
95+
96+
def parse_published_at(published_at: str | None) -> datetime | None:
97+
if not published_at:
98+
return None
99+
try:
100+
return datetime.fromisoformat(published_at.replace("Z", "+00:00"))
101+
except ValueError:
102+
return None
103+
104+
105+
def filter_recent_releases(
106+
releases: list[dict],
107+
) -> tuple[list[dict], int]:
108+
"""Keep releases from the last CHANGELOG_YEARS; return (kept, older_count)."""
109+
cutoff = datetime.now(timezone.utc) - timedelta(days=365 * CHANGELOG_YEARS)
110+
recent: list[dict] = []
111+
older = 0
112+
for release in releases:
113+
published = parse_published_at(release.get("published_at"))
114+
if published is None or published >= cutoff:
115+
recent.append(release)
116+
else:
117+
older += 1
118+
return recent, older
119+
120+
121+
def format_date(published_at: str | None) -> str:
122+
if not published_at:
123+
return ""
124+
try:
125+
dt = datetime.fromisoformat(published_at.replace("Z", "+00:00"))
126+
except ValueError:
127+
return published_at[:10]
128+
return dt.strftime("%B %-d, %Y")
129+
130+
131+
def shorten_pr_urls(text: str) -> str:
132+
"""Turn full PR/issue URLs into [#N](url) markdown links."""
133+
134+
def repl(match: re.Match[str]) -> str:
135+
num = match.group(1)
136+
url = match.group(0)
137+
return f"[#{num}]({url})"
138+
139+
return PR_URL_RE.sub(repl, text)
140+
141+
142+
def linkify_full_changelog(text: str) -> str:
143+
"""Turn bare Full Changelog URLs into markdown links."""
144+
145+
def repl(match: re.Match[str]) -> str:
146+
url = match.group(1)
147+
label = url.rsplit("/", 1)[-1]
148+
return f"**Full Changelog**: [{label}]({url})"
149+
150+
return FULL_CHANGELOG_RE.sub(repl, text)
151+
152+
153+
def demote_headings(text: str) -> str:
154+
"""Shift ATX headings down one level (## → ###, etc.)."""
155+
return HEADING_RE.sub(lambda m: "#" + m.group(1) + " ", text)
156+
157+
158+
def is_noise_line(line: str) -> bool:
159+
stripped = line.strip()
160+
if not stripped:
161+
return False
162+
if stripped == "---":
163+
return True
164+
if INTERNAL_LINE_RE.match(stripped):
165+
return True
166+
return False
167+
168+
169+
def clean_body(body: str | None) -> str:
170+
if not body or not body.strip():
171+
return ""
172+
173+
text = body.replace("\r\n", "\n").strip()
174+
text = demote_headings(text)
175+
text = shorten_pr_urls(text)
176+
text = linkify_full_changelog(text)
177+
178+
cleaned: list[str] = []
179+
prev_blank = False
180+
for line in text.split("\n"):
181+
line = line.rstrip()
182+
if is_noise_line(line):
183+
continue
184+
blank = line == ""
185+
# Collapse runs of blank lines left by filtered noise
186+
if blank and (prev_blank or not cleaned):
187+
continue
188+
cleaned.append(line)
189+
prev_blank = blank
190+
191+
return "\n".join(cleaned).strip()
192+
193+
194+
def version_label(tag_name: str, name: str | None) -> str:
195+
tag = tag_name.strip()
196+
if tag.startswith("v"):
197+
return tag
198+
if name and name.strip():
199+
return name.strip()
200+
return tag
201+
202+
203+
def render_release(release: dict) -> str:
204+
tag = release.get("tag_name") or ""
205+
label = version_label(tag, release.get("name"))
206+
date = format_date(release.get("published_at"))
207+
html_url = release.get("html_url") or f"https://github.com/{REPO}/releases/tag/{tag}"
208+
body = clean_body(release.get("body"))
209+
210+
parts = [f"## {label}"]
211+
meta = []
212+
if date:
213+
meta.append(date)
214+
meta.append(f"[GitHub release]({html_url})")
215+
parts.append(" · ".join(meta))
216+
parts.append("")
217+
if body:
218+
parts.append(body)
219+
parts.append("")
220+
return "\n".join(parts)
221+
222+
223+
def render_page(releases: list[dict], older_count: int) -> str:
224+
header = """\
225+
---
226+
title: Changelog
227+
description: Release notes for each published version of Railpack.
228+
editUrl: false
229+
tableOfContents:
230+
minHeadingLevel: 2
231+
maxHeadingLevel: 2
232+
---
233+
234+
"""
235+
sections = [render_release(r) for r in releases]
236+
body = "\n".join(sections).rstrip()
237+
if older_count > 0:
238+
window = "year" if CHANGELOG_YEARS == 1 else f"{CHANGELOG_YEARS} years"
239+
body += (
240+
f"\n\n## Older releases\n\n"
241+
f"This page covers the last {window}. "
242+
f"See all releases on "
243+
f"[GitHub]({RELEASES_URL}).\n"
244+
)
245+
# Single trailing newline at EOF
246+
return header + body + "\n"
247+
248+
249+
def main() -> None:
250+
repo_root = subprocess.run(
251+
["git", "rev-parse", "--show-toplevel"],
252+
capture_output=True,
253+
text=True,
254+
check=True,
255+
).stdout.strip()
256+
257+
root = Path(repo_root)
258+
output = root / OUTPUT
259+
260+
print(f"Fetching releases from {REPO}…")
261+
releases = fetch_releases()
262+
if not releases:
263+
print("No published releases found", file=sys.stderr)
264+
sys.exit(1)
265+
266+
recent, older_count = filter_recent_releases(releases)
267+
if not recent:
268+
print(
269+
f"No releases in the last {CHANGELOG_YEARS} years",
270+
file=sys.stderr,
271+
)
272+
sys.exit(1)
273+
274+
print(
275+
f"Writing {len(recent)} release(s) to {OUTPUT}"
276+
f" (omitting {older_count} older)"
277+
)
278+
output.parent.mkdir(parents=True, exist_ok=True)
279+
output.write_text(render_page(recent, older_count), encoding="utf-8")
280+
print(f"Wrote {output.relative_to(root)}")
281+
282+
283+
if __name__ == "__main__":
284+
main()

.mise/tasks/mise-upgrade

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ fi
9595
echo "Upgrading go and mise packages..."
9696
mise run upgrade
9797
if [[ -n "$(git status --porcelain go.mod go.sum)" ]]; then
98-
git add go.mod go.sum docs/package.json mise.lock docs/bun.lock
98+
git add go.mod go.sum docs/package.json mise.lock docs/bun.lock
9999
git commit -m "chore: update go, mise, and bun packages"
100100
fi
101101

@@ -120,6 +120,17 @@ if [[ -n "$(git status --porcelain --untracked-files=no)" ]]; then
120120
git commit -m "test: update mise lock with latest version"
121121
fi
122122

123+
echo "Updating docs changelog from GitHub releases..."
124+
changelog_file="docs/src/content/docs/changelog.md"
125+
if mise run docs-generate-changelog; then
126+
if [[ -n "$(git status --porcelain -- "$changelog_file")" ]]; then
127+
git add -- "$changelog_file"
128+
git commit -m "docs: updated changelog"
129+
fi
130+
else
131+
echo "Error: failed to generate docs changelog (continuing upgrade)" >&2
132+
fi
133+
123134
# Push the branch (needed in CI and good practice locally)
124135
git push -u origin "$branch"
125136

docs/astro.config.mjs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -148,10 +148,6 @@ export default defineConfig({
148148
label: "Getting Started",
149149
link: "/getting-started",
150150
},
151-
{
152-
label: "FAQ",
153-
link: "/faq",
154-
},
155151
{
156152
label: "Installation",
157153
link: "/installation",
@@ -222,7 +218,11 @@ export default defineConfig({
222218
},
223219
{
224220
label: "Reference",
225-
items: [{ label: "CLI Commands", link: "/reference/cli" }],
221+
items: [
222+
{ label: "CLI Commands", link: "/reference/cli" },
223+
{ label: "Changelog", link: "/changelog" },
224+
{ label: "FAQ", link: "/faq" },
225+
],
226226
},
227227
{
228228
label: "Architecture",

0 commit comments

Comments
 (0)