#!/usr/bin/env python3 """Render .github/CODEOWNERS from .github/codeowners-roles.yml. The yml is the source of truth — editing CODEOWNERS directly is rejected by CI (see .github/workflows/codeowners.yml). This script expands the role-based yml into the flat path→owners format GitHub expects. 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). """ 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" 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 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)}") return 0 if __name__ == "__main__": sys.exit(main())