Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/agents/implementer.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Keep the branch coherent: change all consumers and declarations affected by an i

## Verify the behavior

Apply root `CLAUDE.md`'s test selection and scheduling policy. Run host checks with `cargo run -- --ci host`; build an affected image with `cargo run -- --build-only`. Use filtered `cargo test` runs only for behavior that requires the guest. Do not run a broad integration suite merely because a PR needs evidence.
Apply root `CLAUDE.md`'s test selection and scheduling policy. Run host checks with `cargo run -- --ci host`; build an affected image with `cargo run -- --build-only`. Dispatch filtered guest rows (`tests/CLAUDE.md`) only for behavior that requires the guest. Do not run a broad integration suite merely because a PR needs evidence.

For relevant metal tests, prepare committed images with `cargo test --test toyos-build -- --metal --metal-readback <dir> <row>`. This writes `<dir>/request.txt` without touching a machine. Send the request to the orchestrator so compatible tests can share boots. Never execute metal runs yourself.

Expand Down
2 changes: 1 addition & 1 deletion .claude/agents/reviewer.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ Hardware oracles come from vendor specifications or independent evidence checked
- New dependencies obey root's source, licensing and self-hosting rules. Another implementation's source is not directly cited as ToyOS's authority. Source, tests and manifests name Linux only as a host or as part of an externally defined interface. No production machinery ships solely to satisfy a test.
- Host tools beyond Rust and QEMU are declared in `issues/the-build-runs-host-tools-outside-rust-and-qemu.md`. No host-only binaries or ToyOS-maintained Python, Perl or shell. Fork changes and every consumed pin land together under `implementer.md`'s fork rules.
- A package has a meaningful `description` and is accounted for by the workspace. Member-level `[profile]` and `[patch]` sections are invalid. Changed features and configuration arms must be covered by `build/clippy.rs`. Issue edits obey `issues/README.md`.
- Preserve CI security: actions are pinned by full commit hash or valid local path; runners are GitHub-hosted. No privileged triggers that execute untrusted code as main. Only main publishes caches other refs restore; nightly release alone receives `contents: write`. Do not bypass required checks, hide failures with `continue-on-error`, or introduce a write-capable cache on a PR.
- Preserve CI security: actions are pinned by full commit hash or valid local path; runners are GitHub-hosted, except the guest runner, which only a `workflow_dispatch`-only workflow may name. No privileged triggers that execute untrusted code as main. Only main publishes caches other refs restore; nightly release alone receives `contents: write`. Do not bypass required checks, hide failures with `continue-on-error`, or introduce a write-capable cache on a PR.
- For workflow, build or harness changes, read `build/CLAUDE.md` and `tests/CLAUDE.md` for their operational contracts, including CI cache provenance and corpus membership.
- An actuator-controlled kernel static is accessed only under its guard or in actuator-only code. A `cfg(not(feature = ...))` arm ships and is not an exemption.
- Changed or removed commands, flags, steps and documents have their instruction references updated in the same change. For deleted documents search both name and path outside `issues/`. Replace contradictory instruction rules rather than appending exceptions.
Expand Down
56 changes: 56 additions & 0 deletions .github/workflows/guest-rows.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: guest-rows
# What a dispatcher finds its own run by.
run-name: guest-rows ${{ inputs.rev }} ${{ inputs.filter }}

# Guest rows on demand: `cargo test --test toyos-build -- <args>` at `rev`, on
# the guest runner (`cargo run -- --ci rows`, build/ci.rs). The runner is a
# dedicated, otherwise idle host that takes one job at a time, so this queue is
# the only one its guests wait in. It keeps its workspace and its toolchain
# store (`~/.cache/toyos`) between jobs; a revision whose toolchain is not in
# the store builds it here first.
#
# The host provides: x86-64 Linux with `/dev/kvm` the runner's user opens; QEMU
# at build/ci/qemu-version for both architectures, with the firmware
# descriptors of its x86-64 and AArch64 UEFI firmware; rustup's stable on
# `PATH`; git, a C and C++ toolchain, CMake and Python 3 for a toolchain build.
#
# `workflow_dispatch` alone, and never callable: a dispatch needs write access
# to the repository, and no pull request reaches this runner's label from this
# repository's workflows (build/ci.rs's `only_a_dispatch_reaches_a_self_hosted_runner`).
# The job reads no secret.
on:
workflow_dispatch:
inputs:
rev:
description: The commit to test, as a full hash
required: true
type: string
filter:
description: The harness's arguments after `--`, split at whitespace; empty runs the whole suite
required: false
type: string

permissions:
contents: read

