#!/usr/bin/env python3 """ Sync release notes from GitHub Releases into: - website/docs/releases.md (full history, grouped by minor) - README.md "Recent Updates" (latest N releases between sentinel markers) Why a sync script: the previous releases.md was hand-maintained and went stale (it claimed v9.6.3 was latest when v9.7.0 had shipped). GitHub Releases is the single source of truth; this script makes it the only source. Usage: python tools/sync_release_notes.py # write + verify python tools/sync_release_notes.py --check # CI: fail if drift GITHUB_TOKEN=xxx python tools/sync_release_notes.py # higher rate limit Local runs without a token use anonymous GitHub API (60 req/hr — plenty for one sync since we only paginate the /releases endpoint). """ from __future__ import annotations import argparse import json import os import re import shutil import ssl import subprocess import sys import textwrap import urllib.error import urllib.request from collections import defaultdict from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent RELEASES_MD = REPO_ROOT / "website" / "docs" / "releases.md" README_MD = REPO_ROOT / "README.md" OWNER = "CoplayDev" REPO = "unity-mcp" API = f"https://api.github.com/repos/{OWNER}/{REPO}/releases" README_RECENT_COUNT = 5 README_MARKER_OPEN = "" README_MARKER_CLOSE = "" RELEASES_HEADER = textwrap.dedent( """\ --- id: releases slug: /releases title: Release Notes sidebar_label: Releases description: Full version-by-version change history for MCP for Unity. --- # Release Notes Latest releases land in [`beta`](https://github.com/CoplayDev/unity-mcp/tree/beta) before promotion to [`main`](https://github.com/CoplayDev/unity-mcp/tree/main). Major breaking changes get a dedicated migration guide under [Migrations](/migrations/v5). For the canonical changelog with PR links, see [GitHub Releases](https://github.com/CoplayDev/unity-mcp/releases). > Auto-generated from the GitHub Releases API by `tools/sync_release_notes.py`. Do not hand-edit — changes will be overwritten on the next sync. """ ) # --------------------------------------------------------------------------- # Fetch # --------------------------------------------------------------------------- def _fetch_via_gh(path: str) -> list[dict] | None: """Prefer `gh api` when available — handles auth + SSL cleanly across macOS Python distributions that miss the system trust store.""" if not shutil.which("gh"): return None try: result = subprocess.run( ["gh", "api", path, "--paginate"], check=True, capture_output=True, text=True, timeout=60, ) except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e: print(f"gh api failed ({e}); falling back to urllib.", file=sys.stderr) return None # `gh api --paginate` concatenates JSON arrays as `][`. Split + parse. text = result.stdout.strip() if not text: return [] if "][" in text: text = "[" + text.replace("][", ",") + "]" # Now we may have [[..],[..]] — flatten. nested = json.loads(text) flat: list[dict] = [] for chunk in nested: flat.extend(chunk if isinstance(chunk, list) else [chunk]) return flat return json.loads(text) def _fetch_via_urllib(url: str) -> list[dict]: headers = { "User-Agent": "mcp-for-unity-docs-sync", "Accept": "application/vnd.github+json", } token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") if token: headers["Authorization"] = f"Bearer {token}" ctx = ssl.create_default_context() try: import certifi # type: ignore ctx = ssl.create_default_context(cafile=certifi.where()) except ImportError: pass req = urllib.request.Request(url, headers=headers) with urllib.request.urlopen(req, timeout=30, context=ctx) as resp: return json.loads(resp.read().decode("utf-8")) def fetch_all_releases() -> list[dict]: # Try `gh api` first. via_gh = _fetch_via_gh(f"repos/{OWNER}/{REPO}/releases?per_page=100") if via_gh is not None: return [r for r in via_gh if not r.get("draft")] # Fallback: paginate urllib. all_releases: list[dict] = [] page = 1 while True: batch = _fetch_via_urllib(f"{API}?per_page=100&page={page}") if not batch: break all_releases.extend(batch) if len(batch) < 100: break page += 1 return [r for r in all_releases if not r.get("draft")] # --------------------------------------------------------------------------- # Render # --------------------------------------------------------------------------- _VERSION_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)") def _parse_version(tag: str) -> tuple[int, int, int] | None: m = _VERSION_RE.match(tag.strip()) if not m: return None return tuple(int(x) for x in m.groups()) # type: ignore[return-value] def _minor_key(tag: str) -> str: v = _parse_version(tag) if not v: return tag return f"v{v[0]}.{v[1]} series" def _normalize_body(body: str | None) -> str: if not body: return "_No release notes._" body = body.replace("\r\n", "\n").replace("\r", "\n") # Trim trailing whitespace; collapse 3+ blank lines. body = body.strip() body = re.sub(r"\n{3,}", "\n\n", body) return body def _digest(body: str | None, max_chars: int = 280) -> str: """Pull a one-line digest for the README block. Prefers the first non-empty line that isn't a heading or list-of-PRs link.""" if not body: return "" for raw in body.splitlines(): line = raw.strip() if not line: continue # Skip headings and bullet-of-PR lines. if line.startswith(("#", ">", "**Full Changelog**")): continue if line.startswith(("*", "-")) and "github.com" in line and "pull/" in line: continue # Strip bullet prefixes for the digest. line = re.sub(r"^[*-]\s+", "", line) # Strip leading markdown emphasis. line = re.sub(r"^\*\*[^*]+\*\*[:.\s—-]*", "", line) if len(line) > max_chars: line = line[: max_chars - 1].rstrip() + "…" return line return "" def render_releases_md(releases: list[dict]) -> str: out = [RELEASES_HEADER] grouped: dict[str, list[dict]] = defaultdict(list) order: list[str] = [] for r in releases: key = _minor_key(r["tag_name"]) if key not in grouped: order.append(key) grouped[key].append(r) for key in order: out.append(f"## {key}\n") for r in grouped[key]: tag = r["tag_name"] name = (r.get("name") or tag).strip() date = (r.get("published_at") or "").split("T")[0] prerelease = " (beta)" if r.get("prerelease") else "" url = r.get("html_url") or f"https://github.com/{OWNER}/{REPO}/releases/tag/{tag}" body = _normalize_body(r.get("body")) summary = name if name and name != tag else tag out.append(f"### [{summary}{prerelease}]({url}) — {date}\n") out.append(f"
\nShow release notes\n\n{body}\n\n
\n") out.append("") # blank line between groups # Migration footer out.append("## Migration guides\n") out.append( "Breaking changes from prior major versions live under [Migrations](/migrations/v5):\n" ) out.append("- [v5 — UnityMcpBridge → MCPForUnity](/migrations/v5)") out.append("- [v6 — New Editor Window (UI Toolkit + service architecture)](/migrations/v6)") out.append("- [v8 — HTTP and Stdio support](/migrations/v8)") out.append("- [v10 — Asset Generation and Docs Refresh](/migrations/v10)\n") return "\n".join(out) def render_readme_recent(releases: list[dict], n: int = README_RECENT_COUNT) -> str: lines = [README_MARKER_OPEN] lines.append("
") lines.append("Recent Updates") lines.append("") for r in releases[:n]: tag = r["tag_name"] date = (r.get("published_at") or "").split("T")[0] url = r.get("html_url") or f"https://github.com/{OWNER}/{REPO}/releases/tag/{tag}" digest = _digest(r.get("body")) suffix = f" — {digest}" if digest else "" prerelease = " *(beta)*" if r.get("prerelease") else "" lines.append(f"* **[{tag}{prerelease}]({url})** ({date}){suffix}") lines.append("") lines.append( "Full history: [Release Notes](https://coplaydev.github.io/unity-mcp/releases)." ) lines.append("") lines.append("
") lines.append(README_MARKER_CLOSE) return "\n".join(lines) # --------------------------------------------------------------------------- # Apply # --------------------------------------------------------------------------- def replace_marked_block(text: str, replacement: str) -> str: pattern = re.compile( re.escape(README_MARKER_OPEN) + r".*?" + re.escape(README_MARKER_CLOSE), re.DOTALL, ) if pattern.search(text): return pattern.sub(replacement, text) # First-time insert: try to replace the legacy
Recent Updates legacy = re.compile( r"
\s*\s*Recent Updates\s*.*?
", re.DOTALL, ) if legacy.search(text): return legacy.sub(replacement, text) # Otherwise append before the "## Community" header if present. anchor = "## Community" if anchor in text: return text.replace(anchor, f"{replacement}\n\n{anchor}", 1) return text + "\n\n" + replacement + "\n" def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--check", action="store_true", help="Exit non-zero if files would change. Used by CI to detect drift.") args = parser.parse_args(argv) try: releases = fetch_all_releases() except urllib.error.HTTPError as e: print(f"GitHub API error: {e.code} {e.reason}", file=sys.stderr) if e.code == 403: print("Hint: set GITHUB_TOKEN to lift the anonymous rate limit.", file=sys.stderr) return 2 if not releases: print("No releases returned by GitHub API. Aborting to avoid blanking files.", file=sys.stderr) return 2 new_releases = render_releases_md(releases) new_readme_block = render_readme_recent(releases) existing_releases = RELEASES_MD.read_text(encoding="utf-8") if RELEASES_MD.exists() else "" existing_readme = README_MD.read_text(encoding="utf-8") if README_MD.exists() else "" new_readme = replace_marked_block(existing_readme, new_readme_block) releases_drift = existing_releases != new_releases readme_drift = existing_readme != new_readme if args.check: if releases_drift or readme_drift: print("Release notes are stale. Run:") print(" python tools/sync_release_notes.py") print("then commit the changes.") if releases_drift: print(f" - drift in {RELEASES_MD.relative_to(REPO_ROOT)}") if readme_drift: print(f" - drift in {README_MD.relative_to(REPO_ROOT)}") return 1 print(f"Release notes are up-to-date ({len(releases)} releases).") return 0 if releases_drift: RELEASES_MD.write_text(new_releases, encoding="utf-8") if readme_drift: README_MD.write_text(new_readme, encoding="utf-8") print(f"Synced {len(releases)} releases.") print(f" - {RELEASES_MD.relative_to(REPO_ROOT)}: {'updated' if releases_drift else 'unchanged'}") print(f" - {README_MD.relative_to(REPO_ROOT)}: {'updated' if readme_drift else 'unchanged'}") return 0 if __name__ == "__main__": sys.exit(main())