pyrtan / sourcev0.19.0 · main

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 a
licence 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, the
author, 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. With
author'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: E402
from 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).stdout
except (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 shutil
import 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")