jobs:
rows:
runs-on: [self-hosted, toyos-guest]
# A wedge guard, wide enough for a toolchain the store does not hold.
timeout-minutes: 360
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
ref: ${{ inputs.rev }}
# `target/` is the warm build; `git clean -ffdx` would remove it.
clean: false
# The fork a toolchain the store lacks is built from; the build
# fetches its nested submodules itself.
submodules: true
# Nothing on this machine outlives the job holding the token.
persist-credentials: false

# One argument, which the driver splits at whitespace.
- env:
FILTER: ${{ inputs.filter }}
run: cargo run -- --ci rows "$FILTER"
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,15 @@ Rust and QEMU are the development prerequisites. Other host tools must be builda

- **Choose the cheapest sufficient test.** First make bugs unrepresentable, then test logic and failure handling on the host, including hardware models. Prefer metal for actual hardware behavior; QEMU is last. Receiving malformed input is a unit or module test, even when the real input arrives from another process. Crossing a component boundary does not itself justify an integration test.
- **Keep tests focused.** Userland is tested as units and modules, with at most a cheap smoke test. Avoid scenarios that install software, launch applications and drive unrelated steps. Kernel integration and hardware tests must name the behavior host tests cannot establish. Each test has a clear claim, controlled inputs and a meaningful failure; automated tests do not depend on the public internet.
- **Declare where integration tests run.** Record supported metal machines and QEMU configurations with the test registration or harness metadata. Before opening a code PR, run the relevant integration tests locally on those targets. The broad integration matrix runs nightly, not for every PR or merge-queue entry. Host checks remain routine. Inspect the actual harness and workflows; these instructions do not themselves change their scheduling.
- **Declare where integration tests run.** Record supported metal machines and QEMU configurations with the test registration or harness metadata. Before opening a code PR, run the relevant integration tests on those targets. The broad integration matrix runs nightly, not for every PR or merge-queue entry. Host checks remain routine. Inspect the actual harness and workflows; these instructions do not themselves change their scheduling.
- **AArch64 waits for a metal bench.** Until an AArch64 metal bench exists, there is no AArch64-only work. A PR runs aarch64 guest tests only when it changes AArch64 code or the shared code beneath it, such as memory management, context switch, the loader, and shared atomics or DMA ordering that only aarch64 exposes; nightly covers the rest. An aarch64-only red that the change did not introduce is a sighting unless it shows a defect in shared code.
- **Amortize boots.** Batch compatible metal tests in a shared boot. Separate boots need a reason, such as a boot-only property, destructive test or incompatible image. Plenty of useful metal tests are welcome; unnecessary reboots are not.
- **Use real hardware for timing and audio.** QEMU establishes order, completion, contents and counts; its clock is only a hang ceiling. Benchmarks and exploratory comparisons are evidence, not automatically regression tests. A timing test has a justified bound for its declared machine.
- **Run what can change.** Use `cargo run -- --ci host` for host checks, `cargo run -- --build-only` for an image, and filtered `cargo test` invocations for relevant guest behavior. Repeat checks only when a change, failure or unresolved concern could invalidate their result. A new commit, comment deletion or rename alone does not require another integration run; compile where necessary.
- **Run what can change.** Use `cargo run -- --ci host` for host checks, `cargo run -- --build-only` for an image, and filtered guest rows for relevant guest behavior. Repeat checks only when a change, failure or unresolved concern could invalidate their result. A new commit, comment deletion or rename alone does not require another integration run; compile where necessary.
- **Investigate reds.** Do not rerun a failure merely to get green. Fix a defect introduced by the change. A real unrelated defect may be recorded without blocking the PR once its independence is established. Repair or delete a defective or redundant test according to its value, preserving evidence of any real bug it exposed. A guest red that appears only under host noise is not investigated or fixed; it may be recorded as a sighting.
- **Show evidence.** Record commands, exit codes, logs, revisions and machines. Identify earlier evidence reused and why later edits do not invalidate it. Use an independent oracle where available and a negative control where a defect would otherwise land unseen; the whole change reverted onto the measured base is the control for the whole change. Another agent is not an independent oracle. Do not add a test for what a type or straightforward inspection already establishes.

Guest tests run only on a quiet host; agents may run their own selected ones. Metal runs are coordinated and executed by the orchestrator; implementers prepare them with `--metal-readback`, which touches no machine.
Guest tests run on the guest runner through `guest-rows.yml`'s dispatch (`tests/CLAUDE.md`), and agents may dispatch their own selected rows; aarch64 guests also run on a quiet Mac on demand. Metal runs are coordinated and executed by the orchestrator; implementers prepare them with `--metal-readback`, which touches no machine.

## Working safely

