Local checks and CI

GitHub Actions is the full build engine. Local verification catches cheap, deterministic failures before a push; CI owns the expensive cross-workspace and packaging proof for ordinary implementation PRs.

Required local tier

Before opening or updating an implementation PR, run:

cargo fmt -- --check
cargo clippy --workspace --all-targets --all-features -- -D warnings

In a multi-worktree checkout with sccache installed, run those commands through scripts/cargo-cached.sh to reuse compiled dependencies without sharing build outputs:

scripts/cargo-cached.sh fmt -- --check
scripts/cargo-cached.sh clippy --workspace --all-targets --all-features -- -D warnings

Both spell --workspace. The root manifest's default-members is the engine — a bare cargo build or cargo clippy skips xtask and the wasm test fixtures — while the CI clippy gate compiles every member, so an unflagged local run is quiet about crates the gate still judges. Both also spell --all-features to match the gate, which lints every feature so that code behind a non-default feature is linted too.

scripts/cargo-cached.sh always uses the current worktree's target/ directory and disables Cargo incremental compilation. It deliberately overrides ambient CARGO_TARGET_DIR, RUSTC_WRAPPER, and CARGO_INCREMENTAL for that command. The cache is compiler-level only; never configure multiple divergent worktrees to share a target directory.

Fix either failure locally. Run cargo fmt to apply formatting, then repeat the check. This tier applies to every branch using the planned implement workflow, including a documentation-only implementation branch. Documentation-only work also builds the mdBook and validates links. For an ad-hoc documentation edit that is not an implementation workflow, the mdBook and link checks are the minimum; current repository guidance or CI path selection may still require the Rust tier.

What CI proves

The checked-in workflows are authoritative for the exact current jobs. The aggregate CI pass check combines applicable gates such as formatting, clippy, docs, marker/feature boundary builds, workspace tests, duplicate-code and unused-dependency checks, wasm packaging, and contract jobs. Path filters can make a job intentionally inapplicable.

The pull-request-only New suppressions job is deliberately outside those filters. It examines additions between the pull request's resolved merge base and head and rejects four forms: line-anchored Rust allow(...) or expect(...) attributes, Rust #[ignore] attributes (either Rust form also when a cfg_attr(...) applies it, nested or among other attributes), new members of the top-level .jscpd.json ignore array, and new members of package.metadata.cargo-machete.ignored. The standing suppression population, removals, exact renames, comments, strings, and unrelated JSON/TOML keys do not fail the diff. Each finding is printed as file:line — token — added line.

The pull-request checkout contains proposed code, so CI does not execute the scanner from that checkout. It materializes scripts/check-suppressions.py from the event's exact base commit into runner-temporary storage and runs that trusted blob against the explicit base and head refs. The only fallback to the head blob is the bootstrap case where the event base does not contain the newly introduced scanner at all; after this gate lands, a later pull request cannot make its suppressions pass by weakening the scanner in the same diff.

Run the same mechanical scan locally with:

python3 scripts/check-suppressions.py

It defaults to the merge base of origin/main and HEAD; --base and --head select explicit refs. The verify.suppress transform runs that exact command, and it is a member of verify.check.

The scan has three terminals, not two (ADR-0193). It exits 0 when a diff adds no suppression at all, and 1 when it adds one that says nothing for itself. In between is the case a lane is actually in when the repository's own policy blesses the write it needs: it states its case on the suppression line, as a trailing comment on the attribute the scanner finds.

#![allow(unused)]
fn main() {
#[allow(clippy::disallowed_methods)] // aether-suppression-request: test-harness skip/strict knob, not cap config
}

A scan whose every finding carries such a marker with a non-empty reason exits 4, and verify.suppress reads that as a pass. One unrequested finding among requested ones holds the whole run at 1, so a request cannot clear its neighbour. A .jscpd.json member can carry no comment, so a JSON suppression is never requestable.

A repository owner can sign off an intentional pull-request suppression only by editing the pull request's main body so it contains exactly one canonical hidden record:

<!-- aether-suppression-signoff:v1 {"base_sha":"<40 lowercase hex>","head_sha":"<40 lowercase hex>","pull_request":123} -->

The scanner verifies the current body and latest body editor through GitHub GraphQL. The latest editor must be the repository owner, and the record must bind the current pull-request number, resolved merge base, and head exactly. An agent-authored initial body is not authorization. A push, base change, or later non-owner body edit invalidates a previous sign-off until the owner edits the current body again. The sign-off changes only the exit status: findings remain in the job log.

