ci: the standards build builds the workspaces a change touches #251

Merged
dcharlot merged 2 commits from ci/build-what-changes into main 2026-10-14 11:10:50 -04:00
3 changed files with 319 additions and 14 deletions

View file

@ -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

View file

@ -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 [email protected] && 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 <what> <expected run list, comma-separated, sorted>
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."

213
scripts/standards-touched.py Executable file
View file

@ -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 <base-sha> <head-sha> [--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:]))