345 lines
12 KiB
Python
345 lines
12 KiB
Python
#!/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 = "<!-- recent-updates:start -->"
|
|
README_MARKER_CLOSE = "<!-- recent-updates:end -->"
|
|
|
|
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"<details>\n<summary>Show release notes</summary>\n\n{body}\n\n</details>\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("<details>")
|
|
lines.append("<summary><strong>Recent Updates</strong></summary>")
|
|
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("</details>")
|
|
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 <details><summary>Recent Updates</summary>
|
|
legacy = re.compile(
|
|
r"<details>\s*<summary>\s*<strong>Recent Updates</strong>\s*</summary>.*?</details>",
|
|
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())
|