pyrtan / sourcev0.14.0 · main

pyrtan / site / gate.py

4,893 bytes · last changed 2026-10-28 in Run the catalog gate from main: catalog-gate, under pull_request_target

1"""The catalog gate, as the workflows run it.
2
3 uv run python site/gate.py [--root CHECKOUT] [--base origin/main] [--author LOGIN] [--author-id ID] [--changed FILE ...]
4
5Runs :func:`pyrtan.catalog.gate` on a checkout (``--root``, default this
6one): every tree under
7``examples/`` and ``catalog/`` loads clean with a licence and a namespace,
8sits where its namespace and name say, ids are unique, each namespace has
9owners. With ``--base`` it asks git which catalog files the pull request
10changed and what version each had before, so a change must come from an
11owner (``--author``, the pull request's login) and must bump the version.
12
13Then :func:`pyrtan.names.gate` on ``site/names/``: every name claim and
14identity document holds, a name is a GitHub login, one per account. With
15``--base``, a new name must be the author's own login carrying the
16author's numeric id (``--author-id``), and a name may be changed or
17removed only by the account whose id it carried on the base branch.
18Exit 1 with the problems listed, 0 when the gate is open.
19
20The code that judges is always this file's own checkout (its ``src/``);
21``--root`` is only what is judged. The ``catalog-gate`` workflow runs
22main's copy of this file against a pull request's tree, so a pull request
23cannot change the gate that judges it.
24"""
25
26from __future__ import annotations
27
28import argparse
29import subprocess
30import sys
31import tomllib
32from pathlib import Path
33
34ROOT = Path(__file__).resolve().parents[1]
35sys.path.insert(0, str(ROOT / "src"))
36
37from pyrtan.catalog import gate # noqa: E402
38from pyrtan.names import NAMES_DIR, gate as names_gate # noqa: E402
39
40
41def git(root: Path, *args: str) -> str | None:
42 try:
43 return subprocess.run(["git", *args], cwd=root, capture_output=True, text=True, check=True).stdout
44 except (subprocess.CalledProcessError, FileNotFoundError):
45 return None
46
47
48def main(argv: list[str] | None = None) -> int:
49 ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
50 ap.add_argument("--root", type=Path, default=ROOT, help="the checkout to judge (default: this file's own); the gate's code is always this file's checkout")
51 ap.add_argument("--base", help="the base ref of the pull request (e.g. origin/main); changes and prior versions come from git")
52 ap.add_argument("--author", help="the pull request's GitHub login; must own any namespace it changes")
53 ap.add_argument("--author-id", type=int, help="the pull request author's numeric GitHub id; must own any name it changes")
54 ap.add_argument("--changed", nargs="*", default=None, help="changed paths, when not taken from git")
55 args = ap.parse_args(argv)
56 root = args.root.resolve()
57 changed = args.changed
58 base: dict[str, tuple[int, bytes]] = {}
59 names_base: dict[str, bytes] = {}
60 if args.base:
61 # --no-renames: a rename is a removal and an addition, and both are judged.
62 out = git(root, "diff", "--name-only", "--no-renames", f"{args.base}...HEAD")
63 if out is None:
64 print(f"gate: cannot diff against {args.base}; checking the tree without change information", file=sys.stderr)
65 else:
66 changed = [line.strip() for line in out.splitlines() if line.strip()] if changed is None else changed
67 for path in changed:
68 if path.startswith(f"{NAMES_DIR}/"):
69 # A name's claim on the base branch says whose it is, for its .id.json too.
70 claim = path[: -len(".id.json")] + ".json" if path.endswith(".id.json") else path
71 prior = subprocess.run(["git", "show", f"{args.base}:{claim}"], cwd=root, capture_output=True)
72 if prior.returncode == 0:
73 names_base[claim] = prior.stdout
74 continue
75 if not (path.startswith(("catalog/", "examples/")) and path.endswith(".toml")):
76 continue
77 prior = subprocess.run(["git", "show", f"{args.base}:{path}"], cwd=root, capture_output=True)
78 if prior.returncode != 0:
79 continue # new on this branch
80 try:
81 version = int(tomllib.loads(prior.stdout.decode("utf-8")).get("version", 1))
82 except Exception:
83 version = 1
84 base[path] = (version, prior.stdout)
85 problems = gate(root, changed=changed, author=args.author, base=base)
86 problems += names_gate(root, changed=changed, author=args.author, author_id=args.author_id, base=names_base)
87 for p in problems:
88 print(f"gate: {p}")
89 scope = f"{len(changed)} changed file(s)" if changed is not None else "the whole checkout"
90 print(f"gate: {'refused' if problems else 'open'} ({scope}" + (f", author {args.author}" if args.author else "") + ")")
91 return 1 if problems else 0
92
93
94if __name__ == "__main__":
95 sys.exit(main())