chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,344 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user