CI: PRs build and test only the packages they touch (#1561)

Clippy, the test archive and the test shards cover the packages a PR changes plus their dependents. Main, nightly and manual runs stay full.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
bherbruckandClaude Sonnet 5.5 authored and GitHub committed 2026-10-15 20:58:26 -04:00
1 parent 4cbf9cf607
commit af771b9ceb
4 files changed
+234 -5

No files matched your search

+43 -5
View File
@@ -1,4 +1,10 @@
# Fast checks always run; native builds run for code/CI changes.
# PRs test what they touch; main and nightly test everything. On a pull request the "Select
# checks" job maps the changed files to workspace packages (scripts/ci/affected.py), adds the
# packages that depend on them, and Clippy, the test archive and the test shards cover only
# those. Changes to the lockfile, root manifest, toolchain/cargo/nextest config, .github/,
# vendor/, tests/, scripts/ or any file no package owns select the whole workspace, as do
# pushes to main, the nightly run and Run workflow.
# Tests are built once and run in 4 shards. A few heavy tests (see .config/nextest.toml) are
# skipped on pull requests and run on pushes to main and nightly.
# Cross-compilation is opt-in through Run workflow; release builds remain in release.yml.
@@ -49,6 +55,8 @@ jobs:
outputs:
native: ${{ steps.paths.outputs.native }}
kernel: ${{ steps.paths.outputs.kernel }}
full: ${{ steps.paths.outputs.full }}
packages: ${{ steps.paths.outputs.packages }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
@@ -62,6 +70,8 @@ jobs:
run: |
native=false
kernel=false
full=true
packages=
if [ "$EVENT_NAME" != pull_request ]; then
native=true
else
@@ -76,9 +86,20 @@ jobs:
*) native=true ;;
esac
done < "$RUNNER_TEMP/ci-paths"
# Pull requests build and test the touched packages and their dependents.
python3 -I scripts/ci/affected.py --paths "$RUNNER_TEMP/ci-paths" > "$RUNNER_TEMP/ci-affected"
cat "$RUNNER_TEMP/ci-affected"
full=$(sed -n 's/^full=//p' "$RUNNER_TEMP/ci-affected")
packages=$(sed -n 's/^packages=//p' "$RUNNER_TEMP/ci-affected")
# Code changed but nothing was selected: be safe and test everything.
if [ "$native" = true ] && [ "$full" != true ] && [ -z "$packages" ]; then full=true; fi
fi
echo "native=$native" >> "$GITHUB_OUTPUT"
echo "kernel=$kernel" >> "$GITHUB_OUTPUT"
{
echo "native=$native"
echo "kernel=$kernel"
echo "full=$full"
echo "packages=$packages"
} >> "$GITHUB_OUTPUT"
checks:
name: Format and repository checks
@@ -102,6 +123,8 @@ jobs:
run: cargo xtask layers
- name: Web app offline use and updates (headless Chrome)
run: cargo xtask web-offline-test
- name: Affected-package selection self-test
run: python3 -I scripts/ci/test_affected.py
clippy:
name: Native Linux Clippy
@@ -117,7 +140,15 @@ jobs:
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: native-clippy
- run: cargo clippy --workspace --all-targets --profile ci --locked -- -D warnings
- name: Clippy
env:
FULL: ${{ needs.changes.outputs.full }}
PACKAGES: ${{ needs.changes.outputs.packages }}
run: |
# Pull requests lint the affected packages (scripts/ci/affected.py); everything else, all.
scope=(--workspace)
if [ "$FULL" != true ]; then scope=(); for p in $PACKAGES; do scope+=(-p "$p"); done; fi
cargo clippy "${scope[@]}" --all-targets --profile ci --locked -- -D warnings
build-tests:
name: Build tests
@@ -144,7 +175,14 @@ jobs:
with:
tool: cargo-nextest
- name: Compile tests into a nextest archive
run: cargo nextest archive --workspace --cargo-profile ci --locked --archive-file tests.tar.zst
env:
FULL: ${{ needs.changes.outputs.full }}
PACKAGES: ${{ needs.changes.outputs.packages }}
run: |
# Pull requests archive only the affected packages (scripts/ci/affected.py).
scope=(--workspace)
if [ "$FULL" != true ]; then scope=(); for p in $PACKAGES; do scope+=(-p "$p"); done; fi
cargo nextest archive "${scope[@]}" --cargo-profile ci --locked --archive-file tests.tar.zst
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: nextest-archive
@@ -183,7 +221,7 @@ jobs:
# Extract into ./target: tests that run a built binary (env!("CARGO_BIN_EXE_*")) have
# the build job's path, which is the same checkout path here.
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
--profile ci --partition "slice:$SHARD/4" "${all[@]}"
--profile ci --partition "slice:$SHARD/4" --no-tests=pass "${all[@]}"
- name: Robustness goldens sample (pull requests that touch the kernel)
# Every 8th seed of tests/robustness/goldens.json, a quarter per shard; pushes to main and
# the nightly run every seed in the step above.
+7
View File
@@ -163,6 +163,13 @@ in the PR. `cargo xtask test` builds the tests with it and then runs every test
`cargo xtask test -- shell`. Tests run beside each other on a busy machine, so a test must not
depend on wall-clock timing.
**PRs test what they touch; main and nightly test everything.** On a pull request, CI maps the
changed files to workspace packages and runs Clippy, the test build and the tests for those and
the packages that depend on them (`scripts/ci/affected.py`, self-tested in the `checks` job). A
change to `Cargo.lock`, the root `Cargo.toml`, `.cargo/`, `.config/`, `.github/`, `vendor/`,
`tests/`, `scripts/` or any file outside a package runs the whole workspace, as does every push
to `main`, the nightly run and Run workflow.
**CI shards the tests.** One job builds them into a cargo-nextest archive and four jobs run a
quarter each, so every test runs in its own process: don't rely on state left by another test.
Pull requests skip a few heavy tests (one to four minutes each), which run on every push to
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""Select the workspace packages a pull request affects.
Usage: affected.py --paths FILE [--metadata FILE] [--github-output FILE]
FILE holds the changed paths relative to the repository root, separated by NUL (git diff -z);
"-" reads standard input. --metadata is the output of `cargo metadata --no-deps
--format-version 1` (run on demand when omitted).
Prints and optionally appends to $GITHUB_OUTPUT:
full=true|false test the whole workspace
packages=a b c the affected packages (empty when full, or when nothing needs building)
A changed file maps to the package whose directory holds it. The packages that depend on those
(directly or not, dev-dependencies included) are added. Anything that can change every package,
or that no package owns, selects the full workspace. Only documentation and packaging files are
ignored.
"""
import argparse
import json
import subprocess
import sys
# Changes here can affect every package.
FULL_PREFIXES = (".cargo/", ".config/", ".github/", "vendor/", "tests/", "scripts/")
FULL_FILES = ("Cargo.toml", "Cargo.lock")
# Changes here affect no code: documentation and packaging.
IGNORED_PREFIXES = ("docs/", "packaging/")
# Root files that a crate embeds (include_str!), so they are not ignorable documentation.
EMBEDDED = {"ATTRIBUTION.md": "solidcraft-ui-egui"}
def load_metadata(path):
if path:
with open(path, encoding="utf-8") as f:
return json.load(f)
out = subprocess.run(
["cargo", "metadata", "--no-deps", "--format-version", "1", "--locked"],
check=True, capture_output=True, text=True,
).stdout
return json.loads(out)
def read_paths(src):
data = sys.stdin.buffer.read() if src == "-" else open(src, "rb").read()
return [p.decode() for p in data.split(b"\0") if p]
def select(meta, paths):
"""Return (full, sorted package names)."""
root = meta["workspace_root"].rstrip("/") + "/"
pkgs = {}
for p in meta["packages"]:
manifest = p["manifest_path"]
assert manifest.startswith(root), manifest
pkgs[p["name"]] = manifest[len(root):].rsplit("/", 1)[0] + "/"
by_dir = {d: n for n, d in pkgs.items()}
# Reverse dependencies inside the workspace, matched by the dependency's path.
dependents = {n: set() for n in pkgs}
for p in meta["packages"]:
for d in p["dependencies"]:
path = d.get("path")
if not path:
continue
rel = path.rstrip("/") + "/"
target = by_dir.get(rel[len(root):]) if rel.startswith(root) else None
if target:
dependents[target].add(p["name"])
hit = set()
for path in paths:
if path in FULL_FILES or path.startswith(FULL_PREFIXES) or path.startswith("rust-toolchain"):
return True, []
owners = [d for d in by_dir if path.startswith(d)]
if owners:
hit.add(by_dir[max(owners, key=len)])
elif path in EMBEDDED:
hit.add(EMBEDDED[path])
elif path.startswith(IGNORED_PREFIXES) or ("/" not in path and path.endswith(".md")):
continue
else:
return True, [] # not owned by any package
todo, seen = list(hit), set(hit)
while todo:
for dep in dependents[todo.pop()]:
if dep not in seen:
seen.add(dep)
todo.append(dep)
return False, sorted(seen)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--paths", required=True)
ap.add_argument("--metadata")
ap.add_argument("--github-output")
a = ap.parse_args()
full, packages = select(load_metadata(a.metadata), read_paths(a.paths))
lines = [f"full={'true' if full else 'false'}", f"packages={' '.join(packages)}"]
print("\n".join(lines))
if a.github_output:
with open(a.github_output, "a", encoding="utf-8") as f:
f.write("\n".join(lines) + "\n")
if __name__ == "__main__":
main()
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Self-test for affected.py. Run: python3 -I scripts/ci/test_affected.py (from the repo root).
Uses a small synthetic workspace, then the real one (`cargo metadata --no-deps`).
"""
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import affected # noqa: E402
R = "/ws"
def pkg(name, dir_, deps=()):
return {
"name": name,
"manifest_path": f"{R}/{dir_}/Cargo.toml",
"dependencies": [{"name": d, "path": f"{R}/{p}"} for d, p in deps] + [{"name": "serde"}],
}
SYNTH = {
"workspace_root": R,
"packages": [
pkg("base", "crates/base"),
pkg("mid", "crates/mid", [("base", "crates/base")]),
pkg("top", "apps/top", [("mid", "crates/mid")]),
pkg("alone", "crates/alone"),
],
}
class Synthetic(unittest.TestCase):
def test_leaf_selects_only_itself(self):
self.assertEqual(affected.select(SYNTH, ["apps/top/src/main.rs"]), (False, ["top"]))
def test_reverse_dependencies_are_added(self):
self.assertEqual(affected.select(SYNTH, ["crates/base/src/lib.rs"]), (False, ["base", "mid", "top"]))
def test_unrelated_package_is_not_selected(self):
full, pk = affected.select(SYNTH, ["crates/mid/Cargo.toml"])
self.assertEqual((full, pk), (False, ["mid", "top"]))
def test_full_triggers(self):
for p in ["Cargo.lock", "Cargo.toml", ".cargo/config.toml", ".config/nextest.toml", "rust-toolchain.toml",
".github/workflows/ci.yml", "vendor/x/lib.rs", "tests/oracle/a.json", "assets/a.png",
"scripts/ci/affected.py", "unknown.txt"]:
self.assertEqual(affected.select(SYNTH, ["crates/alone/a.rs", p]), (True, []), p)
def test_docs_only_selects_nothing(self):
self.assertEqual(affected.select(SYNTH, ["docs/book/a.md", "README.md", "packaging/x.yml"]), (False, []))
class RealWorkspace(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.meta = affected.load_metadata(None)
def test_render_change_selects_render_and_dependents_not_kernel(self):
full, pk = affected.select(self.meta, ["crates/render/src/lib.rs"])
self.assertFalse(full)
self.assertIn("solidcraft-render", pk)
self.assertGreater(len(pk), 1)
self.assertNotIn("solidcraft-kernel", pk)
def test_cargo_lock_selects_full(self):
self.assertEqual(affected.select(self.meta, ["Cargo.lock"]), (True, []))
def test_docs_only_selects_nothing(self):
self.assertEqual(affected.select(self.meta, ["docs/book/src/dev/testing.md", "CONTRIBUTING.md"]), (False, []))
if __name__ == "__main__":
unittest.main()