name: standards workspaces build + test # Build gate for every Rust workspace in the repo. # # It started as three. Then a toolchain bump compiled the rest for the first # time and found FIVE more that did not build — smart-byte (47k LOC), openpay # (six separate breakages across five crates), eoc, mesh and joule-ui — all # broken by the same handful of upstream migrations (hmac -> KeyInit, reqwest # feature-gating, cipher 0.4 -> 0.5, a struct gaining fields), and all invisible # because nothing ever compiled them. Partial coverage is how that happens, so # the matrix now covers everything. # # The original case, still the clearest: smart-byte-rs had never been compiled by # anything, and it did not build. A single wrong `rand` pin — 0.10 against code # written for 0.8, whose crypto stack requires rand_core 0.6 — silently disabled # the entire identity pillar, BBS+ included. `cargo check` would have caught it # on day one. An unbuilt standard is not a standard, so this workflow is the # floor under every claim the specs make. # # One workflow with a matrix rather than sixteen files: the bootstrap step is # identical everywhere, and a shared dependency change should rebuild all of # them anyway. `fail-fast: false` so one red workspace does not mask the others. # One run per branch at a time. A push supersedes the run before it. # # There is one `macos` runner and it is the dev Mac, so every macOS job in this # repo queues on it. With no concurrency group, a session pushing four versions # of a branch queued four complete sets of runs and every one of them had to # drain: on 2026-10-05 a PR's 21 contexts settled none in 70 minutes behind a # backlog of 25 runs, and the queue grew faster than it cleared. Only the newest # commit's verdict is wanted, so the older runs are cancelled. # # Verified on this forge (Forgejo 16.0.5) in `ci-test` before being added here: # the key parses, and a newer push moves an older run to `cancelled` even while # it is still queued, which is the case that matters. # # `cancel-in-progress: true` also applies to `main`. That is deliberate and # safe here: main advances by merge commits whose tree contains the commits # before them, so the newer run covers the older one's changes. concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true on: push: branches: [main] pull_request: # Deliberately NO `paths:` filter. # # The first attempt used `**/*.rs` and `**/Cargo.toml`, and the workflow then # silently did not fire — pushes touching exactly those files triggered other # workflows but not this one, so `**`-prefixed globs evidently do not match here # 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. 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: - { 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 } - { name: openpay, dir: openpay, runner: linux-arm64 } - { name: joule-code-rs, dir: joule-code/joule-code-rs, runner: linux-arm64, z3: true } - { name: map-rs, dir: map/map-rs, runner: linux-arm64, conformance: map-conformance } - { name: aac-rs, dir: aac/aac-rs, runner: linux-arm64, conformance: aac-conformance } - { name: joulecontract-rs, dir: joulecontract/joulecontract-rs, runner: linux-arm64, z3: true } # Runner placement is by what a workspace actually needs, not by # habit. The dev Mac hosts BOTH the `macos` runner and the # `linux-arm64` colima VM, so it served 13 of this repo's 21 # contexts while jetson-hub (`linux`) sat idle — which is why a PR's # macOS contexts could wait indefinitely while its `linux` ones # returned in minutes. # # `arl-rs`, `sense-rs`, `joulehook-rs` and `joule-ui-rs` moved to # `linux` on that basis: each has zero `target_os = "macos"` cfgs, # zero Apple dependencies, and zero Apple crates in its Cargo.lock, # so running them on macOS asserted nothing the repo relies on. # `jouledesk-rs` stays, because it does need it — `libwebrtc` and 19 # Apple crates in its lockfile. `linux-arm64` placements stay too: # arm64 is the deployment target, and `jouleclaw-rs` needs # `+fp16`. - { name: jouledesk-rs, dir: jouledesk/jouledesk-rs, runner: macos } - { name: arl-rs, dir: arl/arl-rs, runner: linux } - { name: sense-rs, dir: sense/sense-rs, runner: linux } - { name: proof-rs, dir: proof/proof-rs, runner: linux, z3: true, conformance: proof-conformance } - { name: joulehook-rs, dir: joulehook/joulehook-rs, runner: linux } - { name: eoc-rs, dir: eoc/eoc-rs, runner: linux, feature_tests: "-p eoc-meter --features cuda" } - { name: mesh-rs, dir: mesh/mesh-rs, runner: linux-arm64 } - { name: joule-ui-rs, dir: joule-ui/joule-ui-rs, runner: linux } # jouleclaw-rs is the one entry whose runner IS a correctness choice. # It reaches candle → ug → gemm, which uses inline aarch64 assembly # (`fmla {0:v}.8h, ...`) requiring the ARMv8.2 FP16 extension. # `aarch64-apple-darwin` carries target_feature="fp16" in its baseline # because Apple Silicon guarantees it; `aarch64-unknown-linux-gnu` # baselines at ARMv8.0-A and does not, so the same asm fails to # ASSEMBLE — a codegen error `cargo check` never reaches. Verified on a # native arm64 Linux container (rustc 1.98.0): without the flag, 12 # errors; with it, 4457 tests pass. # # CAPACITY: this is by far the largest entry — its target tree is # ~16 GB, against a 120 GB volume on tx-dev-linux-arm64 that already # holds ~74 GB of other workspaces' caches. If linux-arm64 jobs start # failing for no apparent reason, check `df -h /mnt/lima-colima-txarm` # on the txarm VM before suspecting the code: a full cache volume # fails whatever happens to be building, not whatever changed. - { 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 # Three workspaces shell out to `z3`, and the runner image has no solver. # # This step used to be load-bearing for correctness: without a solver the # ladder escalated past its real rungs into a model STAND-IN that reported # Proved, so every goal on a solverless machine came back proved. That is # now fixed at the source — the real ladders hold decision procedures only # and an absent rung yields Unproved (proof-solvers `ladder_is_sound` and # `ladder_never_fabricates_a_proof_when_a_rung_is_absent`). # # So the suites pass with or without z3, and this step is kept purely for # COVERAGE: with a solver present the real T1 rung actually runs, and the # SMT paths are exercised rather than skipped. The absent-rung semantics # stay covered either way — that regression test uses a goal z3 cannot # parse, so it asserts the same thing on a machine that has one. # # joule-code-remediate's formal gate is the third. Its tests assert in both # branches, so they pass without a solver, and JOULE_FIX_EXPECT_Z3=1 below # makes a missing z3 fail them where this step installed one. Otherwise CI # would only ever run the no-solver branch. # # The z3 jobs are pinned to Linux runners deliberately: the macOS runner is # a host executor with no apt. `sudo` may or may not exist depending on # whether the container runs as root, so fall back to a bare invocation. - name: Install z3 if: matrix.z3 shell: bash run: | SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo" $SUDO apt-get update -qq $SUDO apt-get install -y -qq z3 z3 --version # The docker runner image ships no Rust at all, so rustup is bootstrapped # here. dtolnay/rust-toolchain cannot be used: it shells out to `rustup`, # which the image lacks. Harmless where cargo already exists. - name: Install Rust (workspace pin) shell: bash run: | export PATH="$HOME/.cargo/bin:$PATH" command -v rustup >/dev/null 2>&1 || \ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" # jouleclaw-rs is the CAPACITY entry above. Linux embeds full DWARF in every # test binary, so with default debuginfo its aarch64 test target outgrows # the ~15 GB left on the shared txarm volume and the link fails with # "No space left on device" (reproduced with the same toolchain, flags and # image). Without debuginfo the same target is ~6 GB and the whole suite # passes. Panic messages keep their file:line: that comes from the panic # location, not from DWARF. # # Set through $GITHUB_ENV rather than a matrix field on purpose: a matrix # field would hand every other job CARGO_PROFILE_DEV_DEBUG="", which cargo # can reject. `cache_suffix` gives this job a fresh cache namespace so it # cannot restore a full-debuginfo target and hold both builds at once; # for every other job the suffix is empty and its key string is unchanged. - name: Trim debuginfo (arm64 jouleclaw) if: matrix.name == 'jouleclaw-rs' shell: bash run: echo "CARGO_PROFILE_DEV_DEBUG=0" >> "$GITHUB_ENV" # aac-rs builds its page sets in its tests (tools/pagesets: build.py, # phrases/build.py) and checks them against the committed catalogue # (catalog.py --check, below). Those need Python 3.11+ with PyYAML; the # runner image has Python 3.10 and no PyYAML, and with neither the # tests would only report that they could not build anything. uv puts # a Python 3.12 and PyYAML in a venv of the job's own, on Linux or # macOS; the tests use it through AAC_PYTHON and fail, not skip, when # CI is set and there is none. Verified in catthehacker/ubuntu:act-22.04 # on arm64: Python 3.12.15, PyYAML 6.0.3. - name: Python 3.11+ and PyYAML for the page sets if: matrix.name == 'aac-rs' shell: bash run: | export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH" command -v uv >/dev/null 2>&1 || curl -LsSf https://astral.sh/uv/install.sh | sh uv venv -q --python 3.12 "$HOME/.pagesets-py" uv pip install -q --python "$HOME/.pagesets-py/bin/python" pyyaml "$HOME/.pagesets-py/bin/python" -c 'import sys, yaml; assert sys.version_info >= (3, 11); print(sys.version, "PyYAML", yaml.__version__)' echo "AAC_PYTHON=$HOME/.pagesets-py/bin/python" >> "$GITHUB_ENV" - name: Cache cargo registry + target uses: actions/cache@v4 with: path: | ~/.cargo/registry ~/.cargo/git ${{ matrix.dir }}/target key: standards-${{ matrix.name }}${{ matrix.cache_suffix }}-${{ hashFiles(format('{0}/Cargo.lock', matrix.dir), 'rust-toolchain.toml') }} restore-keys: | standards-${{ matrix.name }}${{ matrix.cache_suffix }}- # --all-targets is load-bearing: a plain `cargo check` does not build test # targets, and smart-byte's dev-dependencies drifted independently of its # normal ones (rand_chacha 0.10 against rand 0.8). The library compiled # while the tests did not. - name: Check (all targets) working-directory: ${{ matrix.dir }} env: RUSTFLAGS: ${{ matrix.rustflags }} run: cargo check --workspace --all-targets - name: Test working-directory: ${{ matrix.dir }} env: RUSTFLAGS: ${{ matrix.rustflags }} # Where z3 was installed above, its absence is a failure, not a skip. JOULE_FIX_EXPECT_Z3: ${{ matrix.z3 && '1' || '' }} run: cargo test --workspace # The NVML backends sit behind features no workspace member enables # (jcp-energy `nvml`, eoc-meter `cuda`, jouleclaw-energy `nvml`), so the # step above never compiles them, let alone runs their tests. Here they # build and their tests run. The container has no GPU and Docker masks # powercap, so the hardware tests take their "unavailable, and the # oracle agrees" branch; the rule's unit tests run in full. - name: Feature-gated energy tests if: matrix.feature_tests working-directory: ${{ matrix.dir }} env: RUSTFLAGS: ${{ matrix.rustflags }} run: cargo test ${{ matrix.feature_tests }} # The page-set packages are not committed; their catalogue is, naming # each package by its content. Any change to what they are built from — # the sources, the app's translations and gloss audits, the symbol # index and sprites, the navigation words, vocabulary.rs, i18n.rs, the # language packs — changes the packages, and the committed catalogue # must change with them (catalog.py --write), or the app would offer # packages it would then refuse to use. - name: Page-set catalogue is current if: matrix.name == 'aac-rs' working-directory: ${{ matrix.dir }} shell: bash run: | "$AAC_PYTHON" tools/pagesets/catalog.py --check "$AAC_PYTHON" tools/pagesets/phrases/build.py --check # Run each standard's conformance suite. # # These suites live in a binary's `main()`, not in `#[test]`, so # `cargo test` never executes them — verified, not assumed: # `cargo test -p aac-conformance` runs **0 tests** while # `cargo run -p aac-conformance` runs **40 checks**; map is 1 test vs 15 # checks. Same shape for jcp, proof and sandbox. The suites include # negative vectors (`C-neg`, `Cneg`), which are the ones that catch a # verifier ACCEPTING what it must reject. # # This is the `main()` instance of the same bug as a feature-gated test: # the code compiles under `--all-targets`, reports nothing, and looks # covered. Compiling a suite is not running it. # # `--bin` is explicit because these crates also ship a `gen-vectors` # binary; `cargo run -p ` is ambiguous with two binaries and fails # rather than choosing — which is how this step would silently never run. # # No path argument: these binaries take none. They check the # implementation against in-code expectations, which is a different claim # from validating the published `conformance/v1` files on disk — nothing # in CI does that yet, and this step does not pretend to. - name: Conformance suite if: matrix.conformance working-directory: ${{ matrix.dir }} env: RUSTFLAGS: ${{ matrix.rustflags }} run: cargo run -q -p ${{ matrix.conformance }} --bin ${{ matrix.conformance }} # ── The macOS energy backends are exercised NOWHERE otherwise ───────────── # # Five crates carry an Apple Silicon IOReport energy meter: jcp-energy, # proof-cascade, eoc-meter, jouleclaw-energy and sandbox-meter. Each is # behind `#[cfg(target_os = "macos")]` (jouleclaw-energy also behind its # `ioreport` feature), and every workspace holding one is pinned to a linux # runner above. A platform-gated test on a single-platform runner is zero # coverage, and it is indistinguishable from passing. # # What this job checks, and why it used to flake. On macOS 27 an unprivileged # reader does not see the IOReport "Energy Model" CPU rails (`CPU Energy`, # `DIE__CPU Energy`) move under load. They are republished only in rare # lumps: on the M3 Ultra both dies held for over 22 minutes, then jumped # about 1.3 kJ each in a single 100 ms sample. On the M5 Max the GPU rail # stays live. The old jcp-energy test asserted that the *summed* rails # advanced during a CPU burn, and the GPU rail satisfied that whenever # something drew on screen. It passed by day and failed at night. The # 2026-09-19 zero that "healed" by 09-21 was this effect, not a recovery. # # Every meter now uses IOReport only when the CPU rail itself keeps # advancing while the probe burns CPU: in at least 5 sample intervals, at # least 4 in every 5, over at least 300 ms. A lump is published in steps (one # was caught as +61.8 kJ, then +3.4 J 64 ms later), so two advancing # intervals are still just a lump. Otherwise the meter falls back to its # estimator rung. Its tests assert against an independent oracle that reads # the CPU rail directly, so this job is deterministic: # - green while each meter declines a rail that does not keep moving; # - red if any meter selects one; # - on a host whose rail is live, red unless CPU work meters non-zero. # # Each step fails unless its test printed the `ioreport liveness:` line. That # line is the only proof the platform-gated test ran and asserted a branch, # rather than a filter, a feature or a cfg leaving zero tests to pass. It goes # to stderr and is captured on success, hence `--nocapture` and `2>&1`. macos-energy-backend: name: macOS energy backends runs-on: macos steps: - uses: actions/checkout@v4 - name: Install Rust (workspace pin) shell: bash run: | export PATH="$HOME/.cargo/bin:$PATH" command -v rustup >/dev/null 2>&1 || \ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - name: Cache cargo registry + targets uses: actions/cache@v4 with: path: | ~/.cargo/registry ~/.cargo/git jcp/jcp-rs/target proof/proof-rs/target eoc/eoc-rs/target jouleclaw/jouleclaw-rs/target sandbox/sandbox-rs/target key: standards-macos-energy-${{ hashFiles('jcp/jcp-rs/Cargo.lock', 'proof/proof-rs/Cargo.lock', 'eoc/eoc-rs/Cargo.lock', 'jouleclaw/jouleclaw-rs/Cargo.lock', 'sandbox/sandbox-rs/Cargo.lock', 'rust-toolchain.toml') }} restore-keys: | standards-macos-energy- # Each crate's lib tests plus its integration tests. Each crate has a # tests/ file that checks the liveness probe runs exactly once per process, # and that check only holds in a fresh process. `--lib` would skip it. - name: jcp-energy shell: bash working-directory: jcp/jcp-rs run: | set -o pipefail log=$(mktemp) cargo test -p jcp-energy -- --nocapture 2>&1 | tee "$log" grep -q 'ioreport liveness:' "$log" - name: proof-cascade shell: bash working-directory: proof/proof-rs run: | set -o pipefail log=$(mktemp) cargo test -p proof-cascade -- --nocapture 2>&1 | tee "$log" grep -q 'ioreport liveness:' "$log" - name: eoc-meter shell: bash working-directory: eoc/eoc-rs run: | set -o pipefail log=$(mktemp) cargo test -p eoc-meter -- --nocapture 2>&1 | tee "$log" grep -q 'ioreport liveness:' "$log" # Double-gated: `cfg(all(feature = "ioreport", target_os = "macos"))`. # A macOS runner alone reaches none of it. - name: jouleclaw-energy (feature AND platform gated) shell: bash working-directory: jouleclaw/jouleclaw-rs run: | set -o pipefail log=$(mktemp) cargo test -p jouleclaw-energy --features ioreport -- --nocapture 2>&1 | tee "$log" grep -q 'ioreport liveness:' "$log" - name: sandbox-meter shell: bash working-directory: sandbox/sandbox-rs run: | set -o pipefail log=$(mktemp) cargo test -p sandbox-meter -- --nocapture 2>&1 | tee "$log" grep -q 'ioreport liveness:' "$log"