Files
pack/.mise/tasks/docs-generate-changelog
zeekayandHanzo Dev 5a8a970b16 chore(pack): drop Railway branding from our own docs, comments, and examples
Rewrites our own comments, help text, and example READMEs that named
Railway or linked railway.app so they describe what they do instead.
Retargets the changelog generator and RELEASE.md's Actions/release
links at this repo. Leaves upstream issue links, the MIT copyright,
and the builder/runtime/frontend image references untouched -- those
still point at ghcr.io/railwayapp because we don't publish our own
copies of those images yet.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-04 02:05:25 -07:00

285 lines
7.9 KiB
Python
Executable File

#!/usr/bin/env python3
#MISE description="Generate docs/src/content/docs/changelog.md from GitHub releases"
"""Fetch GitHub releases and write a Starlight-ready changelog page.
Requires `gh` (authenticated for higher rate limits, public API works unauthenticated
with lower limits). Skips draft and prerelease releases.
Usage:
mise run docs-generate-changelog
"""
from __future__ import annotations
import json
import re
import subprocess
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
REPO = "hanzoai/pack"
RELEASES_URL = f"https://github.com/{REPO}/releases"
OUTPUT = Path("docs/src/content/docs/changelog.md")
# Only include releases published within this window on the docs page.
CHANGELOG_YEARS = 1
# Match full GitHub PR/issue URLs so we can shorten them in prose.
PR_URL_RE = re.compile(
rf"https://github\.com/{re.escape(REPO)}/(?:pull|issues)/(\d+)"
)
# Bare "Full Changelog" compare/commits links from release templates.
FULL_CHANGELOG_RE = re.compile(
rf"\*\*Full Changelog\*\*:\s+(https://github\.com/{re.escape(REPO)}/"
r"(?:compare|commits)/[^\s]+)"
)
# Boilerplate footer lines from the release notes template.
INTERNAL_LINE_RE = re.compile(
r"^\*?Internal (improvements|build maintenance|snapshot maintenance)\b",
re.IGNORECASE,
)
# Demote ATX headings one level so each release can own the ## slot.
HEADING_RE = re.compile(r"^(#{1,5})\s", re.MULTILINE)
def fetch_releases() -> list[dict]:
"""Return non-draft, non-prerelease releases newest-first.
Stream one JSON object per release so gh --paginate cannot drop pages
the way an outer array expression sometimes does.
"""
result = subprocess.run(
[
"gh",
"api",
f"repos/{REPO}/releases",
"--paginate",
"--jq",
(
".[] | select(.draft == false and .prerelease == false) | "
"{tag_name, name, published_at, html_url, body}"
),
],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
print(
result.stderr.strip() or "failed to fetch releases via gh",
file=sys.stderr,
)
sys.exit(1)
text = result.stdout.strip()
if not text:
return []
releases: list[dict] = []
decoder = json.JSONDecoder()
idx = 0
while idx < len(text):
while idx < len(text) and text[idx].isspace():
idx += 1
if idx >= len(text):
break
obj, offset = decoder.raw_decode(text, idx)
idx = offset
if isinstance(obj, dict):
releases.append(obj)
# API returns newest-first per page; re-sort to be safe
releases.sort(key=lambda r: r.get("published_at") or "", reverse=True)
return releases
def parse_published_at(published_at: str | None) -> datetime | None:
if not published_at:
return None
try:
return datetime.fromisoformat(published_at.replace("Z", "+00:00"))
except ValueError:
return None
def filter_recent_releases(
releases: list[dict],
) -> tuple[list[dict], int]:
"""Keep releases from the last CHANGELOG_YEARS; return (kept, older_count)."""
cutoff = datetime.now(timezone.utc) - timedelta(days=365 * CHANGELOG_YEARS)
recent: list[dict] = []
older = 0
for release in releases:
published = parse_published_at(release.get("published_at"))
if published is None or published >= cutoff:
recent.append(release)
else:
older += 1
return recent, older
def format_date(published_at: str | None) -> str:
if not published_at:
return ""
try:
dt = datetime.fromisoformat(published_at.replace("Z", "+00:00"))
except ValueError:
return published_at[:10]
return dt.strftime("%B %-d, %Y")
def shorten_pr_urls(text: str) -> str:
"""Turn full PR/issue URLs into [#N](url) markdown links."""
def repl(match: re.Match[str]) -> str:
num = match.group(1)
url = match.group(0)
return f"[#{num}]({url})"
return PR_URL_RE.sub(repl, text)
def linkify_full_changelog(text: str) -> str:
"""Turn bare Full Changelog URLs into markdown links."""
def repl(match: re.Match[str]) -> str:
url = match.group(1)
label = url.rsplit("/", 1)[-1]
return f"**Full Changelog**: [{label}]({url})"
return FULL_CHANGELOG_RE.sub(repl, text)
def demote_headings(text: str) -> str:
"""Shift ATX headings down one level (## → ###, etc.)."""
return HEADING_RE.sub(lambda m: "#" + m.group(1) + " ", text)
def is_noise_line(line: str) -> bool:
stripped = line.strip()
if not stripped:
return False
if stripped == "---":
return True
if INTERNAL_LINE_RE.match(stripped):
return True
return False
def clean_body(body: str | None) -> str:
if not body or not body.strip():
return ""
text = body.replace("\r\n", "\n").strip()
text = demote_headings(text)
text = shorten_pr_urls(text)
text = linkify_full_changelog(text)
cleaned: list[str] = []
prev_blank = False
for line in text.split("\n"):
line = line.rstrip()
if is_noise_line(line):
continue
blank = line == ""
# Collapse runs of blank lines left by filtered noise
if blank and (prev_blank or not cleaned):
continue
cleaned.append(line)
prev_blank = blank
return "\n".join(cleaned).strip()
def version_label(tag_name: str, name: str | None) -> str:
tag = tag_name.strip()
if tag.startswith("v"):
return tag
if name and name.strip():
return name.strip()
return tag
def render_release(release: dict) -> str:
tag = release.get("tag_name") or ""
label = version_label(tag, release.get("name"))
date = format_date(release.get("published_at"))
html_url = release.get("html_url") or f"https://github.com/{REPO}/releases/tag/{tag}"
body = clean_body(release.get("body"))
parts = [f"## {label}"]
meta = []
if date:
meta.append(date)
meta.append(f"[GitHub release]({html_url})")
parts.append(" · ".join(meta))
parts.append("")
if body:
parts.append(body)
parts.append("")
return "\n".join(parts)
def render_page(releases: list[dict], older_count: int) -> str:
header = """\
---
title: Changelog
description: Release notes for each published version of Railpack.
editUrl: false
tableOfContents:
minHeadingLevel: 2
maxHeadingLevel: 2
---
"""
sections = [render_release(r) for r in releases]
body = "\n".join(sections).rstrip()
if older_count > 0:
window = "year" if CHANGELOG_YEARS == 1 else f"{CHANGELOG_YEARS} years"
body += (
f"\n\n## Older releases\n\n"
f"This page covers the last {window}. "
f"See all releases on "
f"[GitHub]({RELEASES_URL}).\n"
)
# Single trailing newline at EOF
return header + body + "\n"
def main() -> None:
repo_root = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
check=True,
).stdout.strip()
root = Path(repo_root)
output = root / OUTPUT
print(f"Fetching releases from {REPO}…")
releases = fetch_releases()
if not releases:
print("No published releases found", file=sys.stderr)
sys.exit(1)
recent, older_count = filter_recent_releases(releases)
if not recent:
print(
f"No releases in the last {CHANGELOG_YEARS} years",
file=sys.stderr,
)
sys.exit(1)
print(
f"Writing {len(recent)} release(s) to {OUTPUT}"
f" (omitting {older_count} older)"
)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(render_page(recent, older_count), encoding="utf-8")
print(f"Wrote {output.relative_to(root)}")
if __name__ == "__main__":
main()