Expand Down
96 changes: 82 additions & 14 deletions build/ci.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ const USAGE: &str = "cargo run -- --ci <job>, where <job> is one of:
image the shipped image of every architecture, on the sysroot its
job restored (ci.yml)
guest the guest suite, on the sysroot its job restored (nightly)
rows [<args>...] `cargo test --test toyos-build -- <args>...`, each split at
whitespace, on this host's own toolchain store, held to the
instrument as `guest` is (guest-rows.yml)
release put the sysroot main's nightly restored up as its toolchain release
publish put main's SDK crates on crates.io (publish.yml)";

Expand All @@ -64,6 +67,8 @@ enum Job {
Bootstrap,
Image,
Guest,
/// The harness's arguments.
Rows(Vec<String>),
Release,
Publish,
}
Expand Down Expand Up @@ -96,6 +101,10 @@ fn parse(words: &[String]) -> Result<Job, String> {
};
return Ok(Job::Host(Some(part)));
}
// Split again at whitespace: a workflow hands the arguments over as one.
if words.first().map(String::as_str) == Some("rows") {
return Ok(Job::Rows(words[1..].iter().flat_map(|w| w.split_whitespace()).map(String::from).collect()));
}
let job = match words.first().map(String::as_str) {
Some("host") => Job::Host(None),
Some("seal") => Job::Seal,
Expand Down Expand Up @@ -130,7 +139,8 @@ pub fn dispatch(root: &Path, args: &[String]) {
Job::Toolchain => vec![step("the stores of this tree's toolchain", || release::toolchain(root))],
Job::Bootstrap => vec![step("this tree's toolchain", || release::bootstrap(root))],
Job::Image => image(root),
Job::Guest => guest(root, &suite_args(&["--jobs", "1"])),
Job::Guest => guest(root, &suite_args(&["--jobs", "1"]), true),
Job::Rows(args) => guest(root, &suite_args(&args.iter().map(String::as_str).collect::<Vec<_>>()), false),
Job::Release => vec![step("main's toolchain release", || release::release(root))],
Job::Publish => vec![step("the SDK crates on crates.io", || publish(root))],
};
Expand Down Expand Up @@ -1307,7 +1317,10 @@ fn suite_args(args: &[&str]) -> Vec<String> {
/// A guest job's own `$TMPDIR`, same rule as [`host`]: nothing the suite
/// writes past a `toyos_tmpdir::TempDir` — the harness's own `Run`, its lanes,
/// every boot image — survives past the last step, which reds on it.
fn guest(root: &Path, suite: &[String]) -> Vec<Step> {
///
/// `install`: the sysroot the job restored ([`release::install`]). Without it
/// the suite builds from the host's own store, as a dev host does.
fn guest(root: &Path, suite: &[String], install: bool) -> Vec<Step> {
let tmp = toyos_tmpdir::TempDir::new("ci-guest");
let short = Path::new(toyos_tmpdir::SHORT_BASE);
let before = toyos_tmpdir::gone_roots(short);
Expand All @@ -1319,7 +1332,7 @@ fn guest(root: &Path, suite: &[String]) -> Vec<Step> {
.iter()
.map(|&arch| step(&format!("the {} instrument", arch.name()), || instrument(root, arch)))
.collect();
if steps.iter().all(|s| s.verdict.is_ok()) {
if install && steps.iter().all(|s| s.verdict.is_ok()) {
steps.push(step("the toolchain", || release::install(root)));
}
if steps.iter().all(|s| s.verdict.is_ok()) {
Expand Down Expand Up @@ -1393,8 +1406,8 @@ fn instrument(root: &Path, arch: Arch) -> Result<String, String> {
if have != want {
return Err(format!(
"{line}: this runs QEMU {have} and build/ci/qemu-version declares {want}. The \
container image's digest is what pins it, so moving it is a commit that says the \
instrument moved"
host's QEMU is held to the declaration, and moving the declaration is a commit \
that says the instrument moved"
));
}
if native && node && !arch.accel().is_hardware() {
Expand Down Expand Up @@ -1804,6 +1817,10 @@ mod tests {
assert_eq!(parse(&words("scope")), Ok(Job::Scope));
assert_eq!(parse(&words("image")), Ok(Job::Image));
assert_eq!(parse(&words("guest")), Ok(Job::Guest));
assert_eq!(parse(&words("rows")), Ok(Job::Rows(vec![])));
assert_eq!(parse(&words("rows virtsmp --jobs 1")), Ok(Job::Rows(words("virtsmp --jobs 1"))));
let one = vec!["rows".to_string(), " virtsmp --jobs 1 ".to_string()];
assert_eq!(parse(&one), Ok(Job::Rows(words("virtsmp --jobs 1"))));
assert_eq!(parse(&words("release")), Ok(Job::Release));
assert!(parse(&words("guest 3/12")).is_err());
assert!(parse(&words("tcg")).is_err());
Expand Down Expand Up @@ -1937,13 +1954,44 @@ mod tests {
assert!(text.contains("\n host:"), "ci.yml runs no job `host`");
}

/// Every workflow's `pull_request:` trigger names `main` alone, and none
/// asks for a runner this project does not have — a self-hosted label
/// queues until it times out, silently.
/// The events a workflow's `on:` names, in its block form or either inline
/// form; a shape this does not read is refused.
fn triggers(name: &str, text: &str) -> Vec<String> {
let line = text.lines().find(|l| l.starts_with("on:")).unwrap_or_else(|| panic!("{name}: no `on:`"));
let inline = line["on:".len()..].trim();
if !inline.is_empty() {
return inline.trim_matches(['[', ']']).split(',').map(|t| t.trim().to_string()).collect();
}
let block = text.split_once("\non:\n").expect("`on:` is a line of its own").1;
block
.lines()
.take_while(|l| l.is_empty() || l.starts_with(' ') || l.starts_with('#'))
.filter_map(|l| l.strip_prefix(" "))
.filter(|l| !l.starts_with([' ', '#']) && !l.is_empty())
.map(|l| l.split(':').next().unwrap_or("").trim().to_string())
.collect()
}

/// A `runs-on:` value naming one of GitHub's hosted images. Anything else —
/// a self-hosted label, a list, an expression, a value on later lines — is
/// treated as a self-hosted runner.
fn hosted(runner: &str) -> bool {
let runner = runner.trim();
["ubuntu-", "macos-", "windows-"].iter().any(|p| runner.starts_with(p))
&& runner.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.'))
}

/// Every workflow's `pull_request:` trigger names `main` alone, and a job
/// off GitHub's hosted runners is only in a workflow `workflow_dispatch`
/// alone starts: a dispatch needs write access to the repository, and no
/// pull request, its target, a push, a schedule or another workflow's call
/// reaches the self-hosted runner from this repository's own workflows,
/// directly or through a reusable workflow.
#[test]
fn workflows_run_against_main_on_hosted_runners() {
fn only_a_dispatch_reaches_a_self_hosted_runner() {
let dir = repo_root().join(".github/workflows");
let mut seen = 0;
let mut self_hosted = Vec::new();
for entry in std::fs::read_dir(&dir).expect(".github/workflows is readable").flatten() {
let text = std::fs::read_to_string(entry.path()).expect("a readable workflow");
let name = entry.file_name().to_string_lossy().into_owned();
Expand All @@ -1952,14 +2000,34 @@ mod tests {
let branches = after.lines().next().unwrap_or("").trim();
assert_eq!(branches, "branches: [main]", "{name}");
}
let on = triggers(&name, &text);
assert!(!on.is_empty(), "{name}: no trigger read");
// A reusable workflow runs its jobs in this repository's context:
// one from elsewhere could name the self-hosted label.
for called in text.lines().filter_map(|l| l.strip_prefix(" uses:")) {
assert!(called.trim().starts_with("./.github/workflows/"), "{name}: a job uses{called}");
}
for runner in text.lines().filter_map(|l| l.trim_start().strip_prefix("runs-on:")) {
assert!(
!runner.contains("self-hosted") && !runner.contains("toyos"),
"{name}: runs-on:{runner}"
);
if !hosted(runner) {
assert_eq!(on, ["workflow_dispatch"], "{name}: runs-on:{runner} is started by {on:?}");
self_hosted.push(name.clone());
}
}
}
assert_eq!(seen, 5, "ci.yml, nightly.yml and publish.yml, and guest.yml and toolchain.yml");
assert_eq!(self_hosted, ["guest-rows.yml"], "the workflows that reach a self-hosted runner");
assert_eq!(seen, 6, "ci.yml, nightly.yml, publish.yml and guest-rows.yml, and guest.yml and toolchain.yml");
}

#[test]
fn the_trigger_and_runner_readers_read_what_they_refuse() {
let block = "name: x\non:\n # why\n pull_request:\n branches: [main]\n merge_group:\n\njobs:\n";
assert_eq!(triggers("x", block), ["pull_request", "merge_group"]);
assert_eq!(triggers("x", "on: [push, pull_request_target]\n"), ["push", "pull_request_target"]);
assert_eq!(triggers("x", "on: workflow_dispatch\n"), ["workflow_dispatch"]);
assert!(hosted(" ubuntu-24.04") && hosted(" macos-latest"));
for runner in [" [self-hosted, toyos-guest]", " toyos-guest", " ${{ inputs.runner }}", "", " ubuntu-24.04 # x"] {
assert!(!hosted(runner), "{runner:?}");
}
}

#[test]
Expand Down
Loading