diff --git a/.github/workflows/standards-build.yml b/.github/workflows/standards-build.yml index 9520139a2..84e75c19a 100644 --- a/.github/workflows/standards-build.yml +++ b/.github/workflows/standards-build.yml @@ -54,22 +54,28 @@ on: # the way they do on GitHub. A build gate that quietly fails to run is worse than # no gate at all: it reports nothing and everyone assumes green. # -# This is the repo-wide floor, so it runs on every push and every PR. The cost is -# CI minutes on doc-only changes. The alternative cost is what this branch spent -# a day fixing — five workspaces silently broken because nothing compiled them. +# This is the repo-wide floor. A push to main builds every workspace. A pull request +# builds the workspaces its own diff touches (the `plan` job, scripts/ +# standards-touched.py): a workspace's own area, the areas of everything it depends on +# by path, any file outside them its sources name, and everything every leg shares (this +# file, the toolchain pin, the cargo configuration). Building all 17 for every pull +# request filled the one x86_64 runner's queue with builds no change could affect. The +# plan reads the pull request's diff, not the push delta, so a merge of main into a +# branch does not rebuild everything, and anything it cannot determine builds all. jobs: - build: - name: ${{ matrix.name }} - # Spread across the fleet's runners rather than pinning every job to `linux`. - # For most of these the runner is a capacity choice, not a correctness one — - # and 17 jobs on a single runner queue behind whatever else is using it. - # `jouleclaw-rs` is the exception: see its matrix entry. - runs-on: ${{ matrix.runner }} - strategy: - fail-fast: false - matrix: - include: + # Which workspaces this change touches. The list of workspaces is here, one + # `- { ... }` line each, read by scripts/standards-touched.py; the build job's + # matrix is the entries it selects. + plan: + name: which workspaces this change touches + runs-on: linux-arm64 + outputs: + include: ${{ steps.plan.outputs.include }} + any: ${{ steps.plan.outputs.any }} + energy: ${{ steps.plan.outputs.energy }} + env: + WORKSPACES: | - { name: jcp-rs, dir: jcp/jcp-rs, runner: linux, conformance: jcp-conformance, feature_tests: "-p jcp-energy --features nvml" } - { name: sandbox-rs, dir: sandbox/sandbox-rs, runner: linux, conformance: sandbox-conformance } - { name: smart-byte-rs, dir: smart-byte/smart-byte-rs, runner: linux } @@ -125,6 +131,33 @@ jobs: - { name: jouleclaw-rs, dir: jouleclaw/jouleclaw-rs, runner: linux-arm64, rustflags: "-C target-feature=+fp16", cache_suffix: "-nodebug", feature_tests: "-p jouleclaw-energy --features rapl,nvml" } steps: - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: The decision fails closed (self-test) + run: bash scripts/standards-touched-selftest.sh + - id: plan + name: Which workspaces this change touches + run: | + set -euo pipefail + python3 scripts/standards-touched.py --github-output \ + "${{ github.event.pull_request.base.sha }}" \ + "${{ github.event.pull_request.head.sha }}" + + build: + name: ${{ matrix.name }} + needs: plan + if: needs.plan.outputs.any == 'true' + # Spread across the fleet's runners rather than pinning every job to `linux`. + # For most of these the runner is a capacity choice, not a correctness one — + # and 17 jobs on a single runner queue behind whatever else is using it. + # `jouleclaw-rs` is the exception: see its matrix entry. + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.plan.outputs.include) }} + steps: + - uses: actions/checkout@v5 # Three workspaces shell out to `z3`, and the runner image has no solver. # @@ -354,6 +387,10 @@ jobs: # to stderr and is captured on success, hence `--nocapture` and `2>&1`. macos-energy-backend: name: macOS energy backends + # Runs when the change touches a workspace whose energy backend it tests (the plan's + # `energy`), and on every push to main. + needs: plan + if: needs.plan.outputs.energy == 'true' runs-on: macos steps: - uses: actions/checkout@v4 diff --git a/scripts/standards-touched-selftest.sh b/scripts/standards-touched-selftest.sh new file mode 100755 index 000000000..039eaf17e --- /dev/null +++ b/scripts/standards-touched-selftest.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# +# standards-touched-selftest.sh — the plan builds what a change can affect, and fails +# toward building. +# +# Builds a scratch repository shaped like this one (a workflow with three workspaces, +# one depending on another by path, one reading a file outside its area) and asserts +# what standards-touched.py decides for each kind of change. +set -uo pipefail +here=$(cd "$(dirname "$0")" && pwd) +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +cd "$tmp" +git init -q && git config user.email t@example.invalid && git config user.name t +mkdir -p .github/workflows scripts a/a-rs/src b/b-rs/src c/c-rs/src shared +cp "$here/standards-touched.py" scripts/ +cat > .github/workflows/standards-build.yml <<'W' +jobs: + plan: + env: + WORKSPACES: | + - { name: a-rs, dir: a/a-rs, runner: linux } + - { name: b-rs, dir: b/b-rs, runner: linux-arm64, z3: true } + - { name: c-rs, dir: c/c-rs, runner: macos, feature_tests: "-p x --features y,z" } +W +printf '[package]\nname="a"\n[dependencies]\nb = { path = "../../b/b-rs" }\n' > a/a-rs/Cargo.toml +printf '[package]\nname="b"\n' > b/b-rs/Cargo.toml +printf '[package]\nname="c"\n' > c/c-rs/Cargo.toml +printf 'const D: &str = include_str!("../../../shared/data.txt");\n' > c/c-rs/src/lib.rs +: > a/a-rs/src/lib.rs; : > b/b-rs/src/lib.rs; echo one > shared/data.txt; echo x > README.md +git add -A && git commit -qm base && base=$(git rev-parse HEAD) + +fails=0 +expect() { # expect + local what=$1 want=$2 got + head=$(git rev-parse HEAD) + got=$(python3 -I scripts/standards-touched.py "$base" "$head" | python3 -c 'import json,sys; print(",".join(sorted(json.load(sys.stdin)["run"])))') + if [ "$got" = "$want" ]; then echo "ok $what: runs [$got]"; else echo "FAIL $what: runs [$got], expected [$want]"; fails=$((fails+1)); fi + git reset -q --hard "$base" +} +echo y >> README.md && git commit -qam readme; expect "a file in no area" "" +echo y >> b/b-rs/src/lib.rs && git commit -qam b; expect "b's area, which a depends on by path" "a-rs,b-rs" +echo y >> a/a-rs/src/lib.rs && git commit -qam a; expect "a's own area" "a-rs" +echo y >> shared/data.txt && git commit -qam shared; expect "a file c's sources include" "c-rs" +echo y >> .github/workflows/standards-build.yml && git commit -qam wf; expect "the workflow itself" "a-rs,b-rs,c-rs" +echo y >> scripts/standards-touched.py && git commit -qam script; expect "the planning script" "a-rs,b-rs,c-rs" +got=$(python3 -I scripts/standards-touched.py "" "" | python3 -c 'import json,sys; print(",".join(sorted(json.load(sys.stdin)["run"])))') +[ "$got" = "a-rs,b-rs,c-rs" ] && echo "ok no pull request (a push): runs everything" || { echo "FAIL a push ran [$got]"; fails=$((fails+1)); } +got=$(python3 -I scripts/standards-touched.py "$base" "0000000000000000000000000000000000000bad" 2>/dev/null | python3 -c 'import json,sys; print(",".join(sorted(json.load(sys.stdin)["run"])))') +[ "$got" = "a-rs,b-rs,c-rs" ] && echo "ok a diff it cannot compute: runs everything" || { echo "FAIL an unknown head ran [$got]"; fails=$((fails+1)); } +out=$(GITHUB_OUTPUT="$tmp/out" python3 -I scripts/standards-touched.py --github-output "" "" >/dev/null; cat "$tmp/out") +printf '%s\n' "$out" | grep -q '"z3":true' && printf '%s\n' "$out" | grep -q '"feature_tests":"-p x --features y,z"' && printf '%s\n' "$out" | grep -q '^energy=false$' \ + && echo "ok the matrix entries keep their booleans and quoted values" || { echo "FAIL the emitted matrix: $out"; fails=$((fails+1)); } +if [ $fails -ne 0 ]; then echo "standards-touched-selftest: $fails failure(s)"; exit 1; fi +echo "standards-touched-selftest: the plan builds what a change can affect, and everything when unsure." diff --git a/scripts/standards-touched.py b/scripts/standards-touched.py new file mode 100755 index 000000000..e40b38ba2 --- /dev/null +++ b/scripts/standards-touched.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""standards-touched.py: which standards workspaces does a pull request touch? + +The standards build tests every Rust workspace of the tree, one matrix leg each, on +three kinds of runner. Building all of them for every pull request filled the queue: +a change to one standard rebuilt and retested all the others, though none of their +inputs had changed. This decides, for each workspace, whether a change can have +changed what its build and tests see, and the workflow builds only those. + +A workspace counts as touched when the change touches any of: + 1. its own area: the first component of its directory (for `jcp/jcp-rs`, all of + `jcp/`), which holds its conformance data, documents and examples too; + 2. the area of every crate it depends on by path, followed through those crates' + own path dependencies; + 3. any file outside those areas that one of its Rust sources names with a relative + path (`include_str!`, `include_bytes!`, `#[path]`, or a string literal of a + path a test reads); + 4. anything every leg shares: the workflow file, this script and the scripts the + workflow runs, the toolchain pin, and the cargo configuration. + + standards-touched.py [--workflow FILE] [--github-output] + +Prints one JSON object: {"run": [names], "skip": [names], "all": bool, "why": {...}}. +With --github-output it also writes, to $GITHUB_OUTPUT, `include` (the matrix entries +to build, as JSON), `any` and `energy` (whether the macOS energy job runs). +With no base or head (a push, not a pull request), or anything it cannot determine, +every workspace runs: a wrong "skip" silently drops a test, a wrong "run" only costs +runner time, so it guesses toward running. +""" +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +WORKFLOW = ".github/workflows/standards-build.yml" + +# Every leg reads these; a change to any of them runs every workspace. +SHARED = [ + WORKFLOW, + "scripts/standards-touched.py", + "rust-toolchain.toml", + "rust-toolchain", + ".cargo/", +] + + +def matrix(workflow: Path) -> list[dict]: + """The `include:` entries of the build job's matrix, read without a YAML library + (the runner image has none guaranteed): each entry is a `- { key: value, ... }` + flow mapping on one line, as the workflow writes them.""" + out = [] + for line in workflow.read_text().splitlines(): + m = re.match(r"\s*-\s*\{(.*)\}\s*$", line) + if not m: + continue + entry = {} + for part in re.findall(r'(\w+)\s*:\s*("(?:[^"\\]|\\.)*"|[^,]+)', m.group(1)): + k, v = part + v = v.strip() + if v.startswith('"') and v.endswith('"'): + v = v[1:-1] + elif v in ("true", "false"): + v = v == "true" + entry[k] = v + if "name" in entry and "dir" in entry: + out.append(entry) + return out + + +def scripts_used(workflow: Path) -> list[str]: + """Paths under scripts/ that the workflow names; each is shared by every leg.""" + return sorted(set(re.findall(r"\b(scripts/[\w./-]+)", workflow.read_text()))) + + +def area(path: str) -> str: + """The first component of a repository path, as a prefix: `jcp/jcp-rs` → `jcp/`.""" + first = path.strip("/").split("/", 1)[0] + return first + "/" + + +def path_deps(crate_dir: Path) -> set[Path]: + """The directories this crate or workspace names with `path = "..."` in any of its + manifests, resolved.""" + found = set() + for manifest in crate_dir.rglob("Cargo.toml"): + if "target" in manifest.relative_to(crate_dir).parts: + continue + for rel in re.findall(r'path\s*=\s*"([^"]+)"', manifest.read_text(errors="replace")): + p = (manifest.parent / rel).resolve() + if p.exists(): + found.add(p if p.is_dir() else p.parent) + return found + + +REL = re.compile(r'"((?:\.\./)+[^"\s]+)"') + + +def outside_reads(crate_dir: Path, inside: list[str]) -> set[str]: + """Repository paths outside `inside` that the crate's Rust sources name by a + relative path climbing out of their own directory.""" + found = set() + for src in crate_dir.rglob("*.rs"): + parts = src.relative_to(crate_dir).parts + if "target" in parts: + continue + for rel in REL.findall(src.read_text(errors="replace")): + for base in (src.parent, crate_dir): + p = (base / rel).resolve() + if not p.exists(): + continue + try: + r = p.relative_to(ROOT).as_posix() + except ValueError: + continue + if not any(r.startswith(a) for a in inside): + found.add(r) + return found + + +def inputs_of(entry: dict) -> tuple[list[str], list[str]]: + """(areas, exact paths) whose change can change what this workspace's leg sees.""" + ws = ROOT / entry["dir"] + areas = {area(entry["dir"])} + seen, todo = set(), [ws] + while todo: + d = todo.pop() + if d in seen: + continue + seen.add(d) + for dep in path_deps(d): + try: + areas.add(area(dep.relative_to(ROOT).as_posix())) + except ValueError: + continue + todo.append(dep) + exact = outside_reads(ws, sorted(areas)) + return sorted(areas), sorted(exact) + + +def changed(base: str, head: str) -> list[str]: + out = subprocess.run( + ["git", "-C", str(ROOT), "diff", "--name-only", f"{base}...{head}"], + capture_output=True, text=True, check=True, + ).stdout + return [l for l in out.splitlines() if l] + + +def decide(entries: list[dict], files: list[str], shared: list[str]) -> dict: + run, skip, why = [], [], {} + hit_shared = [f for f in files if any(f == s or (s.endswith("/") and f.startswith(s)) for s in shared)] + for e in entries: + if hit_shared: + run.append(e["name"]); why[e["name"]] = f"shared: {hit_shared[0]}" + continue + areas, exact = inputs_of(e) + hit = next((f for f in files if any(f.startswith(a) for a in areas) + or any(f == x or f.startswith(x.rstrip("/") + "/") for x in exact)), None) + if hit: + run.append(e["name"]); why[e["name"]] = hit + else: + skip.append(e["name"]) + return {"run": run, "skip": skip, "all": not skip, "why": why} + + +# The workspaces whose energy backends the macOS energy job tests; it runs when any of +# them does. +ENERGY = {"jcp-rs", "proof-rs", "eoc-rs", "jouleclaw-rs", "sandbox-rs", "joule-code-rs"} + + +def main(argv: list[str]) -> int: + workflow = WORKFLOW + argv = [a for a in argv if a != "--github-output"] + if "--workflow" in argv: + i = argv.index("--workflow") + workflow = argv[i + 1] + argv = argv[:i] + argv[i + 2:] + base, head = (argv + ["", ""])[:2] + try: + entries = matrix(ROOT / workflow) + if not entries: + raise ValueError("no matrix entries read") + except Exception as exc: # cannot even list the workspaces: say so and fail + print(f"standards-touched: cannot read the matrix: {exc}", file=sys.stderr) + return 2 + names = [e["name"] for e in entries] + try: + if not base or not head or set(base) == {"0"}: + raise ValueError("not a pull request") + files = changed(base, head) + result = decide(entries, files, SHARED + scripts_used(ROOT / workflow)) + except Exception as exc: # fail open: every workspace runs + result = {"run": names, "skip": [], "all": True, "why": {"*": f"running all: {exc}"}} + print(json.dumps(result, sort_keys=True)) + out = os.environ.get("GITHUB_OUTPUT") + if "--github-output" in sys.argv[1:] and out: + keep = set(result["run"]) + include = [e for e in entries if e["name"] in keep] + with open(out, "a") as fh: + fh.write("include=" + json.dumps(include, separators=(",", ":")) + "\n") + fh.write("any=" + ("true" if include else "false") + "\n") + fh.write("energy=" + ("true" if keep & ENERGY else "false") + "\n") + for name in result["run"]: + print(f" runs: {name} ({result['why'].get(name, result['why'].get('*', ''))})") + for name in result["skip"]: + print(f" skips: {name}") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:]))