… is not a verdict
Owner ruling (2026-11-04): guest tests run on a dedicated, otherwise idle
self-hosted runner; host checks stay on GitHub's runners. The nightly is
frozen, so this adds the on-demand path only and leaves nightly.yml and
guest.yml as they are.
- `guest-rows.yml`: `workflow_dispatch` alone, inputs `rev` and `filter`,
on `[self-hosted, toyos-guest]`. It checks out `rev` without cleaning the
workspace, so `target/` stays warm, and the toolchain comes from the
host's own store (`~/.cache/toyos`), as on a dev host. No secret, no
persisted token.
- `cargo run -- --ci rows <args>`: the harness with those arguments, held
to the same instrument check as the nightly's guest job (QEMU at
build/ci/qemu-version, its firmware, a `/dev/kvm` that opens) and the
same $TMPDIR leak check, without installing a restored sysroot.
- The harness reads the host's busy CPU time (/proc/stat, Mach's
HOST_CPU_LOAD_INFO) against its own and its reaped children's
(getrusage) over the run. Other work averaging more than one host CPU
ends the run `HOST NOT QUIET`, exit 3, whatever the rows said.
- `only_a_dispatch_reaches_a_self_hosted_runner` replaces the test that
forbade a self-hosted runner: a job off GitHub's hosted images is
allowed only in a workflow `workflow_dispatch` alone starts, and a
job-level reusable workflow must be this repository's own.
- Instructions say guest tests run on the guest runner through that
dispatch, aarch64 also on a quiet Mac on demand; the frozen nightly is
recorded as an open item.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem
The owner ruled on 2026-11-04 that ToyOS guest (QEMU) tests run only on a dedicated, otherwise idle x86 Linux host with KVM, registered as a GitHub self-hosted runner. Host checks stay on GitHub-hosted runners. Until now, agents had no shared, serialized place to run guest rows. The harness also could not tell a run on a busy host from a run on a quiet one, so host noise showed up as test reds.
The nightly is frozen by an owner ruling of the same day. This PR therefore adds only the on-demand path and leaves
nightly.ymlandguest.ymluntouched.Change
On-demand guest rows:
.github/workflows/guest-rows.ymlIts only trigger is
workflow_dispatch, and its one input isfilter(optional; empty runs the whole suite). The run tests the head of the dispatched branch.[self-hosted, toyos-guest].run-name: guest-rows <branch> <filter>lets a dispatcher find its own run.if: github.ref != 'refs/heads/main'). A job on main's ref would hold a token of main's cache scope, and main's caches are restored by every other ref.permissions: {}and noactions/cache: the runner's own workspace is the only cache. No secret is read.clean: falsekeepstarget/warm.submodules: truelets a toolchain the store lacks be built fromrust/.persist-credentials: falsekeeps the token out of.git/config.cargo run -- --ci rows "$FILTER".serialartifact. The driver first removes what earlier runs kept, so the artifact holds only this run's logs.The runner takes one job at a time, so this queue replaces the ad-hoc ssh runner, and no separate lock is needed.
cargo run -- --ci rows <args>(build/ci.rs)This runs
cargo test --test toyos-build -- <args>, with each argument split at whitespace in Rust, so the shell neither splits nor globs.It shares
guest()with the nightly's job:build/ci/qemu-version, the firmware QEMU declares, and a/dev/kvmthat opens;$TMPDIRleak check.It differs in two ways:
~/.cache/toyos, which persists across jobs. A revision whose toolchain key is not in the store builds it on the runner first, which is why the timeout is 360 min.target/red-run-serial.Host-not-quiet outcome (
tests/common/hostload.rs,Tally)#1166's CPU budget is a set of admission locks and measures no load, so there was no existing measurement to reuse. The new reading follows the pattern of the suspend detector (
clock.rs)./proc/stat's aggregate line. Idle and iowait are taken as one sum, because iowait alone can decrease (Documentation/filesystems/proc.rst). Steal counts as busy.HOST_CPU_LOAD_INFO, whose 32-bit counters wrap.getrusageof itself plus every reaped child.test result: HOST NOT QUIET.and exits 3, whatever the rows said. The reasoning: a whole CPU that the guest budget admitted guests against was then not theirs for the run.host: other work used … of CPU beside this run, N CPUs on average.Run::exitnow keeps them on every status but green,HOST NOT QUIETincluded.Security guard
only_a_dispatch_reaches_a_self_hosted_runnerreplacesworkflows_run_against_main_on_hosted_runners, which forbade self-hosted runners. It enforces:runs-onis not a single GitHub-hosted image label is allowed only in a workflow whose triggers are exactly[workflow_dispatch]. Anyruns-onit cannot read counts as self-hosted, so a list, a label, an expression or a multi-line value all fall under this rule.permissions: {}and no other permissions block. It uses noactions/cache, and every job's condition is exactlygithub.ref != 'refs/heads/main'. The reader test refuses each widened variant.uses:must be a local./.github/workflows/file.guest-rows.yml.Instrument
build/ci/qemu-versionmoves from 11.1.1 to 11.1.2, the guest runner host's QEMU: the instrument moved. Consequences:guest.yml's frozen container still installs 11.1.1. That is recorded in the freeze issue, whose exit now includes moving the container to 11.1.2.Instructions and records
CLAUDE.md,tests/CLAUDE.md(a new "Where guests run" section),implementer.mdandreviewer.mdnow say that guest tests run on the guest runner through the dispatch, and that aarch64 guests also run on a quiet Mac on demand.HOST NOT QUIETis what decides whether a given Mac run was silent.issues/the-nightly-is-frozen-so-nothing-runs-the-guest-matrix.md, held by the owner, with the exit "the nightly resumes".issues/the-build-runs-host-tools-outside-rust-and-qemu.mdgains a row for the Actions runner agent on the guest host. Its firmware and Debian rows now say what each QEMU version is.The owner must keep this repository setting
Settings → Actions → General → Fork pull request workflows from outside collaborators: "Require approval for all outside collaborators". The reviewer confirmed it is set (
all_external_contributors).The guard covers only this repository's own workflow files. A fork PR can add a workflow with
on: pull_requestandruns-on: [self-hosted, toyos-guest], and GitHub runs the PR's version of its workflows. On the free plan a runner cannot be restricted to selected workflows, so this approval is the only thing standing between such a PR and the host.A branch in this repository is trusted already: pushing one needs write access, which is also what a dispatch needs. Its code can taint later verdicts on the persistent host. The owner accepted that by choosing a persistent runner; main's caches stay out of reach.
Runner host prerequisites
These are also stated in the workflow's header.
/dev/kvmthat the runner's user can open.toyos-guest, running one job at a time (the default for one runner process)..credentialsamong them. That is GitHub's standard self-hosted model.build/ci/qemu-version), withqemu-system-x86_64andqemu-system-aarch64onPATH. A different version reds the instrument step./usr/share/qemu/firmware/*.jsonor QEMU's data dirs.build/firmware.rsfinds them through QEMU's interop spec.stableon the runner user'sPATH.cc,c++,ar), CMake and Python 3. These are needed only when the store lacks a revision's toolchain.$HOME(the store) and a runner workspace kept between jobs.The aarch64 rows run on this runner under TCG; that is expected.
Serial logs carry no machine identifiers
I checked a kept log from a local guest run (
r1/uart-0.log, x86-64 under TCG). It contains no MAC, no IP, no host path or name, no serial number and no model string. The kernel reads no CPU brand string.Under KVM on the runner, the
microcode:andfpu:lines will carry the host CPU's CPUID signature and microcode revision. Those identify a CPU model and stepping, not a machine.Separately,
--ci's instrument line prints/proc/cpuinfo's model name into the job log. That is existingguest()behaviour, and the run logs of a public repository are public. Whether to keep it for the guest host is open (see Risks).Checks and evidence
Evidence: r1 in
pr-1173/r1and r0 inpr-1173/r0. Logs are masked; eachexits.txtlists exit codes and revisions.r1, at
2d29d98dbunless stated:cargo run -- --ci host: exit 1, 108 of 109 steps green. The one red is two build-system tests this change does not touch:buildlock::…a_narrow_guest_does_not_overtake_a_wide_one_waitingandsysroot::…the_primary_s_fork_checkout_is_moved_to_its_pin.build/diff is onlyci.rsandqemu-version, and neither test reads either.isolated-*.log).issues/a-narrow-guest-test-reads-any-queue-holder-as-the-wide-waiter.md.r1-host-ci.log.cargo test --test toyos-checks -- other_work_detector run_exit_status iowait_going_back: green on macOS.iowait_going_backfeeds two/proc/statreadings in which iowait falls by 20 ticks and idle rises by 100, and expects 15 ticks busy out of 95.cargo test -p toyos-build --lib -- ci::tests firmware: green, including the guard and its refusal cases.r1/controls/):iowait_going_backred ("busy 42949672.91s of …, and it is 150ms of 950ms").actions/cache/restorestep added to the workflow: the guard red.a_drives_row_holds_one_process_per_functionpassed, the run exited 3 with 13.29 CPUs of other work, and its serial log was kept (this run's serial logs are kept at …). That is B3's harness half, end to end.toyos-guestdeclared as a self-hosted label, shellcheck unavailable):guest-rows.ymlis clean. The only finding is the pre-existingtoolchain.yml:20concurrency.queue.r0, still valid for what r1 did not change: the detector self-check and controls M1, M2 and M3, which are the
getrusageattribution, the tick unit and the exit order.Not run end to end. The runner is not registered, so the workflow,
permissions: {}with checkout, the artifact upload and the instrument step on a Linux/KVM host have not run. On Linux, the/proc/statpath itself has not run; its arithmetic is now unit-tested on any host.ci.yml's Linuxhostjob runstoyos-checksonce the PR leaves draft, and must be green before landing.First-run verification once the runner exists (orchestrator)
First dispatch. Push a branch at main's head after landing, then
gh workflow run guest-rows.yml --ref <that branch> -f filter='--list'. Expect:permissions: {};QEMU 11.1.2, firmware …, /dev/kvm opensfor x86-64 andanother architecture's machine: emulatedfor aarch64;Note the toolchain time.
Quiet baseline.
-f filter='<one cheap x86-64 row> <one cheap aarch64 row>'. Expecthost: other work used … N CPUswell under 1,test result: ok., and no toolchain rebuild.A wide run (S1). One full x86-64 dispatch, recording the
N CPUs on averageline. Kernel work the run causes is charged to no process: writeback, IRQ time, kswapd. This measures how close that comes to the one-CPU bound on this host. If it comes near, the bound needs revisiting before rows rely on it.Queueing. Dispatch the same request twice. The second waits until the first ends.
Main refused.
gh workflow run guest-rows.yml --ref mainmust show the job skipped.Logs on a red. On a deliberately red row, for example a filter that matches nothing: the run is red, and the
serialartifact holds only that run's logs, or warns that it found none.Optional loaded-host check. With the owner running one CPU-bound process per core on the host, a row ends
HOST NOT QUIET.Risks
getrusagecannot give. The dedicated host is the primary guarantee, and this check is a backstop.HOST NOT QUIET, never a false green.clean: falseleaves untracked files from earlier revisions in place, and a branch's code can taint later runs on the host. Both are inherent to a persistent runner.🤖 Generated with Claude Code