#!/usr/bin/env python3 """Render .github/CODEOWNERS and the ownership tables in docs/dev/codeowners.md from .github/codeowners-roles.yml. The yml is the source of truth. This script expands the role-based yml into (1) the flat path→owners format GitHub expects in `.github/CODEOWNERS`, and (2) the "who owns what" markdown tables spliced between the generated-region markers in `docs/dev/codeowners.md`. Both are derived artifacts; CI re-renders them on every PR (see .github/workflows/codeowners.yml) and auto-commits the result on same-repo PRs, so the source of truth and the human-readable view never drift. Usage: python3 .github/scripts/render-codeowners.py Exits non-zero on: - Missing PyYAML. - Unknown role referenced in `paths` or `default`. - Role with no members (a role must always resolve to at least one owner; otherwise CODEOWNERS would assign nobody and GitHub would silently fall back to "no required reviewer", which defeats the purpose). - Missing generated-region markers in docs/dev/codeowners.md. """ from __future__ import annotations import sys from pathlib import Path try: import yaml except ImportError: sys.exit( "error: PyYAML is required. Install with `pip install pyyaml` " "or `python3 -m pip install pyyaml`." ) REPO_ROOT = Path(__file__).resolve().parents[2] SOURCE = REPO_ROOT / ".github" / "codeowners-roles.yml" OUTPUT = REPO_ROOT / ".github" / "CODEOWNERS" DOCS = REPO_ROOT / "docs" / "dev" / "codeowners.md" # The "who owns what" tables in docs/dev/codeowners.md are spliced between # these markers so the human-readable view never drifts from the source of # truth. Edit codeowners-roles.yml and re-render — never the table by hand. DOCS_BEGIN = "" DOCS_END = "" BANNER = """\ # AUTOGENERATED from .github/codeowners-roles.yml. Do not edit by hand. # # To change role membership or path assignments: # 1. Edit .github/codeowners-roles.yml # 2. Run `python3 .github/scripts/render-codeowners.py` # 3. Commit both files together # # CI fails if this file drifts from its source, and rejects PRs that # edit this file directly without also editing the yml. """ def resolve(role_name: str, roles: dict) -> list[str]: role = roles.get(role_name) if role is None: sys.exit( f"error: unknown role '{role_name}'. " f"Known roles: {sorted(roles.keys())}" ) members = role.get("members") or [] if not members: sys.exit( f"error: role '{role_name}' has no members. " f"A role must resolve to at least one owner." ) return members def owners_for(role_names: list[str], roles: dict) -> list[str]: """Return @-prefixed GitHub handles, deduped, preserving order.""" seen: list[str] = [] for role_name in role_names: for member in resolve(role_name, roles): handle = f"@{member}" if handle not in seen: seen.append(handle) return seen def _oneline(text: str) -> str: """Collapse a folded/multi-line YAML description into one cell of text.""" return " ".join((text or "").split()) def ownership_tables(spec: dict, roles: dict) -> str: """Render the human-readable "who owns what" markdown — a path→owners table (the operative view at PR time, in last-match-wins order with the catch-all first) plus a role→members table. Spliced into the docs between the markers so it is always current with the source of truth.""" out: list[str] = [] out.append("**Path → owners** (GitHub applies *last match wins*; the `*` " "catch-all is listed first and is overridden by the specific " "patterns below it):") out.append("") out.append("| Path | Owners | Role(s) |") out.append("|---|---|---|") if "default" in spec: owners = " ".join(owners_for(spec["default"], roles)) out.append(f"| `*` | {owners} | {', '.join(spec['default'])} |") for pattern, role_names in (spec.get("paths") or {}).items(): owners = " ".join(owners_for(role_names, roles)) out.append(f"| `{pattern}` | {owners} | {', '.join(role_names)} |") out.append("") out.append("**Roles**:") out.append("") out.append("| Role | Members | Description |") out.append("|---|---|---|") for name, role in roles.items(): members = " ".join(f"@{m}" for m in (role.get("members") or [])) out.append(f"| `{name}` | {members} | {_oneline(role.get('description', ''))} |") out.append("") return "\n".join(out) def splice_docs(table_md: str) -> None: """Replace the region between DOCS_BEGIN/DOCS_END in the docs file with the freshly generated tables, leaving surrounding prose untouched.""" if not DOCS.exists(): sys.exit(f"error: docs file not found: {DOCS}") text = DOCS.read_text() if DOCS_BEGIN not in text or DOCS_END not in text: sys.exit( f"error: ownership markers not found in {DOCS.relative_to(REPO_ROOT)}. " f"Add the lines:\n {DOCS_BEGIN}\n {DOCS_END}\n" f"around the generated table region." ) head, rest = text.split(DOCS_BEGIN, 1) _, tail = rest.split(DOCS_END, 1) new = f"{head}{DOCS_BEGIN}\n\n{table_md}\n{DOCS_END}{tail}" DOCS.write_text(new) def main() -> int: if not SOURCE.exists(): sys.exit(f"error: source file not found: {SOURCE}") spec = yaml.safe_load(SOURCE.read_text()) roles = spec.get("roles") or {} if not roles: sys.exit("error: codeowners-roles.yml declares no roles") paths = spec.get("paths") or {} if not paths: sys.exit("error: codeowners-roles.yml declares no paths") lines: list[str] = [BANNER] # Pad the path column for alignment. Width is the longest pattern # plus a small margin. width = max(len(p) for p in paths) + 2 # GitHub CODEOWNERS uses "last match wins" semantics. Emit the # default catch-all `*` FIRST so specific patterns below override # it for the paths they cover. If we emitted `*` last, every file # would resolve to the default owners regardless of more-specific # rules — which would silently nullify any role distinction. if "default" in spec: default_owners = owners_for(spec["default"], roles) lines.append(f"{'*':<{width}} {' '.join(default_owners)}") lines.append("") for pattern, role_names in paths.items(): owners = owners_for(role_names, roles) lines.append(f"{pattern:<{width}} {' '.join(owners)}") lines.append("") # trailing newline so the file ends cleanly rendered = "\n".join(lines) # Regression check: the catch-all `*` line (if any) must precede # every specific-path line. Failure here means the generator is # silently nullifying specific rules. if "default" in spec: non_comment = [ln for ln in rendered.splitlines() if ln and not ln.startswith("#")] first_pattern = non_comment[0].split()[0] if non_comment else None if first_pattern != "*": sys.exit( f"error: generator invariant violated — first emitted pattern is " f"{first_pattern!r}, expected '*'. CODEOWNERS uses last-match-wins; " f"the catch-all must come first." ) OUTPUT.write_text(rendered) print(f"wrote {OUTPUT.relative_to(REPO_ROOT)}") splice_docs(ownership_tables(spec, roles)) print(f"updated {DOCS.relative_to(REPO_ROOT)}") return 0 if __name__ == "__main__": sys.exit(main())