omnigraph/.github/scripts/render-codeowners.py
Andrew Altshuler c7365bf8ef
ci(codeowners): un-trap required checks, auto-render, generate owner tables (#142)
The CODEOWNERS required checks blocked every PR — the real root cause was a
name mismatch, compounded by a path filter:

- branch-protection.json required the contexts `CODEOWNERS / drift` and
  `CODEOWNERS / noedit` (the GitHub UI "workflow / job-id" display form), but
  the jobs report check-run names from their `name:` fields — "CODEOWNERS
  matches source" / "CODEOWNERS not hand-edited". The required contexts
  therefore never matched any reported check and sat permanently pending.
- The workflow was also path-filtered to CODEOWNERS files, so it didn't even
  run for most PRs.

Net effect: with both required checks unsatisfiable, every PR could only land
via admin override (e.g. #140).

Fixes:
- A: drop the `paths:` filter so the workflow runs on every PR and both
  required contexts always report.
- name fix: point branch-protection.json at the actual job names verbatim, and
  add a doc note that the contexts must equal the job `name:` values.
- B: the `drift` job now re-renders and, on same-repo PRs, auto-commits the
  regenerated artifacts back to the branch (mirrors the openapi.json job in
  ci.yml); forks / manual runs strict-check instead. Contributors no longer
  run the script by hand.
- D: render-codeowners.py also generates a "who owns what" path->owners +
  roles table spliced into docs/dev/codeowners.md between markers, so the
  human-readable view never drifts. Idempotent; CODEOWNERS output unchanged.
- docs: correct the stale `enforce_admins: true` line (JSON and live are
  false).

NOTE: the branch-protection.json change only takes effect after an admin runs
`./scripts/apply-branch-protection.sh` (deliberate manual step, per
docs/dev/branch-protection.md). Until then `main` still requires the old
mismatched contexts, so this PR itself needs an admin-override merge — the last
one that should be necessary.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:09:47 +03:00

205 lines
7.6 KiB
Python
Executable file

#!/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 = "<!-- BEGIN GENERATED OWNERSHIP — edit codeowners-roles.yml + run render-codeowners.py -->"
DOCS_END = "<!-- END GENERATED OWNERSHIP -->"
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())