#!/usr/bin/env python3 """Update marker-driven documentation for upstream and template releases.""" import argparse import json import os import re import sys from datetime import date from pathlib import Path from typing import NamedTuple from urllib.request import Request, urlopen ROOT = Path(__file__).resolve().parents[2] RELEASES_API = "https://api.github.com/repos/go-gitea/gitea/releases" VERSION_RE = re.compile(r"v?(\d+)\.(\d+)\.(\d+)\Z") ROW_RE = re.compile(r"^\|\s*(?:\*\*)?(\d+\.\d+\.\d+)(?:\*\*)?\s*\|") INLINE_VERSION_RE = re.compile(r"(?\s*$") TAG_RE = re.compile(r"^\s*$") BADGE_RE = re.compile(r"Gitea-(\d+\.\d+\.\d+)(?:%20(?:pending|%5BPENDING%5D))?%20%7C%20(\d+\.\d+\.\d+)%20tested-(?:blue|yellow)") RELEASE_VERSION_RE = re.compile(r"\bv\d+\.\d+\.\d+\b") RELEASE_LINK_RE = re.compile(r"\[v\d+\.\d+\.\d+\]\(https://[^)]+/releases/latest\)") TABLES = { "VERSION-MAP": "| Gitea version | Template release |", "HISTORY": "| Gitea | Release Date | Mail Template Changes | Breaking? |", } SKIP_DIRECTORIES = frozenset((".git", "node_modules", "vendor", "dist")) class TagBlock(NamedTuple): path: Path subject: str content: str start: int end: int def body(lines, block): return lines[block.start + 1:block.end] def version_key(value): match = VERSION_RE.fullmatch(value) if not match: raise ValueError(f"Expected a stable X.Y.Z version, got {value!r}") return tuple(int(part) for part in match.groups()) def fetch_release(requested_version): endpoint = f"{RELEASES_API}/tags/v{requested_version}" if requested_version else f"{RELEASES_API}/latest" request = Request(endpoint, headers={"Accept": "application/vnd.github+json", "User-Agent": "gitea-mail-templates-tracker"}) token = os.environ.get("GITHUB_TOKEN") if token: request.add_header("Authorization", f"Bearer {token}") with urlopen(request, timeout=20) as response: release = json.load(response) if release.get("draft") or release.get("prerelease"): raise ValueError("Draft and prerelease Gitea versions are not tracked") version = str(release["tag_name"]).removeprefix("v") version_key(version) if requested_version and version != requested_version: raise ValueError(f"Release tag mismatch: expected {requested_version}, got {version}") published = release.get("published_at") if not published: raise ValueError(f"Release v{version} has no published_at date") release_date = date.fromisoformat(published[:10]).isoformat() return version, release_date def table(lines, name): if len(lines) < 3 or lines[0].strip() != TABLES[name]: raise ValueError(f"Unexpected table header in TRACKER:{name}") if not re.fullmatch(r"\|[\s|:-]+\|", lines[1].strip()): raise ValueError(f"Missing Markdown table separator in TRACKER:{name}") if any(not line.startswith("|") for line in lines[2:]): raise ValueError(f"Non-table line in TRACKER:{name}") versions = set() for line in lines[2:]: match = ROW_RE.match(line) if not match: if name == "HISTORY" and re.match(r"^\|\s*\*\*≤\s*\d+\.\d+\.x\*\*\s*\|", line): continue raise ValueError(f"Malformed version row in TRACKER:{name}: {line.strip()}") if match.group(1) in versions: raise ValueError(f"Duplicate version in TRACKER:{name}: {match.group(1)}") versions.add(match.group(1)) if not versions: raise ValueError(f"Empty version table in TRACKER:{name}") return versions def documents(root): """Scan every Markdown file; each participant declares its paired tag blocks.""" result = {} blocks = [] for path in sorted(root.rglob("*.md")): if any(part in SKIP_DIRECTORIES or part.startswith("tracker-test-") for part in path.relative_to(root).parts[:-1]): continue lines = path.read_text(encoding="utf-8").splitlines(keepends=True) declarations = [MANIFEST_RE.match(line) for line in lines] declarations = [match for match in declarations if match] if len(declarations) > 1: raise ValueError(f"Multiple DOC-TAGS declarations in {path}") if not declarations: if any(TAG_RE.match(line) for line in lines): raise ValueError(f"Tag block without DOC-TAGS declaration in {path}") continue manifest = json.loads(declarations[0].group(1)) if not isinstance(manifest, dict) or not manifest: raise ValueError(f"DOC-TAGS must be a non-empty object in {path}") declared = set() for subject, contents in manifest.items(): if subject not in HANDLERS or not isinstance(contents, list) or not contents: raise ValueError(f"Unknown or empty DOC-TAGS subject {subject!r} in {path}") for content in contents: if content not in HANDLERS[subject]: raise ValueError(f"Unknown {subject} content {content!r} in {path}") declared.add((subject, content)) result[path] = lines opened = None found = set() for index, line in enumerate(lines): match = TAG_RE.match(line) if not match: continue closing, subject, content = match.groups() key = (subject, content) if key not in declared: raise ValueError(f"Undeclared {subject}:{content} block in {path}") if closing: if opened is None or opened[0] != key: raise ValueError(f"Unmatched closing tag {subject}:{content} in {path}") if key not in found: blocks.append(TagBlock(path, subject, content, opened[1], index)) found.add(key) opened = None elif opened is not None: raise ValueError(f"Nested tag block in {path}") else: opened = (key, index) if opened is not None: raise ValueError(f"Unclosed tag block {opened[0]} in {path}") if missing := declared - found: raise ValueError(f"Missing declared tag block(s) {sorted(missing)} in {path}") return result, blocks def latest_tested(documents_by_path, blocks): versions = set() for block in blocks: if (block.subject, block.content) == ("TRACKER", "LATEST-TESTED"): match = re.search(r"Gitea\s+(\d+\.\d+\.\d+)", "".join(body(documents_by_path[block.path], block))) if not match: raise ValueError(f"Missing Gitea version in TRACKER:LATEST-TESTED in {block.path}") versions.add(match.group(1)) if len(versions) != 1: raise ValueError(f"LATEST-TESTED markers disagree: {sorted(versions)}") return versions.pop() def update_version_map(content, version, release_date, tested): if version not in table(content, "VERSION-MAP"): content.insert(2, f"| {version} | [PENDING] Compatibility verification; no tested template release yet |\n") return content def update_history(content, version, release_date, tested): if version not in table(content, "HISTORY"): content.insert(2, f"| **{version}** | {release_date} | [PENDING] Mail-template compatibility verification | TBD |\n") return content def update_upstream(content, version, release_date, tested): if len(content) != 1 or len(STATUS_RE.findall(content[0])) != 1 or len(INLINE_VERSION_RE.findall(content[0])) != 1: raise ValueError("TRACKER:UPSTREAM needs exactly one status and version on one body line") line = INLINE_VERSION_RE.sub(version, content[0], count=1) content[0] = STATUS_RE.sub("[PENDING]", line, count=1) return content def update_badge(content, version, release_date, tested): match = BADGE_RE.search(content[0]) if len(content) == 1 else None if not match or match.group(2) != tested: raise ValueError("TRACKER:BADGE has an unexpected format or tested version") badge = f"Gitea-{version}%20%5BPENDING%5D%20%7C%20{tested}%20tested-yellow" content[0] = BADGE_RE.sub(badge, content[0], count=1) return content def keep_manual(content, version, release_date, tested): return content def update_release(content, tag, path): line = "".join(content) matches = RELEASE_VERSION_RE.findall(line) if len(content) != 1 or len(matches) != 1 or ("[v" in line and len(RELEASE_LINK_RE.findall(line)) != 1): raise ValueError(f"RELEASE block needs one vX.Y.Z label and a /releases/latest link when linked: {path}") return [RELEASE_VERSION_RE.sub(tag, content[0], count=1)] HANDLERS = { "TRACKER": { "VERSION-MAP": update_version_map, "HISTORY": update_history, "UPSTREAM": update_upstream, "BADGE": update_badge, "LATEST-TESTED": keep_manual, "LATEST-VERIFIED": keep_manual, }, "RELEASE": {"HEADER": update_release, "SUMMARY": update_release, "CURRENT": update_release}, } def dispatch(originals, blocks, subject, handler): updated = {path: lines.copy() for path, lines in originals.items()} for block in sorted((item for item in blocks if item.subject == subject), key=lambda item: (str(item.path), item.start), reverse=True): lines = updated[block.path] lines[block.start + 1:block.end] = handler(block, body(lines, block)) return updated def save_changes(originals, updated, dry_run): changed = [path for path in updated if updated[path] != originals[path]] if not dry_run: for path in changed: path.write_text("".join(updated[path]), encoding="utf-8") return changed def apply_release(root, version, release_date, dry_run=False): target = version_key(version) date.fromisoformat(release_date) originals, blocks = documents(root) tested = latest_tested(originals, blocks) history = [block for block in blocks if (block.subject, block.content) == ("TRACKER", "HISTORY")] if len(history) != 1: raise ValueError(f"Expected one TRACKER:HISTORY block, found {len(history)}") history_lines = body(originals[history[0].path], history[0]) latest = max(map(version_key, table(history_lines, "HISTORY"))) if target < latest or target <= version_key(tested): return [] updated = dispatch(originals, blocks, "TRACKER", lambda block, content: HANDLERS["TRACKER"][block.content](content, version, release_date, tested)) return save_changes(originals, updated, dry_run) def apply_template_release(root, tag, dry_run=False): if not re.fullmatch(r"v\d+\.\d+\.\d+", tag): raise ValueError(f"Expected a stable vX.Y.Z release tag, got {tag!r}") originals, blocks = documents(root) release_blocks = [block for block in blocks if block.subject == "RELEASE"] if not release_blocks: raise ValueError("No RELEASE blocks declared") versions = set() for block in release_blocks: content = body(originals[block.path], block) line = "".join(content) matches = RELEASE_VERSION_RE.findall(line) if len(content) != 1 or len(matches) != 1 or ("[v" in line and len(RELEASE_LINK_RE.findall(line)) != 1): raise ValueError(f"RELEASE block needs one vX.Y.Z label and a /releases/latest link when linked: {block.path}") versions.add(matches[0]) if len(versions) != 1: raise ValueError(f"TRACKER:RELEASE labels disagree: {sorted(versions)}") current = versions.pop() if version_key(tag) <= version_key(current): return [] updated = dispatch(originals, blocks, "RELEASE", lambda block, content: HANDLERS["RELEASE"][block.content](content, tag, block.path)) return save_changes(originals, updated, dry_run) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--version", default=os.environ.get("GITEA_VERSION"), help="Specific stable Gitea version; omit for latest release") parser.add_argument("--release-date", help="YYYY-MM-DD; use with --version for offline checks") parser.add_argument("--template-release", help="Published template tag vX.Y.Z; update RELEASE blocks only") parser.add_argument("--dry-run", action="store_true", help="Check without changing repository documentation") args = parser.parse_args() if args.template_release and (args.version or args.release_date): parser.error("--template-release cannot be combined with upstream release options") if args.release_date and not args.version: parser.error("--release-date requires --version") if args.template_release: version = args.template_release changed = apply_template_release(ROOT, version, dry_run=args.dry_run) label = "Template release" else: if args.version: args.version = args.version.removeprefix("v") version_key(args.version) if args.release_date: version, release_date = args.version, date.fromisoformat(args.release_date).isoformat() else: version, release_date = fetch_release(args.version) changed = apply_release(ROOT, version, release_date, dry_run=args.dry_run) label = "Gitea" print(f"{label} {version}: {', '.join(str(path.relative_to(ROOT)) for path in changed) if changed else 'already tracked'}") output_path = os.environ.get("GITHUB_OUTPUT") if output_path: with open(output_path, "a", encoding="utf-8") as output: output.write(f"version={version}\nchanged={'true' if changed else 'false'}\n") return 0 if __name__ == "__main__": try: sys.exit(main()) except (KeyError, OSError, ValueError) as error: sys.exit(f"Tracker failed: {error}")