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 | |
| 5 | Runs :func:`pyrtan.catalog.gate` on a checkout (``--root``, default this |
| 6 | one): every tree under |
| 7 | ``examples/`` and ``catalog/`` loads clean with a licence and a namespace, |
| 8 | sits where its namespace and name say, ids are unique, each namespace has |
| 9 | owners. With ``--base`` it asks git which catalog files the pull request |
| 10 | changed and what version each had before, so a change must come from an |
| 11 | owner (``--author``, the pull request's login) and must bump the version. |
| 12 | |
| 13 | Then :func:`pyrtan.names.gate` on ``site/names/``: every name claim and |
| 14 | identity 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 |
| 16 | author's numeric id (``--author-id``), and a name may be changed or |
| 17 | removed only by the account whose id it carried on the base branch. |
| 18 | Exit 1 with the problems listed, 0 when the gate is open. |
| 19 | |
| 20 | The code that judges is always this file's own checkout (its ``src/``); |
| 21 | ``--root`` is only what is judged. The ``catalog-gate`` workflow runs |
| 22 | main's copy of this file against a pull request's tree, so a pull request |
| 23 | cannot change the gate that judges it. |
| 24 | """ |
| 25 | |
| 26 | from __future__ import annotations |
| 27 | |
| 28 | import argparse |
| 29 | import subprocess |
| 30 | import sys |
| 31 | import tomllib |
| 32 | from pathlib import Path |
| 33 | |
| 34 | ROOT = Path(__file__).resolve().parents[1] |
| 35 | sys.path.insert(0, str(ROOT / "src")) |
| 36 | |
| 37 | from pyrtan.catalog import gate # noqa: E402 |
| 38 | from pyrtan.names import NAMES_DIR, gate as names_gate # noqa: E402 |
| 39 | |
| 40 | |
| 41 | def 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 | |
| 48 | def 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 | |
| 94 | if __name__ == "__main__": |
| 95 | sys.exit(main()) |