main is protected by the Protect main ruleset, which requires a pull request but no review and no status checks. CI pass and Lint title are the checks to wait on; the ruleset does not enforce them. CI pass proves the applicable tree checks; it does not prove lifecycle readiness. No review record exists or is required. Native change requests and unresolved review threads are the independent blockers, and pull-request reviews and comments remain ordinary human prose that never carries a machine JSON/HTML review marker. Landing independently re-reads those facts before clearing draft state.

Do not copy a list from a CI log into a shell and run it. Logs are evidence; commands come from checked-in workflows and repository guidance.

Affected-code test run

For a local reproduction of the pull-request test selection, run:

cargo xtask affected --run

The command computes the same conservative selection as PR CI, prints that selection, and then runs it. An empty selection succeeds without spawning a test command. A narrowed selection runs xtask's affected-selection invariants, then the selected packages' tests with the CI nextest profile, then their doctests with cargo test --doc, because nextest does not run doctests and the gate's verify.test runs them as a second pass. A narrowed selection always includes the packages whose tests scan every crate's source (WORKSPACE_SCANNING_PACKAGES in xtask/src/affected/select.rs, currently aether-kinds). Selections that need runtime artifacts pre-build them with cargo xtask dist; a run_all result pre-builds artifacts, runs the one-shard workspace nextest equivalent, and then runs the workspace doctests.

--run requires cargo-nextest; selections that pre-build artifacts also require the wasm32-unknown-unknown Rust target. The test suite runs with the runtime requirement and isolated in-memory store used by the repository's verification lane, so a missing runtime artifact fails rather than silently skipping a scenario.

This is an additive test-execution shortcut. It does not replace the required full-workspace formatting and clippy tier above, and it does not narrow compile coverage.

Coupling-gap triage loop

cargo xtask affected narrows a PR's CI to the workspace graph's reverse-dependency closure plus hand-injected path rules for couplings the cargo graph cannot express (PATH_RULES_TOML in xtask/src/affected/rules.rs). A path matching nothing already escalates to run_all, so the one gap left is mis-attribution: a changed path that matched some package but has an additional consumer outside the graph (a runtime-read data file, fixture, golden file, or env contract in another crate). Pushes to main keep the unconditional full suite as the backstop, so a mis-attribution gap surfaces as a red full suite on main after the merged PR's narrowed CI was green. That suite runs as three parallel Test (shard N of 3) jobs, each executing a third of the tests, so the red lands on whichever shard drew the failing test and the other two shards are expected to stay green.

When that happens, work the loop:

  1. Discriminate the red's shape first. main also goes red from timeouts — the full builds run long — so the signal is noisy. A deterministic test or compile failure in a package the PR did not select is the coupling signature; a timeout or infra red is not. Treat a recurring timeout red as its own type:flake defect rather than background noise — a streak of them can mask a real coupling red behind it, and "main is just red again" is exactly how a gap survives.
  2. Identify which changed path should have selected which package. The failing test names the consumer.
  3. Add one [[path-rule]] block (globs → mark-changed) to PATH_RULES_TOML, with a comment naming the coupling. The rule PR self-validates: xtask/ is in RUN_ALL_PREFIXES, so it runs the full suite.
  4. Land the rule fix alongside (or before) the breakage fix so the selection gap closes with the incident.

Two future options worth a sentence each, not yet done: the Test job already computes the narrowed set, so persisting it (a run artifact or output line) would let a main red auto-correlate against a recently merged PR's unselected set, turning this triage mechanical. And if the rule count grows past a handful, revisit centralized rules vs. crate-local declaration (e.g. [package.metadata.affected] extra-paths read by xtask) as a deliberate design decision.

The symbol pass

