ci: the standards build builds the workspaces a change touches #251
3 changed files with 319 additions and 14 deletions
65
.github/workflows/standards-build.yml
vendored
65
.github/workflows/standards-build.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
55
scripts/standards-touched-selftest.sh
Executable file
55
scripts/standards-touched-selftest.sh
Executable 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
213
scripts/standards-touched.py
Executable 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:]))
|
||||
Loading…
Reference in a new issue