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:
1 parent
4cbf9cf607
commit
af771b9ceb
4 files changed
+234
-5
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user