feat(ci): refuse fork-authored code before checkout, on every self-hosted job - #23
Merged
Merged
Conversation
…sted job These jobs run BARE on shared University of Melbourne HPC nodes — no container, no overlay, two concurrent jobs sharing one $HOME (measured by scitex-hpc on the CI supervisor allocation, job 28161762, spartan-bm062). So `actions/checkout` + `uv pip install -e .` executes a pull request's own build-backend hooks on university hardware, and rtd-sphinx-build executes the fork's conf.py as plain Python. Operator, 2026-07-30: 「大学の資源を外部の人にも使わせる形になったら一発で アウトなのでそれはだめですね」. WHY REFUSE RATHER THAN RE-ROUTE. Routing fork PRs to ubuntu-latest was the obvious fix and it is forbidden: operator mandate 2026-07-14, enforced as PS-169 — 「github側のランナーというのは本当にもう一切使わないでください… 強制です、例外なしです」. Two mandates, no runner in the intersection, so fork-authored code is refused rather than sent somewhere else. (The durable resolution is a runner we OWN — neither GitHub's nor the university's. That is a hardware conversation, not this patch.) The guard is the FIRST step, ahead of actions/checkout, because checkout is what first puts fork content on the node — a guard after it is a report, not a barrier. It FAILS rather than skipping: a skipped job's check can be reported as successful to branch protection, which is a red that looks green. It names the remedy (`gh pr checkout` + push to a review branch), because an error that only says what broke is half-written. FIVE workflows, not one. The first search reported a single self-hosted `runs-on` and an unfiltered re-run found nine across seven files — eight rows had been dropped silently. auto-merge and cla are excluded because they check out nothing (cla measured 2026-07-30 on scitex-app), so no fork code runs there. tests/test_fork_guard.py DERIVES the covered set instead of listing it: it parses every workflow, selects jobs that are self-hosted AND check out, and requires the guard on exactly those. Add a self-hosted workflow that checks out a PR and these tests go red on that change — a hardcoded list would go stale silently and keep passing. It also asserts locally that no job resolves to a hosted image, so a future "just use ubuntu-latest for forks" cannot land here quietly. Mutation-probed, each restoring the tree afterwards: guard moved after checkout -> test_guard_step_precedes_every_checkout FAILS predicate -> head.repo.fork -> test_guard_predicate_is_the_sanctioned_one FAILS a job -> ubuntu-latest -> selector count + PS-169 arm FAIL (2) Baseline and post-restore: 74 passed. WHAT THIS DOES NOT DO, stated in the README next to the guard so nobody mistakes it for a boundary: for `pull_request`, GitHub runs the workflow definition from the PR's own head, so a hostile fork can edit the CALLER's ci.yml to bypass these reusable workflows and declare its own self-hosted job. This closes the DEFAULT path. The boundary is the fork-PR approval policy — measured `all_external_contributors` on 74 of 74 public scitex-ai repos and as the org default for new repos, 2026-07-30 — i.e. a maintainer's click. The README says plainly what that click means. No repo or org settings are changed by this PR.
ywatanabe1989
added a commit
to scitex-ai/scitex-ui
that referenced
this pull request
Jul 30, 2026
…tself (#120) scitex-ai/.github#23 put a pre-checkout fork guard on the five REUSABLE workflows this repo calls. A reusable workflow cannot guard a job declared LOCALLY, and this repo declares two that are fork-reachable: typecheck.yml on: pull_request [main, develop] self-hosted + checkout docs-sphinx.yml on: pull_request self-hosted + checkout Both were outside that PR's reach entirely. Found by auditing this repo afterwards instead of assuming the shared fix covered it — the mechanism shipped, and it reached five workflows out of seven. `typecheck` is a REQUIRED status check on main and develop, which makes it the most reachable self-hosted job in the repo. `npm ci` there runs the pull request's own lifecycle scripts. docs-sphinx is worse per-run despite gating nothing: it declares `permissions: contents: write`, passes a token to checkout, and sphinx EXECUTES the PR's conf.py as plain Python. These jobs run BARE on shared University of Melbourne HPC nodes — no container, no overlay, two concurrent jobs sharing one $HOME (measured by scitex-hpc, job 28161762, spartan-bm062). operator 2026-07-30 「大学の資源を外部の人にも使わせる形になったら一発で アウトなのでそれはだめですね」 operator 2026-07-14 「github側のランナーというのは本当にもう一切使わないで ください…強制です、例外なしです」 (PS-169) Nothing sits in the intersection of those two, so fork code is REFUSED here rather than re-routed to a hosted runner. The guard is the first step, ahead of checkout, because checkout is what first puts fork content on the node. It FAILS rather than skipping: a skipped job's check can be reported as successful to branch protection, which is a red that looks green. tests/develop/test_fork_guard_own_workflows.py DERIVES the covered set: a job needs the guard when its workflow has a pull_request trigger AND the job resolves to self-hosted AND the job checks out. The release workflow is excluded by the trigger arm (tags only), not by an exemption. The self-hosted test handles BOTH spellings — the label list and the fleet's `fromJSON(vars.CI_RUNS_ON || '[...]')` form — because a membership test alone would silently exempt every job using the second, which is most of the release workflow. That helper has its own unit test rather than being trusted. Mutation-probed, each restoring the tree: guard deleted from typecheck -> 5 failures guard moved after checkout (docs) -> test_guard_step_precedes_every_checkout a NEW unguarded self-hosted PR job -> 5 failures (the drift arm: adding one goes red on the change that adds it) Baseline and post-restore: 26 passed. WHAT THIS DOES NOT DO: for `pull_request`, GitHub runs workflow definitions from the PR's own head, so a fork can delete these guards in its own PR. This closes the default path; it is not a boundary. The boundary is the fork-PR approval policy — measured `all_external_contributors` on 74 of 74 public scitex-ai repos, 2026-07-30 — i.e. a maintainer's click. No repo or org settings are changed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Every self-hosted job here that runs
actions/checkoutnow refuses fork-authored pull requests before checkout. Five workflows:pytest-matrix,import-smoke,quality-audit,rtd-sphinx-build,self-test.No repo or org settings are changed by this PR.
Why
These jobs run bare on shared University of Melbourne HPC nodes — no container, no overlay, two concurrent jobs sharing one
$HOME(measured by scitex-hpc on the CI supervisor allocation, job 28161762, spartan-bm062).uv pip install -e .alone executes a pull request's own build-backend hooks there, andrtd-sphinx-buildexecutes the fork'sconf.pyas plain Python.Operator, 2026-07-30: 「大学の資源を外部の人にも使わせる形になったら一発でアウトなのでそれはだめですね」
Why refuse rather than re-route to
ubuntu-latestThat was the obvious fix and it is forbidden. Operator mandate 2026-07-14, enforced as PS-169: 「github側のランナーというのは本当にもう一切使わないでください…強制です、例外なしです」.
Two mandates, nothing in the intersection — so fork-authored code is refused, not relocated. The durable resolution is a runner we own (neither GitHub's nor the university's); that is a hardware conversation, not this patch.
Shape of the guard
actions/checkout— checkout is what first puts fork content on the node, so a guard after it is a report, not a barrier.gh pr checkout+ push to a review branch) — an error that only says what broke is half-written.Scope correction worth recording
The first search reported one self-hosted
runs-on. An unfiltered re-run found nine, across seven files — eight rows had been dropped with no truncation marker.auto-merge-to-developandclaare excluded because they check out nothing (cla measured 2026-07-30 on scitex-app), so no fork code runs there.Tests
tests/test_fork_guard.pyderives the covered set rather than listing it: it parses every workflow, selects jobs that are self-hosted and check out, and requires the guard on exactly those. Add a self-hosted workflow that checks out a PR and these tests go red on that change — a hardcoded list would go stale silently and keep passing. It also asserts locally that no job resolves to a hosted image, so a future "just use ubuntu-latest for forks" cannot land here quietly.Mutation-probed, each restoring the tree afterwards:
test_guard_step_precedes_every_checkoutfailshead.repo.forktest_guard_predicate_is_the_sanctioned_onefailsubuntu-latestBaseline and post-restore: 74 passed.
What this does NOT do
For
pull_request, GitHub runs the workflow definition from the PR's own head. A hostile fork can therefore edit the caller'sci.ymlto bypass these reusable workflows entirely and declare its own self-hosted job. This closes the default path; it is not a boundary.The boundary is the fork-PR approval policy — measured
all_external_contributorson 74 of 74 publicscitex-airepos and as the org default for new repos (2026-07-30), so no external contributor's workflow runs without a maintainer's click. The README now says plainly what that click means, and gives the safe alternative: adopt the branch into the repo and open a same-repo PR.