Repository navigation
fork: Fork tests on the repository's own fleet runners, chosen per run (CI_RUNNERS) - #13
scuttletoby wants to merge 3 commits into
Conversation
…n (CI_RUNNERS) A GitHub-hosted `route` job picks, for each Linux job of Fork tests, an idle fleet runner or GitHub-hosted Ubuntu 24.04. The repository variable CI_RUNNERS sets the mode: auto (also unset), fleet or github. Only trusted runs may use the fleet: the owner's pushes to main and the owner's own same-repository pull requests. Pull requests from other repositories never do. Each fleet runner's job-started hook re-checks the same rule (`gate`). Each routed job waits for memory on a fleet runner before it starts work. A stateless sweep (fork-fleet.yml, every 10 minutes) cancels and re-runs, on GitHub-hosted runners, the runs that the fleet did not take or did not finish. All facts live in .github/fork-fleet.json. The runbook, owner steps and failure table are in .github/fork-fleet.md. No upstream file changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
lens: security+correctness S-2 · reviewer: opus high (t3lens-13-s2) · read at: f2d6e15
|
|
lens: security+correctness S-1 · reviewer: opus high (t3lens-13-s1) · read at: f2d6e15 |
Conflicts in fork-tests.yml: each routed job keeps #13's needs and runs-on and takes main's pull request guard in its if:. The new `route` job gets the guard too (fork-contracts.test.py: every job a pull request starts begins with it). fork-ci-tests.txt lists both new tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s only; the hook kills a refused job Fix round r1 for the #13 security lenses. - Blocker (s1): no standing self-hosted runner. A root dispatcher on each fleet host (fork-fleet.py dispatch + fork-fleet-launch.sh) starts a one-job JIT runner only for a queued job that asks for exactly a pool's label plus the per-run random label route made for it, of a run whose GitHub record passes the trust rule, when no other queued job could take that runner. The runner runs from a fresh copy of a root-owned template as a user of its own, its environment (JIT config, run, attempt, hook) from a root-only file. The job-started hook (root-owned) is the second line: it kills Runner.Worker, so no later step runs, if: always() ones included. - s2 minor 1: runs-on follows CI_RUNNERS on a re-run; the sweep reads the latest attempt; the watch watches its own attempt. - s2 minor 2: route is a step of changes, not a job. - s2 minor 3: a truncated or garbled reply is never red (IncompleteRead, ValueError and the like are caught). - s1 minors: the sweep applies the dispatcher's trust rule; fleet_watch bounds the wait per run (10 min auto, 2 h fleet) and the sweep cancels a fleet-mode run past 2 h with an error; a test for "a person's cancel stands". - Doc: rewritten runbook (.github/fork-fleet.md) with host setup, owner steps, a real-runner check and the failure table; skip-only shards go hosted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
not a lens: fix round r1 New head: Per finding
Found while testing, and fixed:
Red → green evidenceEach mutation was applied to the real file, the suite was run (red), the file was restored, and the suite was run again (green): Suites at the new head, all green:
shellcheck is not installed here, so it did not run. Not proved here: a real runner. The runbook's real-runner check needs a throwaway private repository under the owner's account, and none was created. It runs on VM106 next to the real install and checks:
|
|
lens: security+correctness S-1 · reviewer: opus high (t3lens-13-s1-r2) · read at: eaf1320
|
|
lens: security+correctness S-2 · reviewer: opus high (t3lens-13-s2-r2) · read at: eaf1320
|
Fork tests can run its Linux jobs on this repository's own machines (the fleet). Each run picks per job, and the repository variable
CI_RUNNERScontrols it:auto(also unset),fleetorgithub. Until a fleet host runs the dispatcher,autobehaves as today: everything is GitHub-hosted, with a note that says why.Runbook, host setup, owner steps, a real-runner check and the full failure table:
.github/fork-fleet.md.Design: no runner waits for work
The first version had a standing self-hosted runner and a job-started hook that refused untrusted jobs. Security lens s1 showed that this hook is a pre-job step: when it fails, later
if: always()/failure()steps still run. A stranger's pull request that edits the workflow to name the fleet label could then run code on the host, and rewrite the runner's.envto remove the hook. The fix removes the cause: an untrusted job never finds a runner at all.Route is now the last step of
changes(no extra job). It calls no API and reads no secret. Inauto, a pool counts only when its dispatcher's heartbeat (a repository variable, at most 180 s old) reports a free slot. For each fleet job it picks two labels: the pool's label, and a label made for this run (<pool label>-<job key>-<16 random hex>). Untrusted runs go GitHub-hosted.The dispatcher (
fork-fleet.py dispatch) is the root of the design. It runs as root on the fleet host, from a root-owned copy ofmain's script and config. It starts a one-job (JIT) runner only when all of these hold:pulls/<n>;The launcher (
fork-fleet-launch.sh) runs that runner from a fresh hard-link copy of a root-owned, unconfigured runner template. It runs as a user of its own, in a transient systemd unit (whole process group killed on stop, 1 h at most). Its environment comes from a root-only file that is deleted once the runner has started: the JIT config, the run and attempt it was made for, and the hook path. A job that names a fleet label by itself, from a changed workflow or a fork's pull request, stays queued with no runner.The hook (
fork-fleet-hook.sh→gate, root-owned) is the second line. If the job is not the run and attempt its runner was made for, or if it breaks the trust rule, the hook kills the job'sRunner.Worker(SIGKILL). The job then fails with no later step run,always()ones included. A gate that crashes refuses the job.Bounded waits.
fleet_watch(a job in each run that sent a job to the fleet) cancels the run with an error naming the job when a fleet job waits 10 minutes (fleetmode: 2 hours). The stateless sweep (every 10 min) re-runs such a run once, GitHub-hosted, inauto/github. Infleetmode it re-runs nothing, and it cancels with an error any trusted run whose fleet job waited 2 hours. A re-run of failed jobs has no watch of its own, so this bounds it. The sweep never touches an untrusted run.Why this design: the hook alone cannot be made safe, because a failed pre-job step does not stop the job. Killing the worker does stop it, but it still depends on the hook's environment. With a standing runner, a job could rewrite that environment for the next job. With one-job runners made only for a trusted run's own labels, untrusted code needs both a runner it can never get and a gate it cannot change. The PC pool needs no standing connection either: its runners start on demand.
Runner source this relies on (actions/runner
main, read 2026-11-12)src/Runner.Worker/JobExtension.cs~303-315: the hook path is read from the worker's process environment, and the hook is a pre-job step with conditionalways(). Container setup is added after it, so the hook runs first.src/Runner.Worker/JobHookProvider.cs:74: the hook's environment starts empty and gets the process environment plusGITHUB_*/RUNNER_*, not the workflow'senv:. SoFORK_FLEET_RUN/FORK_FLEET_ATTEMPTfrom the root-only file can be trusted.src/Runner.Listener/JobDispatcher.cs:1179-1219: when the worker dies, the listener marks the job failed, and no later step runs.src/Runner.Listener/CommandSettings.cs:112-138:ACTIONS_RUNNER_INPUT_JITCONFIGis read from the environment and then deleted, so the JIT config is never in argv.src/Runner.Listener/Program.cs:172-194: the runner loads.envfrom its own directory and lets it override its environment. That is why the launcher refuses a template with.env,.path,.runneror.credentials, and uses each copy for one job only.Also fixed (lens minors)
runs-onalso checksvars.CI_RUNNERS. A re-run follows the setting as it is now (github→ hosted,auto→ hosted for a re-run of failed jobs,fleet→ the run's labels while it is stillfleet). The watch reads its own attempt, and the sweep reads the latest attempt of every run.changes; the route job is gone.http.client.IncompleteRead,ValueErrorand the like) is a warning and never turns a run red, in watch, sweep and dispatch (route calls no API)..envcan no longer turn off the hook (see the design).fleet_watchand the sweep (above).fleetmode, shards that only print a skip run GitHub-hosted.Found while testing, and fixed:
WORK_ROOTmust be 0711 so the runner user can reach its copy;set -uand left the environment file behind;/runis created after a boot.Owner steps (none done here: no settings, variables, secrets, runners or repositories were changed)
Host setup on VM106 is in
.github/fork-fleet.mdas paste-ready root commands:maincommit;/etc/fork-fleet/launch.confand the token;--pool vm.The
pcpool stays off until alaunch-pcssh wrapper exists. Whether the PC joins is an owner decision.Real-runner check before the fleet takes jobs: the steps are in the runbook. They need a throwaway private repository under the owner's account (the owner's call; none was created) and check:
always()step never runs;Failure table: what happens when this runs again, stops half-way, or meets existing/old/empty state?
The full table (about 50 rows) is in the runbook. All state lives in GitHub (runs, jobs, runner registrations, heartbeat variables) and in systemd on the host. The dispatcher keeps only pacing in memory.
CI_RUNNERSmisspelledchangesfails with a clear message: red. Unset or empty meansauto.auto: GitHub-hosted with a note.fleet: queue onvm.generate-jitconfig, beforestartstartpruneremoves the half copy and any environment file, and the job is started again.auto: the sweep re-runs it hosted), and a queued job gets a new runner.auto: hosted after 10 min;fleet: an error at 2 h).auto: hosted.fleet: the watch reports at 2 h.systemd-runfailsprune.Runner.Workeris killed: no step runs,always()included.Runner.Workerabove the hook (a renamed worker)github-actions), so it is left alone: one re-run at most.pruneremoves them on every pass.main/opt/fork-fleet/SOURCEnames the commit.Tests
.github/scripts/fork-fleet.test.pyhas 104 tests, run by theworkflowsjob:systemd-run/systemctl;GateProcessmodels the s1 blocker. A process namedRunner.Workerruns the installed hook, then analways()step that writes a marker. A refused job leaves no marker, in these cases:A mutant gate that only exits 1 does leave the marker.
Mutation check: 15 mutations, each red, then green once restored (details in the fix-round comment). They cover:
runs-on;Locally:
fork-*.yml;node .github/scripts/fork-ci-tests.cjspasses, and so dofork-contracts.test.pyandfork-release-workflow.test.py;fork-tests-drift.cjsmatches the record;bash -npasses on both new scripts;General vs project
fork-fleet.py(route, watch, sweep, dispatcher, gate),fork-fleet-launch.shand the hook. They fit any repository and any pools..github/fork-fleet.json: the repository, actors, branches, pools (labels, slots, heartbeat variables, launch commands), jobs (shards, gates, memory) and the time limits.Follow-up
Plan row 9 is not in this PR: the config path as an input, a
hosted: falseoption, at most one heavy job per run on the PC, a runner pool by draft state, and the SkyRPG fleet requirements as tests. This PR is already about 2,300 changed lines, so row 9 will be a stacked follow-up PR on this branch.Open for the owner
scuttletoby(bots included) always run GitHub-hosted. Allowing them would be a change totrusted.fleetmode runs up to 16 Linux jobs two at a time onvm. A slow full run can come near the 2-hour bound.autois meant as the everyday mode.🤖 Generated with Claude Code