Run the catalog gate from main: catalog-gate, under pull_request_target
Claude · 2026-10-28 · f4565a8a30911f545380f51f56f582531a640d64 · parents 6713aef
The gate decides whether a pull request may change a namespace or a name, so it must not be the pull request's own. The catalog workflow keeps the tests (they need the pull request's code); a new catalog-gate workflow runs main's site/gate.py, main's pyrtan and main's lockfile against the pull request's tree, checked out beside main as data and never run. pull_request_target, because under pull_request the workflow file itself is the pull request's and could be edited to run its own gate. Read-only token, no secrets, no credentials left in either checkout, no cache.
site/gate.py takes --root: the checkout to judge, while the code that judges is always the file's own checkout. The test runs this checkout's gate against a repository whose own gate.py lets anything through. The docs that said the catalog workflow runs the gate say catalog-gate, and main's copy.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rD7jgoLgxA3qCSMVWdoDv
Changes
.github/workflows/catalog-gate.yml | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ .github/workflows/catalog.yml | 15 ++++++-------- CONTRIBUTING.md | 5 +++-- README.md | 2 +- _meta/identity.md | 9 +++++---- _meta/platform.md | 6 ++++-- catalog/README.md | 3 ++- site/README.md | 5 +++-- site/gate.py | 26 +++++++++++++++--------- site/names/README.md | 3 ++- src/pyrtan/cli.py | 2 +- tests/test_names.py | 9 +++++---- 12 files changed, 112 insertions(+), 36 deletions(-)
diff --git a/.github/workflows/catalog-gate.yml b/.github/workflows/catalog-gate.yml new file mode 100644 index 0000000..0e152e8 --- /dev/null +++ b/.github/workflows/catalog-gate.yml@@ -0,0 +1,63 @@+# The catalog gate, run from main: every pull request that touches a tree+# or a name is judged by main's site/gate.py, main's pyrtan and main's+# lockfile, against the pull request's tree read as data. Which files+# changed, who opened the pull request and what each file was on main+# come from git and the event: a change must come from a namespace's+# owners and bump its version; a new name is the author's own login with+# the author's numeric GitHub id, and only that account changes it.+#+# pull_request_target, because under pull_request the workflow itself is+# the pull request's and could be edited to run its own gate. Nothing from+# the pull request is ever executed here: it is checked out beside main,+# read by main's code, with a read-only token, no secrets, no credentials+# left in either checkout and no cache. Keep it that way: never add a step+# that runs, installs or imports anything from pr/.+# A maintainer merges when it is green; the pages workflow publishes on merge.++name: catalog-gate++on:+ pull_request_target:+ paths:+ - "catalog/**"+ - "examples/**"+ - "site/gate.py"+ - "src/pyrtan/catalog.py"+ - "site/names/**"+ - "src/pyrtan/names.py"+ - "src/pyrtan/identity.py"++permissions:+ contents: read++jobs:+ gate:+ runs-on: ubuntu-latest+ env:+ BASE_SHA: ${{ github.event.pull_request.base.sha }}+ AUTHOR: ${{ github.event.pull_request.user.login }}+ AUTHOR_ID: ${{ github.event.pull_request.user.id }}+ steps:+ # The judge: main as the pull request found it.+ - uses: actions/checkout@v4+ with:+ ref: ${{ github.event.pull_request.base.sha }}+ path: base+ fetch-depth: 0+ persist-credentials: false+ # What is judged: the pull request's tree, as data.+ - uses: actions/checkout@v4+ with:+ repository: ${{ github.event.pull_request.head.repo.full_name }}+ ref: ${{ github.event.pull_request.head.sha }}+ path: pr+ fetch-depth: 0+ persist-credentials: false+ - name: Give the pull request's checkout main's commit, to diff against+ run: git -C pr fetch --no-tags "$GITHUB_WORKSPACE/base" "$BASE_SHA"+ - uses: astral-sh/setup-uv@v5+ with:+ enable-cache: false+ - run: uv sync --project base --frozen --extra identity+ - run: uv run --project base --no-sync pyrtan check pr/examples pr/catalog --quiet+ - run: uv run --project base --no-sync python base/site/gate.py --root pr --base "$BASE_SHA" --author "$AUTHOR" --author-id "$AUTHOR_ID"diff --git a/.github/workflows/catalog.yml b/.github/workflows/catalog.yml index aacc57b..c1ffc2d 100644 --- a/.github/workflows/catalog.yml +++ b/.github/workflows/catalog.yml@@ -1,10 +1,8 @@-# The catalog gate: every pull request that touches a tree runs the same-# checks `pyrtan publish` ran locally, with what git knows added: which-# files changed, who opened the pull request, and what version each tree-# had on main. The same gate judges names under site/names/: a new name is-# the author's own login with the author's numeric GitHub id, and only that-# account changes it. A maintainer merges when it is green; the pages-# workflow publishes on merge.+# The catalog tests: every pull request that touches a tree, a name or the+# gate runs the test suite and `pyrtan check` on its own code. The gate+# itself, which decides whether the pull request may change what it+# changes, runs from main in the catalog-gate workflow, so a pull request+# cannot change the gate that judges it.name: catalog@@ -23,7 +21,7 @@ permissions:contents: read jobs:- gate:+ tests:runs-on: ubuntu-latest steps: - uses: actions/checkout@v4@@ -33,4 +31,3 @@ jobs:- run: uv sync --extra identity # the identity tests sign and verify - run: uv run pytest -q - run: uv run pyrtan check examples catalog --quiet- - run: uv run python site/gate.py --base "origin/${{ github.base_ref }}" --author "${{ github.event.pull_request.user.login }}" --author-id "${{ github.event.pull_request.user.id }}"diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 69321da..c90117c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md@@ -33,8 +33,9 @@ namespace. Everyone else publishes under `catalog/<namespace>/`:pyrtan publish my-tree.toml --into . --owner <your GitHub login> git checkout -b catalog/<namespace>-my-tree && git add catalog && git commit && git push -u origin HEAD-then open a pull request. The `catalog` workflow runs the gate-(`site/gate.py`): the tree loads clean, has a licence and a namespace,+then open a pull request. The `catalog-gate` workflow runs main's copy+of the gate (`site/gate.py`), so a change to the gate is judged by the+gate it changes only once it is merged: the tree loads clean, has a licence and a namespace,sits in its namespace's directory under its own name, no other tree has its id, only the namespace's `OWNERS` change it, and a changed file bumps its `version`. A test and a recording are welcome but not required for diff --git a/README.md b/README.md index c484e85..c7c970a 100644 --- a/README.md +++ b/README.md@@ -386,7 +386,7 @@ identifier such as `MIT`) and who publishes it in a `namespace` line.<checkout of this repository> --owner <your GitHub login>` puts the file under `catalog/<namespace>/`, writes the namespace's `OWNERS` if it is new, and runs the gate; push the branch and open a pull request. The-`catalog` workflow runs the same gate (every tree loads clean with a+`catalog-gate` workflow runs main's copy of the same gate (every tree loads clean with alicence and a namespace, sits where its namespace and name say, ids are unique, only a namespace's owners change it, a changed file bumps its `version`), a maintainer merges, and the next build serves it. No diff --git a/_meta/identity.md b/_meta/identity.md index 3a797c2..053b94f 100644 --- a/_meta/identity.md +++ b/_meta/identity.md@@ -243,8 +243,8 @@ the identity's document:- the name is the author's **GitHub login**, lowercased. One person, one name, no disputes to judge, and GitHub has already checked it; - `"github": <numeric user id>`. GitHub frees a login when its owner- renames, so the gate (`site/gate.py`, run by the `catalog` workflow as- it is for namespaces) checks the pull request author's *numeric* id+ renames, so the gate (`site/gate.py`, main's copy, run by the+ `catalog-gate` workflow as it is for namespaces) checks the pull request author's *numeric* id(`github.event.pull_request.user.id`) against the file, not the login. A new name must be the author's own login carrying the author's id; a name, or its document, is changed or removed only by the account whose@@ -429,8 +429,9 @@ hub that checks Alice with a shared secret can also forge Alice.- **Second, built: names.** `names.py`: resolving, pins, building `/.well-known/pyrtan/` from a directory of claims, and pyrtan.com's gate; `pyrtan id resolve`, `pins`, `wellknown`, `name --into`;- `site/names/` with its README, `site/gate.py --author-id`, the- `catalog` workflow passing the author's numeric id, and the `pages`+ `site/names/` with its README, `site/gate.py --author-id` and+ `--root`, the `catalog-gate` workflow running main's gate on the pull+ request's tree with the author's numeric id, and the `pages`build serving the names. pyrtan.com hands out names; nothing yet grants by them. - **Third, built: hubs.** `identity.py` signs and verifies requests; diff --git a/_meta/platform.md b/_meta/platform.md index 5edc863..c7bdffa 100644 --- a/_meta/platform.md +++ b/_meta/platform.md@@ -113,8 +113,10 @@ shape whatever the sources look like.`pyrtan` namespace) and `catalog/<namespace>/<name>.toml`, each namespace with an `OWNERS` file of GitHub logins. `pyrtan publish FILE --into CHECKOUT --owner LOGIN` places the file, writes `OWNERS` for a new-namespace, and runs the gate; the `catalog` workflow runs the same gate-on every pull request with what git adds: the changed files, the+namespace, and runs the gate; the `catalog-gate` workflow runs main's+copy of the same gate (`pull_request_target`, the pull request's tree+read as data and never run, so a pull request cannot change the gate+that judges it) on every pull request with what git adds: the changed files, theauthor, the version each tree had on `main`. The gate (`catalog.gate`, `site/gate.py`) refuses a tree that does not load clean, lacks a licence or a namespace, sits under the wrong directory or file name, shares an id diff --git a/catalog/README.md b/catalog/README.md index 82fdda5..ca01039 100644 --- a/catalog/README.md +++ b/catalog/README.md@@ -12,7 +12,8 @@ naming the GitHub logins that may change the namespace:`pyrtan publish my-tree.toml --into <this checkout> --owner <your login>` puts a tree in the right place and runs the gate; then open a pull-request. The `catalog` workflow runs the same gate on the pull request:+request. The `catalog-gate` workflow runs the same gate on the pull request,+main's copy of it, so a pull request cannot change the gate that judges it:every tree loads clean, carries `license` and `namespace`, sits in its namespace's directory under its own name, ids are unique, only owners change a namespace, and a changed file bumps its `version`. A maintainer diff --git a/site/README.md b/site/README.md index 1698c6f..b53df58 100644 --- a/site/README.md +++ b/site/README.md@@ -46,8 +46,9 @@ The catalog is `examples/` (the `pyrtan` namespace) and `catalog/`(everyone else's, one directory per namespace with an `OWNERS` file), served as `/catalog/<namespace>/<name>.toml` beside `index.json`. A tree goes in by pull request: `pyrtan publish my-tree.toml --into <checkout> ---owner <login>` places it and runs the gate, the `catalog` workflow runs-the same gate on the pull request (`site/gate.py`: clean, licensed,+--owner <login>` places it and runs the gate, the `catalog-gate` workflow runs+main's copy of the same gate on the pull request, reading the pull request's+tree as data and running none of it (`site/gate.py`: clean, licensed,namespaced, in the right place, unique id, changed only by an owner, version bumped), and a maintainer merges. The `pages` workflow runs the gate and the tests again before it publishes, so what is at `/catalog/` diff --git a/site/gate.py b/site/gate.py index 8197601..04b8c11 100644 --- a/site/gate.py +++ b/site/gate.py@@ -1,8 +1,9 @@"""The catalog gate, as the workflows run it.- uv run python site/gate.py [--base origin/main] [--author LOGIN] [--author-id ID] [--changed FILE ...]+ uv run python site/gate.py [--root CHECKOUT] [--base origin/main] [--author LOGIN] [--author-id ID] [--changed FILE ...]-Runs :func:`pyrtan.catalog.gate` on this checkout: every tree under+Runs :func:`pyrtan.catalog.gate` on a checkout (``--root``, default this+one): every tree under``examples/`` and ``catalog/`` loads clean with a licence and a namespace, sits where its namespace and name say, ids are unique, each namespace has owners. With ``--base`` it asks git which catalog files the pull request@@ -15,6 +16,11 @@ identity document holds, a name is a GitHub login, one per account. Withauthor's numeric id (``--author-id``), and a name may be changed or removed only by the account whose id it carried on the base branch. Exit 1 with the problems listed, 0 when the gate is open.++The code that judges is always this file's own checkout (its ``src/``);+``--root`` is only what is judged. The ``catalog-gate`` workflow runs+main's copy of this file against a pull request's tree, so a pull request+cannot change the gate that judges it.""" from __future__ import annotations@@ -32,26 +38,28 @@ from pyrtan.catalog import gate # noqa: E402from pyrtan.names import NAMES_DIR, gate as names_gate # noqa: E402-def git(*args: str) -> str | None:+def git(root: Path, *args: str) -> str | None:try:- return subprocess.run(["git", *args], cwd=ROOT, capture_output=True, text=True, check=True).stdout+ return subprocess.run(["git", *args], cwd=root, capture_output=True, text=True, check=True).stdoutexcept (subprocess.CalledProcessError, FileNotFoundError): return None def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])+ 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")ap.add_argument("--base", help="the base ref of the pull request (e.g. origin/main); changes and prior versions come from git") ap.add_argument("--author", help="the pull request's GitHub login; must own any namespace it changes") ap.add_argument("--author-id", type=int, help="the pull request author's numeric GitHub id; must own any name it changes") ap.add_argument("--changed", nargs="*", default=None, help="changed paths, when not taken from git") args = ap.parse_args(argv)+ root = args.root.resolve()changed = args.changed base: dict[str, tuple[int, bytes]] = {} names_base: dict[str, bytes] = {} if args.base: # --no-renames: a rename is a removal and an addition, and both are judged.- out = git("diff", "--name-only", "--no-renames", f"{args.base}...HEAD")+ out = git(root, "diff", "--name-only", "--no-renames", f"{args.base}...HEAD")if out is None: print(f"gate: cannot diff against {args.base}; checking the tree without change information", file=sys.stderr) else:@@ -60,13 +68,13 @@ def main(argv: list[str] | None = None) -> int:if path.startswith(f"{NAMES_DIR}/"): # A name's claim on the base branch says whose it is, for its .id.json too. claim = path[: -len(".id.json")] + ".json" if path.endswith(".id.json") else path- prior = subprocess.run(["git", "show", f"{args.base}:{claim}"], cwd=ROOT, capture_output=True)+ prior = subprocess.run(["git", "show", f"{args.base}:{claim}"], cwd=root, capture_output=True)if prior.returncode == 0: names_base[claim] = prior.stdout continue if not (path.startswith(("catalog/", "examples/")) and path.endswith(".toml")): continue- prior = subprocess.run(["git", "show", f"{args.base}:{path}"], cwd=ROOT, capture_output=True)+ prior = subprocess.run(["git", "show", f"{args.base}:{path}"], cwd=root, capture_output=True)if prior.returncode != 0: continue # new on this branch try:@@ -74,8 +82,8 @@ def main(argv: list[str] | None = None) -> int:except Exception: version = 1 base[path] = (version, prior.stdout)- problems = gate(ROOT, changed=changed, author=args.author, base=base)- problems += names_gate(ROOT, changed=changed, author=args.author, author_id=args.author_id, base=names_base)+ problems = gate(root, changed=changed, author=args.author, base=base)+ problems += names_gate(root, changed=changed, author=args.author, author_id=args.author_id, base=names_base)for p in problems: print(f"gate: {p}") scope = f"{len(changed)} changed file(s)" if changed is not None else "the whole checkout" diff --git a/site/names/README.md b/site/names/README.md index 3b21ed1..c509e8d 100644 --- a/site/names/README.md +++ b/site/names/README.md@@ -28,7 +28,8 @@ another.## What the gate checks-The `catalog` workflow runs `site/gate.py` on the pull request:+The `catalog-gate` workflow runs main's `site/gate.py` on the pull request+(a pull request that edits the gate is still judged by main's):- every claim is for `<its file's name>@pyrtan.com` and signed by the key it names; every identity document is signed by that same key; diff --git a/src/pyrtan/cli.py b/src/pyrtan/cli.py index daec961..34e20c4 100644 --- a/src/pyrtan/cli.py +++ b/src/pyrtan/cli.py@@ -1293,7 +1293,7 @@ def cmd_publish(args: argparse.Namespace) -> int:print(f" git add {rel.parent}") print(f" git commit -m \"catalog: {rel.parent.name}/{rel.stem}\"") print(" git push -u origin HEAD")- print("then open a pull request; the catalog workflow runs the same gate and a maintainer merges.")+ print("then open a pull request; the catalog-gate workflow runs the same gate (main's copy) and a maintainer merges.")return 0 diff --git a/tests/test_names.py b/tests/test_names.py index 3f38ee7..055d257 100644 --- a/tests/test_names.py +++ b/tests/test_names.py@@ -1,5 +1,4 @@import json-import shutilimport subprocess import sys@@ -184,11 +183,13 @@ def test_gate_a_change_or_removal(repo, alice, bob):def test_site_gate_reads_owners_from_git(tmp_path, alice, bob):- """site/gate.py, as the catalog workflow runs it, against a real base branch."""+ """site/gate.py, as the catalog-gate workflow runs it: this checkout's gate, judging+ another checkout (--root) against a real base branch."""repo = tmp_path / "repo" (repo / "site" / "names").mkdir(parents=True) (repo / "catalog").mkdir()- shutil.copy(ROOT / "site" / "gate.py", repo / "site" / "gate.py")+ # The judged checkout's own gate would let anything through; it is never the one run.+ (repo / "site" / "gate.py").write_text("import sys\nsys.exit(0)\n")(repo / "site" / "CNAME").write_text("pyrtan.com\n") put(repo, alice, "alice", 1) git = lambda *a: subprocess.run(["git", "-c", "user.name=t", "-c", "user.email=t@t", *a], cwd=repo, check=True, capture_output=True)@@ -198,7 +199,7 @@ def test_site_gate_reads_owners_from_git(tmp_path, alice, bob):git("checkout", "-q", "-b", "pr") put(repo, bob, "alice", 1) git("commit", "-q", "-am", "take alice")- gate = lambda *a: subprocess.run([sys.executable, "site/gate.py", "--base", "main", *a], cwd=repo, capture_output=True, text=True)+ gate = lambda *a: subprocess.run([sys.executable, str(ROOT / "site" / "gate.py"), "--root", str(repo), "--base", "main", *a], cwd=tmp_path, capture_output=True, text=True)run = gate("--author", "alice", "--author-id", "2") assert run.returncode == 1 and "belongs to GitHub account 1" in run.stdout run = gate("--author", "alice", "--author-id", "1")