diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c2b1607..a245aab5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8f15211f..edde0648 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/scripts/ci/affected.py b/scripts/ci/affected.py new file mode 100644 index 00000000..b6d0a082 --- /dev/null +++ b/scripts/ci/affected.py @@ -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() diff --git a/scripts/ci/test_affected.py b/scripts/ci/test_affected.py new file mode 100644 index 00000000..5fae56cd --- /dev/null +++ b/scripts/ci/test_affected.py @@ -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()