verify.dup runs a second pass beside jscpd (#5185). jscpd finds token clones; the pass finds concept clones — a helper re-derived under a name the workspace already has. It reads the symbols the candidate introduces (each touched file against the same file at the work order's diff base), asks the workspace inventory whether each name already lives somewhere, and adds a couple of argument-aware rules clippy's disallowed-methods cannot express: a Command::new("git") outside the modules that own the git spawn, and a hand-rolled hex nibble loop outside the digest codec.

Nothing it finds refuses. A name that already exists may be a genuinely different responsibility, and only judgment separates that from re-derivation, so the pass writes a dossier to the evidence's review_flags channel and the review seat decides. Test files are exempt — a fixture that spawns git is building a repository to test against. Build the same inventory by hand with cargo xtask symbols build and search it with cargo xtask symbols find.

The namespace pass

A hand-hashed address is closed: the name hashes are private to aether-data, and clippy.toml refuses MailboxId::from_name. A hand-written one is not: an actor's NAMESPACE spelled out again as a &str somewhere else compiles, resolves, and warn-drops the day the two diverge, because the parameter receiving it is a &str.

cargo xtask namespaces

It reads every const NAMESPACE in crates/*/src and reports the string literals repeating one outside the crate that declares it. Inline #[cfg(test)] regions and the files a test module pulls in are out of scope, and so is a crate writing its own namespace. The dependency-direction cases — a crate that cannot see the const because it sits above the declaring crate in the graph — live in xtask/namespace-literals-allow.toml, one entry per (crate, namespace) pair with the reason stated; an entry no literal matches any more is itself reported, so the file does not accumulate.

Local-only: the check is not a CI job, so run it when you touch addressing.

Watching a draft PR

Implementation PRs stay draft while their required facts accumulate. The repository helper can wait for the checked-in CI aggregate:

scripts/wave-status.sh --wait <pr>

Inspect the first deterministic red rather than waiting for every expensive job. After pushing a fix, evaluate the new head SHA; results on the superseded head do not prove the current one.

The agent workflow explains how Plan, approval, owned implementation artifacts, current-head proof, and explicit landing compose without synthetic lifecycle state.

Targeted local checks

Choose the smallest command that crosses the changed boundary:

ChangeUseful local proof
Markdown/navigationmdbook build docs plus relative-link validation
One Rust unitcargo test <name>
One cratecargo test -p <crate> or cargo check -p <crate>
Added-suppression diffpython3 scripts/check-suppressions.py
Formattingcargo fmt -- --check
Lintscargo clippy --workspace --all-targets --all-features -- -D warnings
Doc comments and intra-doc linkscargo doc --workspace --no-deps --document-private-items --all-features --keep-going
Wasm/component boundarythe owning fixture/build command from CI
SubstrateHarness behaviorfocused integration test target
Hub/process boundaryfocused FleetHarness test with required dist artifacts

The rustdoc row carries --document-private-items because rustdoc resolves intra-doc links only on items it documents. Without the flag a link between two private items is never resolved, so moving a private item across a module boundary can dangle every link that named it and no gate notices. The row also carries --all-features because a module behind a non-default feature is otherwise never compiled by cargo doc, so a broken or private link inside it is never resolved either — the feature-gated module has to actually build before rustdoc can look at it. CI's Rustdoc job runs the same command under RUSTDOCFLAGS='-D rustdoc::…', so keep the two identical.

--all-features moves the feature blind spot rather than removing it. With every feature on, an item gated #[cfg(not(feature = "…"))] is the one compiled out, so its docs are the ones that go unchecked — the mirror image of the feature-on modules the flag reaches, over a much smaller surface (fallback stubs). A run without the flag has the blind spot on the other side; no single feature selection has none. #[cfg(test)] modules sit outside the trade entirely: rustdoc does not document them at all, so a doc link inside one stays unchecked under any feature set.

Do not run the full expensive matrix merely to appear thorough. Do not skip a focused boundary test when it is the only proof of the changed contract.

Full local verification

If the issue or user explicitly requests a full local build/test/dist pass, use the current commands from AGENTS.md and workflows. Report the exact commands and results. target/ and generated dist/ can be large; reclaim them when no longer needed or ask before preserving useful artifacts.

Cross-worktree isolation

Keep each divergent worktree on its own Cargo target directory. Never point multiple worktrees at one shared CARGO_TARGET_DIR: incremental metadata can surface a dependency compiled from another branch and produce phantom errors. Use scripts/cargo-cached.sh when available to share compiler results through sccache, not target artifacts.

Tests must also isolate namespace roots, ports, artifact stores, and other host resources. Prefer SubstrateHarness/FleetHarness builders and allocated temp roots over process-global environment mutation.

Hooks are guardrails

Project Codex hooks live in .codex/hooks.json and may require trust review. They can prepare a best-effort worktree hint and block suspicious source/PR-text operations, but a hook subprocess cannot change the parent Codex cwd. Hooks are defense in depth—not CI, not a new test surface, and not permission to bypass the workflow contract.