From 9edc1ad2aa6724c993942b2db7f330e73d909ee5 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 14 Oct 2026 03:40:08 -0400 Subject: [PATCH] ci: the standards build builds the workspaces a change touches Every pull request built and tested all 17 standards workspaces, whatever it changed, and the one x86_64 runner's queue filled with builds no change could affect (90 jobs waiting when measured). A plan job on linux-arm64 now reads the pull request's own diff and selects the workspaces it touches: 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 workflow, the planning script, the toolchain pin, the cargo configuration). The build matrix is the entries it selects; the macOS energy job runs when an energy-metering workspace is touched. A push to main builds everything, and so does anything the plan cannot determine. Over the last forty merges into main, the WAI-only changes, about half, would have built none of the 17; the rest built between one and ten. A probe in ci-test confirmed this forge builds a matrix from a previous job's output and creates no job for an empty list. A self-test runs in the plan job. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/standards-build.yml | 65 ++++++-- scripts/standards-touched-selftest.sh | 55 +++++++ scripts/standards-touched.py | 213 ++++++++++++++++++++++++++ 3 files changed, 319 insertions(+), 14 deletions(-) create mode 100755 scripts/standards-touched-selftest.sh create mode 100755 scripts/standards-touched.py 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:])) -- 2.54.0 (Apple Git-157)