From d89d0322adb1533d1c05b780b31986b73d0543a5 Mon Sep 17 00:00:00 2001 From: Dylan Penka Date: Thu, 25 Jun 2026 15:23:02 -0400 Subject: [PATCH 1/9] RWX CI for the Zig compiler (Linux): parallel rollout Stand up an RWX-based Linux CI pipeline that runs in parallel with GitHub Actions (non-blocking), reproducing ci_zig's Linux test matrix plus cross-compilation coverage, with Captain for per-test results and retry. - .rwx/ci.yml + .rwx/ci-arm64.yml: build-once anchor + consumer-leaf DAG (x86_64 with an embedded arm64 run), positive input filters (docs-only change -> cache HIT), a single network boundary for deps (parallel fetch of the lazy cross-compile bundles), a 6-target compile-only cross matrix, the plan.md gate, and GitHub PR triggers. - .captain/config.yml: rwx-v1-json suites for unit/eval/cli/lsp/platforms/ playground (inline per-test results + targeted retry + flake detection). - build.zig, src/build/*, and the test runners: a gated rwx-v1-json reporter (-Droc-test-runner / -Droc-test-rwx-out), a shared test_harness, the cross-agent host-tools caching fix (cpu_model = .baseline), and the playground test optimize set to ReleaseSmall to match GHA. --- .captain/.gitignore | 3 + .captain/config.yml | 199 ++++++ .rwx/ci-arm64.yml | 268 ++++++++ .rwx/ci.yml | 607 +++++++++++++++++++ build.zig | 193 +++++- src/build/modules.zig | 48 ++ src/build/test_harness.zig | 176 ++++++ src/cli/test/parallel_cli_runner.zig | 64 +- src/eval/test/parallel_runner.zig | 49 ++ src/lsp/test/parallel_integration_runner.zig | 49 +- test/playground-integration/main.zig | 56 +- test/zig_test_runner.zig | 298 +++++++-- 12 files changed, 1941 insertions(+), 69 deletions(-) create mode 100644 .captain/.gitignore create mode 100644 .captain/config.yml create mode 100644 .rwx/ci-arm64.yml create mode 100644 .rwx/ci.yml diff --git a/.captain/.gitignore b/.captain/.gitignore new file mode 100644 index 00000000000..96c5a10ed14 --- /dev/null +++ b/.captain/.gitignore @@ -0,0 +1,3 @@ +# Captain's per-suite OSS-mode state (timings/flakes/quarantines) is generated at run time +# and managed by Captain Cloud on RWX — don't commit it. Keep only config.yml. +*/ diff --git a/.captain/config.yml b/.captain/config.yml new file mode 100644 index 00000000000..7aff6439915 --- /dev/null +++ b/.captain/config.yml @@ -0,0 +1,199 @@ +# Captain configuration for roc's Zig unit tests on RWX. +# +# roc's custom test runner (test/zig_test_runner.zig, enabled with -Droc-test-runner) emits +# rwx-v1-json directly — Captain's own canonical schema — so no language/framework hint is +# needed; Captain ingests the per-module files (one per binary) generically and merges them. +# +# - command: runs every module test, writing tmp/rwx-results-*/zig-tests-.json. +# - retries: re-runs only the failing test ids Captain records in {{ jsonFilePath }} +# (each module binary self-filters by id; non-matching binaries run nothing). +# +# Not partitioned: the whole module suite runs in ~14s, so splitting across `parallel` tasks +# saved almost no wall-clock while paying the per-agent fixed cost (tool-cache download + +# build-graph eval) N times. Run as single tasks. (build.zig keeps a dormant -Droc-test-files +# hook if partitioning is ever wanted again.) + +test-suites: + roc-unit-debug: + # Module unit tests only (-Droc-test-modules-only) so the count Captain reports matches + # what actually ran. The non-module bespoke runners (eval/cli/LSP) and the Zig-native + # platform tests now have their own Captain suites below (roc-eval/roc-cli-*/roc-lsp/ + # roc-platforms) rather than running in a plain task. + command: >- + zig build run-test-zig + -Droc-test-runner -Droc-test-modules-only -Droc-test-rwx-out=tmp/rwx-results-debug + -Dfuzz -Dsystem-afl=false + fail-on-duplicate-test-id: true + results: + path: tmp/rwx-results-debug/zig-tests-*.json + output: + print-summary: true + reporters: + rwx-v1-json: tmp/captain/roc-unit-debug.json + retries: + attempts: 2 + command: >- + zig build run-test-zig + -Droc-test-runner -Droc-test-modules-only -Droc-test-rwx-out=tmp/rwx-results-debug + -Dfuzz -Dsystem-afl=false + -- --roc-test-only-json={{ jsonFilePath }} + + roc-unit-release: + # Module unit tests only (see roc-unit-debug note), ReleaseFast. + command: >- + zig build run-test-zig -Doptimize=ReleaseFast + -Droc-test-runner -Droc-test-modules-only -Droc-test-rwx-out=tmp/rwx-results-release + -Dfuzz -Dsystem-afl=false + fail-on-duplicate-test-id: true + results: + path: tmp/rwx-results-release/zig-tests-*.json + output: + print-summary: true + reporters: + rwx-v1-json: tmp/captain/roc-unit-release.json + retries: + attempts: 2 + command: >- + zig build run-test-zig -Doptimize=ReleaseFast + -Droc-test-runner -Droc-test-modules-only -Droc-test-rwx-out=tmp/rwx-results-release + -Dfuzz -Dsystem-afl=false + -- --roc-test-only-json={{ jsonFilePath }} + + # ── Bespoke fork-based runners (eval / cli / LSP) ──────────────────────────────────── + # These are NOT Zig's test protocol; each is a parent process that forks workers and + # aggregates results. They emit rwx-v1-json from their parent via the shared reporter in + # src/build/test_harness.zig (writeRwxResults), so a single results file per suite — no + # per-binary glob/merge. The runner reads --roc-test-only-json on retry and self-filters by + # id (the same id scheme as the unit runner: "::"). + + roc-eval: + # Eval interpreter/dev/wasm/llvm test suite (all backends; --llvm adds the LLVM backend). + command: >- + zig build run-test-eval + -- --llvm --roc-test-rwx-out=tmp/rwx-results-eval --roc-test-suite=eval + fail-on-duplicate-test-id: true + results: + path: tmp/rwx-results-eval/eval.json + output: + print-summary: true + reporters: + rwx-v1-json: tmp/captain/roc-eval.json + retries: + attempts: 2 + command: >- + zig build run-test-eval + -- --llvm --roc-test-rwx-out=tmp/rwx-results-eval --roc-test-suite=eval + --roc-test-only-json={{ jsonFilePath }} + + roc-cli-platforms: + # CLI integration matrix, interpreter + dev backends (the default `run-test-cli` run). + command: >- + zig build run-test-cli + -- --roc-test-rwx-out=tmp/rwx-results-cli-platforms --roc-test-suite=cli-platforms + fail-on-duplicate-test-id: true + results: + path: tmp/rwx-results-cli-platforms/cli-platforms.json + output: + print-summary: true + reporters: + rwx-v1-json: tmp/captain/roc-cli-platforms.json + retries: + attempts: 2 + command: >- + zig build run-test-cli + -- --roc-test-rwx-out=tmp/rwx-results-cli-platforms --roc-test-suite=cli-platforms + --roc-test-only-json={{ jsonFilePath }} + + roc-cli-llvm: + # CLI integration matrix, LLVM size + speed backends only (distinct suite so its count + # matches what ran; the failing ids on retry are a subset of these cases). + command: >- + zig build run-test-cli + -- --include-llvm --filter "[size]" --filter "[speed]" + --roc-test-rwx-out=tmp/rwx-results-cli-llvm --roc-test-suite=cli-llvm + fail-on-duplicate-test-id: true + results: + path: tmp/rwx-results-cli-llvm/cli-llvm.json + output: + print-summary: true + reporters: + rwx-v1-json: tmp/captain/roc-cli-llvm.json + retries: + attempts: 2 + command: >- + zig build run-test-cli + -- --include-llvm --filter "[size]" --filter "[speed]" + --roc-test-rwx-out=tmp/rwx-results-cli-llvm --roc-test-suite=cli-llvm + --roc-test-only-json={{ jsonFilePath }} + + roc-lsp: + # LSP integration specs (bespoke fork-based runner, dedicated build step). + command: >- + zig build run-test-zig-module-lsp_integration + -- --roc-test-rwx-out=tmp/rwx-results-lsp --roc-test-suite=lsp + fail-on-duplicate-test-id: true + results: + path: tmp/rwx-results-lsp/lsp.json + output: + print-summary: true + reporters: + rwx-v1-json: tmp/captain/roc-lsp.json + retries: + attempts: 2 + command: >- + zig build run-test-zig-module-lsp_integration + -- --roc-test-rwx-out=tmp/rwx-results-lsp --roc-test-suite=lsp + --roc-test-only-json={{ jsonFilePath }} + + # ── Zig-native platform tests (fx / http / json / watch) ───────────────────────────── + # These ARE Zig's test protocol (b.addTest), so they reuse the unit runner's rwx-v1-json + # reporter via -Droc-test-runner (each writes zig-tests-.json; glob + merge). Run + # together in one suite. watch uses suite "watch_cli" so its ids don't collide with the + # watch *module* already in roc-unit-*. -Droc-test-runner matches the build-ci anchor so + # the test binaries cache-hit and only RUN here. + roc-platforms: + command: >- + zig build + run-test-zig-fx-platform + run-test-zig-http-header-decoder-platform + run-test-zig-json-decoder-platform + run-test-zig-watch-cli + -Droc-test-runner -Droc-test-rwx-out=tmp/rwx-results-platform + -Dfuzz -Dsystem-afl=false + fail-on-duplicate-test-id: true + results: + path: tmp/rwx-results-platform/zig-tests-*.json + output: + print-summary: true + reporters: + rwx-v1-json: tmp/captain/roc-platforms.json + retries: + attempts: 2 + command: >- + zig build + run-test-zig-fx-platform + run-test-zig-http-header-decoder-platform + run-test-zig-json-decoder-platform + run-test-zig-watch-cli + -Droc-test-runner -Droc-test-rwx-out=tmp/rwx-results-platform + -Dfuzz -Dsystem-afl=false + -- --roc-test-only-json={{ jsonFilePath }} + + roc-playground: + # WASM playground integration suite (bespoke fork runner under bytebox; same shared reporter). + command: >- + zig build run-test-playground + -- --roc-test-rwx-out=tmp/rwx-results-playground --roc-test-suite=playground + fail-on-duplicate-test-id: true + results: + path: tmp/rwx-results-playground/playground.json + output: + print-summary: true + reporters: + rwx-v1-json: tmp/captain/roc-playground.json + retries: + attempts: 2 + command: >- + zig build run-test-playground + -- --roc-test-rwx-out=tmp/rwx-results-playground --roc-test-suite=playground + --roc-test-only-json={{ jsonFilePath }} diff --git a/.rwx/ci-arm64.yml b/.rwx/ci-arm64.yml new file mode 100644 index 00000000000..2181d8ac763 --- /dev/null +++ b/.rwx/ci-arm64.yml @@ -0,0 +1,268 @@ +# arm64 Linux leg — embedded run (called from ci.yml). arch is run-level, so the +# arm64 matrix lives in its own definition with base.arch: arm64. Runs the +# arch-sensitive subset of the x64 DAG plus kcov parser coverage (arm64-only: +# x86_64 hits a Zig DWARF bug, so coverage runs here today). +# on.cli lets this leg be iterated directly (`rwx run .rwx/ci-arm64.yml --target +# build-ci-arm64`); it's ignored when this file is embedded via ci.yml. +on: + cli: + init: + commit-sha: ${{ event.git.sha }} + +base: + image: ubuntu:24.04 + config: rwx/base 1.1.1 + arch: arm64 + +tool-cache: + vault: dylan-roc + +tasks: + # No .git here — preserving it busts the cache key every commit. Only the + # snapshot zero-drift check needs git, via the separate code-git clone below. + - key: code + call: git/clone 2.0.7 + with: + repository: https://github.com/roc-lang/roc.git + ref: ${{ init.commit-sha }} + + # Extracted into $HOME (OUTSIDE the workspace) with the local zig build cache + # pinned to $HOME/.zig-cache, so the positive, workspace-relative input filters + # stay pure-source without whiting out the toolchain or busting the tool-cache. + - key: zig-arm64 + run: | + set -euo pipefail + ZIG_VERSION=0.16.0 + ZIG_DIR="zig-aarch64-linux-${ZIG_VERSION}" + URL="https://ziglang.org/download/${ZIG_VERSION}/${ZIG_DIR}.tar.xz" + for attempt in 1 2 3; do + if curl -fsSL --retry 3 -o zig.tar.xz "$URL"; then break; fi + echo "zig download attempt ${attempt} failed; retrying" >&2 + sleep 5 + done + tar -xf zig.tar.xz -C "$HOME" + rm zig.tar.xz + mkdir -p "$HOME/.zig-cache" + echo "$HOME/${ZIG_DIR}" > "$RWX_ENV/PATH" + echo "$HOME/.zig-cache" > "$RWX_ENV/ZIG_LOCAL_CACHE_DIR" + "$HOME/${ZIG_DIR}/zig" version + + # The ONLY network boundary — retry transient fetch flakes here so the build + # anchors run offline and need no retry. `--fetch` fetches only EAGER deps; + # kcov is a LAZY dep (build.zig.zon `lazy = true`) that arm64 build-ci needs, + # so we additionally `zig fetch` it (URL read from build.zig.zon to stay in + # sync). Both land in ~/.cache/zig, which carries across `use:`. + # POSITIVE input filter (include only build inputs). Toolchain + local zig cache + # live in $HOME (outside the workspace), so this workspace-relative filter never + # drops them. `&pos-build` defined here (first filtered task on this leg). + - key: zig-deps-arm64 + use: [code, zig-arm64] + filter: &pos-build + - build.zig + - build.zig.zon + - src + - test + - ci + - vendor + - legal_details # embedded into the roc binary via addAnonymousImport (build.zig:5042) + run: | + set +e + pattern="HttpConnectionClosing|EndOfStream|503|Timeout|WriteFailed|TemporaryNameServerFailure|cannot download|ConnectionResetByPeer|ConnectionTimedOut|FetchFailed|invalid HTTP response|bad HTTP response" + fetch_all() { + zig build build-ci -Dfuzz -Dsystem-afl=false --fetch || return 1 + kcov_url=$(grep -A1 'kcov = ' build.zig.zon | grep -oE 'https://[^"]+') + [ -n "$kcov_url" ] || { echo "kcov url not found in build.zig.zon" >&2; return 1; } + echo "fetching lazy dep kcov: $kcov_url" + zig fetch "$kcov_url" || return 1 + } + attempt=1; max=5 + while :; do + fetch_all 2>&1 | tee /tmp/fetch.out + rc=${PIPESTATUS[0]} + [ "$rc" -eq 0 ] && break + if [ "$attempt" -ge "$max" ] || ! grep -qE "$pattern" /tmp/fetch.out; then exit "$rc"; fi + attempt=$((attempt+1)); echo ">>> transient fetch failure; retry ${attempt}/${max} in 10s" >&2; sleep 10 + done + + # Captain CLI — inline per-test results, retry, and partitioning for the unit suites + # (shared .captain/config.yml). Secret-free; RWX provides the access token automatically. + - key: captain + call: rwx/install-captain 1.1.7 + + # System packages not on the rwx/base image, pulled into their own cached tasks. + - key: system-file + run: | + set -euo pipefail + sudo apt-get update && sudo apt-get install -y file + + # kcov build/link deps. On arm64 `zig build build-ci` builds kcov (coverage + # tools); x86_64 build-ci skips kcov (Zig DWARF bug), so this is arm64-only. + - key: system-kcov + run: | + set -euo pipefail + # Authoritative build-dep list: src/flake.nix `kcovBuildDeps` + # = [ elfutils pkg-config curl zlib ] (Linux only). + # elfutils -> libdw-dev + libelf-dev, curl -> libcurl4-openssl-dev, + # zlib -> zlib1g-dev. pkg-config is essential: kcov's build resolves + # libdw/libelf via pkg-config module names; without it Zig searches + # "lib"+name (liblibdw.so) and fails. rwx/base ships none of these. + sudo apt-get update && sudo apt-get install -y \ + pkg-config libdw-dev libelf-dev libcurl4-openssl-dev zlib1g-dev + + # Debug anchor (arm64 native; defaults to musl per build.zig:2217). No retry: + # deps are pre-fetched, so this builds offline and fails fast on real errors. + - key: build-ci-arm64 + use: [code, zig-arm64, system-kcov, zig-deps-arm64] + filter: *pos-build + tool-cache: ci-build-ci-arm64 + timeout: 45m # arm64 build compiles roc + kcov from source; exceeds the 10m default + agent: + cpus: 32 + memory: 64gb + run: | + set -euo pipefail + # -Droc-test-runner: pre-build the custom-runner test binaries so the unit tasks cache-hit + # and only run them (see ci.yml build-ci-x64). + zig build build-ci -Droc-test-runner -Dfuzz -Dsystem-afl=false + + # Test-execution leaves: CPU-bound, memory-light → compute-optimized 8c/16g. + # Leaf positive filter: build inputs + the anchor's compiled binaries (zig-out), + # consumed via the filesystem layer. `.zig-cache` + toolchain are in $HOME, so + # they're inherited unfiltered. `&pos-leaf` defined here (first leaf). + # Captain-partitioned module unit tests (see .captain/config.yml, suite roc-unit-debug). + - key: unit-debug + use: [build-ci-arm64, captain] + filter: &pos-leaf + - build.zig + - build.zig.zon + - src + - test + - ci + - vendor + - legal_details + - zig-out + - .captain # Captain suite config (.captain/config.yml); without it `captain run` finds no command + agent: &test-agent + cpus: 8 + memory: 16gb + run: | + set -euo pipefail + captain run roc-unit-debug + outputs: + test-results: + - path: tmp/captain/roc-unit-debug.json + + # NB: snapshot tests are arch-independent (compiler front-end output, not + # codegen), so they run once on x64 (ci.yml) — not duplicated here. + + # run-test-cli exercises the fx platform tests (test/fx/file_import_str.roc imports + # CONTRIBUTING/profiling/...), and this leaf roc-checks a CONTRIBUTING/profiling + # file, so it keeps CONTRIBUTING/profiling on top of the leaf inputs (only + # CONTRIBUTING reader on this leg, so the list is inline rather than anchored). + - key: platforms + use: [build-ci-arm64, captain] + filter: + - build.zig + - build.zig.zon + - src + - test + - ci + - vendor + - legal_details + - zig-out + - .captain # Captain suite config; without it `captain run` finds no command + # Data files imported by the CLI test fixtures (run-test-cli): test/fx + # imports CONTRIBUTING/profiling/…, test/echo/all_syntax_test.roc imports + # the repo-root README.md. Both must survive the filter (FILE NOT FOUND otherwise). + - CONTRIBUTING/profiling + - README.md + agent: *test-agent + run: | + set -euo pipefail + captain run roc-cli-platforms + ./zig-out/bin/roc check ./CONTRIBUTING/profiling/bench_repeated_check.roc + outputs: + test-results: + - path: tmp/captain/roc-cli-platforms.json + + # Parser coverage via kcov (arm64-only). kcov is built from the build.zig.zon + # dep; it needs the system-kcov libs at runtime too. + # system-kcov must precede build-ci-arm64 in `use:` because build-ci-arm64 + # itself uses system-kcov (a dependency must be listed before its dependents). + - key: coverage-parser + use: [system-kcov, build-ci-arm64] + filter: *pos-leaf + agent: *test-agent + run: | + set -euo pipefail + zig build run-coverage-parser + + # Release anchor (arm64). No retry (deps pre-fetched). + - key: build-release-arm64 + use: [code, zig-arm64, system-kcov, zig-deps-arm64] + filter: *pos-build + tool-cache: ci-build-release-arm64 + timeout: 45m + agent: + cpus: 32 + memory: 64gb + run: | + set -euo pipefail + # -Droc-test-runner: pre-build custom-runner test binaries so unit-release cache-hits. + zig build build-ci -Droc-test-runner -Doptimize=ReleaseFast -Dfuzz -Dsystem-afl=false + + # Module unit tests in ReleaseFast via Captain (single task — not worth partitioning). + - key: unit-release + use: [build-release-arm64, captain] + filter: *pos-leaf + agent: *test-agent + run: | + set -euo pipefail + captain run roc-unit-release + outputs: + test-results: + - path: tmp/captain/roc-unit-release.json + + # LSP integration specs via Captain (bespoke fork-based runner; was part of plain unit-misc). + - key: lsp + use: [build-ci-arm64, captain] + filter: *pos-leaf + agent: *test-agent + run: | + set -euo pipefail + captain run roc-lsp + outputs: + test-results: + - path: tmp/captain/roc-lsp.json + + # fx / http / json / watch platform tests via Captain (Zig-native; was part of plain + # unit-misc). Needs pos-cli inputs (fx fixtures import CONTRIBUTING/profiling + README.md). + - key: platform-tests + use: [build-ci-arm64, captain] + filter: + - build.zig + - build.zig.zon + - src + - test + - ci + - vendor + - legal_details + - zig-out + - .captain + - CONTRIBUTING/profiling + - README.md + agent: *test-agent + run: | + set -euo pipefail + captain run roc-platforms + outputs: + test-results: + - path: tmp/captain/roc-platforms.json + + - key: static-link + use: [build-release-arm64, system-file] + filter: *pos-leaf + run: | + set -euo pipefail + file ./zig-out/bin/roc + file ./zig-out/bin/roc | grep "statically linked" diff --git a/.rwx/ci.yml b/.rwx/ci.yml new file mode 100644 index 00000000000..474854353ec --- /dev/null +++ b/.rwx/ci.yml @@ -0,0 +1,607 @@ +on: + # Match GHA (ci_manager.yml) — CI runs on pull requests. No push trigger (GHA has none here). + github: + pull_request: + init: + commit-sha: ${{ event.git.sha }} + pr-number: ${{ event.github.pull_request.number }} + cli: + init: + commit-sha: ${{ event.git.sha }} + pr-number: '' + +# Match GHA's `concurrency: cancel-in-progress` (grouped per ref): a new commit on a PR cancels +# the superseded run. Scoped to PR runs via `if` so local `rwx run` iterations (pr-number empty) +# aren't cancelled. Pool ids are org-global, so the id is repo-qualified to avoid collisions. +concurrency-pools: + - id: roc-lang/roc:ci-pr-${{ init.pr-number }} + if: ${{ init.pr-number != '' }} + capacity: 1 + on-overflow: cancel-running + +base: + image: ubuntu:24.04 + config: rwx/base 1.1.1 + +# Tool caches (incremental compile state) live in the scratch dylan-roc vault. +# It's unlocked, so CLI runs can write; migrates to the proper cache vault at step 3. +tool-cache: + vault: dylan-roc + +tasks: + # ============================================================ setup + # Clone roc (public repo, tokenless). NO .git here: preserving it would change + # the cache key on every commit and bust hits for the build anchors and leaves. + # Only the two git-specific checks use the separate `code-git` clone below. + - key: code + call: git/clone 2.0.7 + with: + repository: https://github.com/roc-lang/roc.git + ref: ${{ init.commit-sha }} + + # Git-bearing clone — ONLY for checks that genuinely need .git (snapshot + # zero-drift `git diff`, and run-check-git-lints which otherwise skips itself). + # This one busts cache per-commit by design; the expensive anchors don't. + - key: code-git + call: git/clone 2.0.7 + with: + repository: https://github.com/roc-lang/roc.git + ref: ${{ init.commit-sha }} + preserve-git-dir: true + + # Install pinned Zig 0.16.0 (no RWX zig package exists). Extracted into $HOME + # (OUTSIDE the workspace) and PATH-ed there; $PATH is auto-merged across `use:` + # deps. Living outside /var/mint-workspace is what lets the positive, workspace- + # relative input filters stay pure-source without whiting out the toolchain. + # The local zig build cache is pinned to $HOME/.zig-cache for the same reason + # (so the anchors' positive filter need not list — and thereby churn on — it). + # Dependency-download is isolated here as the single retryable network boundary. + - key: zig-x64 + run: | + set -euo pipefail + ZIG_VERSION=0.16.0 + ZIG_DIR="zig-x86_64-linux-${ZIG_VERSION}" + URL="https://ziglang.org/download/${ZIG_VERSION}/${ZIG_DIR}.tar.xz" + for attempt in 1 2 3; do + if curl -fsSL --retry 3 -o zig.tar.xz "$URL"; then break; fi + echo "zig download attempt ${attempt} failed; retrying" >&2 + sleep 5 + done + tar -xf zig.tar.xz -C "$HOME" + rm zig.tar.xz + mkdir -p "$HOME/.zig-cache" + echo "$HOME/${ZIG_DIR}" > "$RWX_ENV/PATH" + echo "$HOME/.zig-cache" > "$RWX_ENV/ZIG_LOCAL_CACHE_DIR" + "$HOME/${ZIG_DIR}/zig" version + + # Captain CLI — inline per-test results, targeted retry, and timing-balanced + # partitioning for the Zig unit suites (see .captain/config.yml). Secret-free: + # RWX provides Captain its access token automatically. + - key: captain + call: rwx/install-captain 1.1.7 + + # ============================================================ source-only gates + # Pure source/grep/perl checks — no compiled binaries needed, so they run in + # parallel with the build instead of waiting on the anchor (build.zig:4091-4115). + # POSITIVE input filter: include ONLY the build inputs. Anything outside this set + # (docs, design/, devtools/, examples/, root *.md, …) is whited out, so changes to + # them are a content-cache HIT. Safe because the toolchain + zig cache live in + # $HOME (outside the workspace, so the filter never touches them). `&pos-build` is + # defined here (first filtered task) and referenced by the anchors + zig-deps. + - key: source-gates + use: [code, zig-x64] + filter: &pos-build + - build.zig + - build.zig.zon + - src + - test + - ci + - vendor + - legal_details # embedded into the roc binary via addAnonymousImport (build.zig:5042) + run: | + set -euo pipefail + zig build run-check-zig-format + zig build run-check-type-checker-patterns + zig build run-check-enum-from-int-zero + zig build run-check-unused-suppression + zig build run-check-semantic-audit + zig build run-check-postcheck-architecture + zig build run-check-panic + zig build run-check-cli-global-stdio + + # Dependency fetch — the single network boundary. Fetches all build.zig.zon deps + # once into Zig's global cache (~/.cache/zig); the whole filesystem carries + # across `use:`, so the build anchors compile offline (no per-anchor fetch idle, + # and the flaky HttpConnectionClosing surface is confined to this one retried task). + - key: zig-deps-x64 + use: [code, zig-x64] + filter: *pos-build + run: | + # The ONLY network boundary — retry transient fetch flakes here (faithful + # port of GHA .github/actions/flaky-retry) so the build anchors run offline + # and need no retry of their own. RWX injects `set -e`; `set +e` lets us + # inspect+retry, and $PIPESTATUS (bash) reads the exit through the tee pipe. + # NOTE: `--fetch` only gets EAGER build.zig.zon deps. x86_64 build-ci needs + # only those (it skips kcov), so no lazy `zig fetch` is required here. + set +e + pattern="HttpConnectionClosing|EndOfStream|503|Timeout|WriteFailed|TemporaryNameServerFailure|cannot download|ConnectionResetByPeer|ConnectionTimedOut|FetchFailed|invalid HTTP response|bad HTTP response" + attempt=1; max=5 + while :; do + zig build build-ci -Dfuzz -Dsystem-afl=false --fetch 2>&1 | tee /tmp/fetch.out + rc=${PIPESTATUS[0]} + [ "$rc" -eq 0 ] && break + if [ "$attempt" -ge "$max" ] || ! grep -qE "$pattern" /tmp/fetch.out; then exit "$rc"; fi + attempt=$((attempt+1)); echo ">>> transient fetch failure; retry ${attempt}/${max} in 10s" >&2; sleep 10 + done + + # ============================================================ DEBUG anchor + # The single cached Debug compile. On Linux x86_64 the build defaults to musl + + # x86_64_v3 (build.zig:2215-2227) and Debug optimize (:2229), matching GHA. + # Consumed by every debug-config leaf; eval/cli --llvm are runtime flags (LLVM + # is statically linked unconditionally) so they reuse this anchor too. + # No retry: deps are pre-fetched by zig-deps-x64, so this runs offline and fails + # fast on real compile errors. + - key: build-ci-x64 + use: [code, zig-x64, zig-deps-x64] + # Positive filter: only the build inputs affect this anchor's cache key, so any + # change outside them (docs, design/, examples/, root *.md, …) is a HIT. The zig + # toolchain + local cache live in $HOME (outside the workspace), so they survive + # this workspace-relative filter and the tool-cache warm-restore is untouched. + filter: *pos-build + tool-cache: ci-build-ci-x64 # persist .zig-cache + ~/.cache/zig → warm runs skip the build-runner compile & recompile only changed modules + timeout: 45m + agent: + cpus: 32 + memory: 64gb + run: | + set -euo pipefail + # -Droc-test-runner so the module + standalone test binaries are compiled ONCE here with + # the custom runner; the Captain unit partitions then cache-hit and only RUN (no per- + # partition recompile). Doesn't affect the roc compiler / check tools / other runners. + zig build build-ci -Droc-test-runner -Dfuzz -Dsystem-afl=false + + # Tool/roc-dependent checks (need the prebuilt check tools + debug roc). + # Uses code-git: run-check-tidy (git ls-files) and run-check-zig-lints + # (git diff vs origin/main) both require a git worktree. + - key: gates + use: [build-ci-x64, code-git] + run: | + set -euo pipefail + zig build run-check-zig-lints + zig build run-check-tidy + zig build run-check-test-wiring + zig build run-check-builtin-format + zig build run-check-fx-platform-test-coverage + zig build run-test-serialization-sizes + + # git-lints needs .git (run-check-git-lints skips itself without a worktree), + # so it uses code-git. Kept separate from `gates` so the other checks stay + # cacheable across commits. + - key: git-lints + use: [build-ci-x64, code-git] + run: | + set -euo pipefail + zig build run-check-git-lints + # GHA ci_zig.yml `check-once` parity: no tracked plan.md files allowed. Needs a real + # git worktree (git ls-files), which code-git provides and the .git-less `code` clone does not. + matches="$(git ls-files -- ':(glob)**/plan.md')" + if [ -n "$matches" ]; then + echo "Tracked plan.md files are not allowed:" + echo "$matches" + exit 1 + fi + + # Test-execution leaves need more than the default 2c/8g (they OOM), but are + # CPU-bound and memory-light — so compute-optimized 8c/16g (not 8c/32g) via the + # &test-agent alias. Cheap lint/format/check leaves stay on 2c/8g. + # Captain runs roc's custom runner (-Droc-test-runner; binaries pre-built by the anchor so + # this only RUNS them), emits rwx-v1-json per module, and gives inline per-test results + + # "retry failed tests" via outputs.test-results. See .captain/config.yml (roc-unit-debug). + # NOT partitioned: with the anchor pre-build, running all ~2800 module tests is ~14s, so + # `parallel:` only saved ~9s wall while paying the ~64s/agent fixed cost (tool-cache download + # + build-graph eval) N times. The partition config/globs/flags stay wired — re-add + # `parallel: N` here if the suite grows enough to be worth it. + - key: unit-debug + use: [build-ci-x64, captain] + # Leaf positive filter: build inputs + the anchor's compiled binaries (zig-out). + # zig-out is consumed via the filesystem layer (zig build run-* / ./zig-out/bin + # expect it at the cwd-relative project path). `.zig-cache` + toolchain are in + # $HOME, so they're inherited unfiltered. Defined here (first leaf). + filter: &pos-leaf + - build.zig + - build.zig.zon + - src + - test + - ci + - vendor + - legal_details + - zig-out + - .captain # Captain suite config (.captain/config.yml); without it `captain run` finds no command + agent: &test-agent + cpus: 8 + memory: 16gb + run: | + set -euo pipefail + captain run roc-unit-debug + outputs: + test-results: + - path: tmp/captain/roc-unit-debug.json + + # 4c/8g — measured faster AND more consistent than 2c (4c ~167-174s vs 2c ~181-270s + # exec, benchmarked with cache:false). The snapshot *regeneration* is single-threaded + # under --debug (src/snapshot_tool/main.zig:726-728), but the rest of the exec (LLVM + # relink of snapshot_exe, write_compiled_builtins, build runner) is multi-threaded and + # benefits; 2c also contends more (high variance). 4 cores is the sweet spot — the + # single-threaded regen means more cores wouldn't help further. + - key: snapshots + use: [build-ci-x64, code-git] + agent: + cpus: 4 + memory: 8gb + run: | + set -euo pipefail + zig build run-check-snapshots -- --debug + git diff --exit-code test/snapshots + + - key: eval-llvm + use: [build-ci-x64, captain] + filter: *pos-leaf + agent: *test-agent + run: | + set -euo pipefail + captain run roc-eval + outputs: + test-results: + - path: tmp/captain/roc-eval.json + + # run-test-cli exercises the fx platform tests, whose test/fx/file_import_str.roc + # imports CONTRIBUTING/profiling/bench_repeated_check_ORIGINAL.roc — so this leaf + # (and `platforms`) must keep CONTRIBUTING/profiling on top of the leaf inputs. + # `&pos-cli` defined here; referenced by `platforms` below. + - key: cli-llvm + use: [build-ci-x64, captain] + filter: &pos-cli + - build.zig + - build.zig.zon + - src + - test + - ci + - vendor + - legal_details + - zig-out + - .captain # Captain suite config; without it `captain run` finds no command + # Data files imported by the CLI test fixtures (run-test-cli): test/fx + # imports CONTRIBUTING/profiling/…, test/echo/all_syntax_test.roc imports + # the repo-root README.md. Both must survive the filter or the tests fail + # with FILE NOT FOUND. + - CONTRIBUTING/profiling + - README.md + agent: *test-agent + run: | + set -euo pipefail + captain run roc-cli-llvm + outputs: + test-results: + - path: tmp/captain/roc-cli-llvm.json + + # CLI platform tests (interpreter + dev) + minimal roc-check smoke (debug roc). + - key: platforms + use: [build-ci-x64, captain] + filter: *pos-cli + agent: *test-agent + run: | + set -euo pipefail + captain run roc-cli-platforms + ./zig-out/bin/roc check ./CONTRIBUTING/profiling/bench_repeated_check.roc + outputs: + test-results: + - path: tmp/captain/roc-cli-platforms.json + + - key: dylib-archive + use: build-ci-x64 + filter: *pos-leaf + agent: *test-agent + run: | + set -euo pipefail + zig build run-test-dylib + zig build run-test-archive + + - key: playground + use: [build-ci-x64, captain] + filter: *pos-leaf + run: | + set -euo pipefail + captain run roc-playground + outputs: + test-results: + - path: tmp/captain/roc-playground.json + + - key: fuzz-repro + use: build-ci-x64 + filter: *pos-leaf + env: + ROC_FUZZ_SEED: ${{ init.commit-sha }} + run: | + set -euo pipefail + # python3 is present on the rwx/base image (verified). + for target in tokenize parse canonicalize typecheck; do + export ROC_FUZZ_TARGET="$target" + export ROC_FUZZ_INPUT="/tmp/roc-${target}-fuzz-input" + python3 - <<'PY' + import base64, hashlib, os, pathlib + seed = os.environ["ROC_FUZZ_SEED"] + target = os.environ["ROC_FUZZ_TARGET"] + data = bytearray() + for index in range(8): + data.extend(hashlib.sha256(f"{seed}:{target}:{index}".encode()).digest()) + path = pathlib.Path(os.environ["ROC_FUZZ_INPUT"]) + path.write_bytes(data) + print(f"{target} fuzz seed: {seed}") + print(f"{target} fuzz input base64: {base64.b64encode(data).decode()}") + PY + zig build "run-repro-${target}" -- "$ROC_FUZZ_INPUT" + done + + # ============================================================ cross-compile (compile-only) + # GHA ci_zig.yml `zig-cross-compile`: compile-only checks that roc builds for each cross + # target (no execution). Runs on every build like the anchors; the *pos-build filter makes + # docs/test-only changes a HIT. x64-only — cross output is build-host-independent and GHA + # runs it x64-only too. + # + # The roc-cross-compile / on-target-run job (ci_cross_compile.yml) deliberately STAYS on GHA: + # it builds from 4 hosts (3 are mac/win — never on RWX) and its on-target check runs every + # host's binary together on one target runner. RWX can't reproduce that — arch is run-level so + # arm64 is an embedded run, and embedded runs are filesystem-isolated (only string init params + # cross the boundary, never the cross-built binary). So it's part of the mac/win GHA remainder. + # + # Each cross target lazily pulls its roc_deps_ (roc-bootstrap prebuilt deps). `zig build + # --fetch` only gets EAGER deps, so pre-fetch the lazy per-target deps here as a dedicated, + # retryable network boundary — kept separate from zig-deps-x64 so cross-dep churn doesn't bust + # the native anchor deps cache. Mirrors the arm64 zig-deps lazy-fetch pattern. + - key: zig-deps-cross-x64 + use: [code, zig-x64] + # Narrow key: this task only reads build.zig.zon (greps the dep URLs) but build.zig MUST be in + # the filter too — `zig fetch ` errors "no build.zig file found" without a project root + # (validated). Keying on just these two — NOT *pos-build — means a src/test change is a HIT here; + # it only re-runs when build.zig OR build.zig.zon changes. + # No tool-cache on purpose: `zig fetch ` is URL-keyed (it downloads, THEN hashes), so it + # re-downloads even when the package is already in the global cache — verified: a warm 2.3 GB + # tool-cache restored, yet every dep still re-fetched. A tool-cache would just move the same + # ~2.3 GB over the wire for nothing. (Zig's hash-skip lives in `zig build`, not `zig fetch`.) + # Instead the 6 prebuilt bundles fetch in PARALLEL — independent downloads into distinct + # p/ dirs. `zig fetch` is CPU-bound on decompression (`.tar.xz`/`.zip`, user≈real), not + # network, so wall time scales with cores: 4 cpus runs the 6 extractions in ~2 waves (~100s vs + # the ~290s serial sum). Memory stays small (8gb) — fetch/extract isn't memory-heavy. + agent: + cpus: 4 + memory: 8gb + filter: + - build.zig + - build.zig.zon + run: | + set +e + # `zig fetch` of a .zip needs a seekable temp file in the global package cache's tmp dir; it + # doesn't makePath it first, so pre-create it. x86_64-windows-gnu is the only .zip dep. Each + # fetch gets its own random tmp subdir, so concurrent fetches don't collide. + mkdir -p "$HOME/.cache/zig/tmp" "$HOME/.cache/zig/p" + pattern="HttpConnectionClosing|EndOfStream|503|Timeout|WriteFailed|TemporaryNameServerFailure|cannot download|ConnectionResetByPeer|ConnectionTimedOut|FetchFailed|invalid HTTP response|bad HTTP response" + # Per-dep fetch with its own flaky-retry (faithful port of GHA .github/actions/flaky-retry); + # invoked as a background job per dep so all 6 download concurrently. + fetch_one() { + key="$1" + url=$(grep -A1 "\.${key} = " build.zig.zon | grep -oE 'https://[^"]+') + [ -n "$url" ] || { echo "[${key}] url not found in build.zig.zon" >&2; return 1; } + attempt=1; max=5 + while :; do + out="/tmp/fetch.${key}.out" + zig fetch "$url" >"$out" 2>&1 + rc=$? + sed "s|^|[${key}] |" "$out" + [ "$rc" -eq 0 ] && { echo "[${key}] done"; return 0; } + if [ "$attempt" -ge "$max" ] || ! grep -qE "$pattern" "$out"; then + echo "[${key}] fetch failed (rc=${rc}) after ${attempt} attempt(s)" >&2; return "$rc" + fi + attempt=$((attempt+1)); echo "[${key}] transient failure; retry ${attempt}/${max} in 10s" >&2; sleep 10 + done + } + pids="" + for key in \ + roc_deps_aarch64_linux_musl \ + roc_deps_arm_linux_musleabihf \ + roc_deps_x86_linux_musl \ + roc_deps_aarch64_macos_none \ + roc_deps_x86_64_macos_none \ + roc_deps_x86_64_windows_gnu; do + echo "fetching lazy cross dep ${key}" + fetch_one "$key" & + pids="$pids $!" + done + rc=0 + for p in $pids; do wait "$p" || rc=1; done + exit "$rc" + + # Compile-only matrix (6 targets), structured like a build anchor: offline build off the deps + # boundary, own per-target tool-cache. Per-target cache name is REQUIRED — a tool cache is + # initial + 3 incremental layers and resets on the 4th, so sharing one name across the 6 matrix + # variants would thrash (each target reset the others). + - key: zig-cross-compile + use: [code, zig-x64, zig-deps-x64, zig-deps-cross-x64] + filter: *pos-build + tool-cache: ci-zig-cross-${{ parallel.target }} + timeout: 45m + # Match the build anchors: Compute-Optimized 32c/64gb. A full `zig build` of roc is + # memory-heavy (LLVM/link peak) — 32gb (CO 16c) OOMs; 64gb is the next CO tier (32c) and is + # what the anchors use to build roc with even more pressure (-Droc-test-runner -Dfuzz). + agent: + cpus: 32 + memory: 64gb + parallel: + matrix: + target: + - aarch64-linux-musl + - arm-linux-musleabihf + - x86-linux-musl + - aarch64-macos-none + - x86_64-macos-none + - x86_64-windows-gnu + run: | + set -euo pipefail + zig build -Dtarget=${{ parallel.target }} + + # ============================================================ tracy compile-check + # Anti-bitrot: ensure the Tracy-instrumented build still compiles (-Dno-bin). + - key: tracy-src + call: git/clone 2.0.7 + with: + repository: https://github.com/wolfpld/tracy.git + ref: v0.11.0 + path: tracy + + # tracy-check is a full compiler compile (with Tracy instrumentation), not a + # cheap check — it needs compile-class CPU + a longer timeout + its own + # tool-cache (distinct -Dtracy config, so it can't share build-ci's cache). + - key: tracy-check + use: [code, zig-x64, zig-deps-x64, tracy-src] + # pos-build inputs plus the tracy/ clone this build consumes (-Dtracy=./tracy). + # Can't reuse *pos-build (it lacks tracy/), so the include set is inline. + filter: + - build.zig + - build.zig.zon + - src + - test + - ci + - vendor + - legal_details + - tracy + tool-cache: ci-tracy-check + timeout: 45m + agent: + cpus: 16 + memory: 32gb + run: | + set -euo pipefail + zig build -Dno-bin -Dfuzz -Dtracy=./tracy + + # ============================================================ RELEASE anchor + # ReleaseFast musl/x86_64_v3 — separate compilation from Debug (no artifact + # sharing). Serves release unit tests + the static-link assertion on the + # distribution binary. + - key: build-release-x64 + use: [code, zig-x64, zig-deps-x64] + filter: *pos-build + tool-cache: ci-build-release-x64 + timeout: 45m + agent: + cpus: 32 + memory: 64gb + run: | + set -euo pipefail + # -Droc-test-runner: pre-build the custom-runner test binaries (see build-ci-x64) so the + # ReleaseFast unit partitions cache-hit and only run. + zig build build-ci -Droc-test-runner -Doptimize=ReleaseFast -Dfuzz -Dsystem-afl=false + + # Module unit tests in ReleaseFast via Captain (single task — see unit-debug; not worth + # partitioning). + - key: unit-release + use: [build-release-x64, captain] + filter: *pos-leaf + agent: *test-agent + run: | + set -euo pipefail + captain run roc-unit-release + outputs: + test-results: + - path: tmp/captain/roc-unit-release.json + + # LSP integration specs — bespoke fork-based runner, now in Captain (was part of the old + # plain `unit-misc` non-module run). rwx-v1-json from its parent via the shared reporter. + - key: lsp + use: [build-ci-x64, captain] + filter: *pos-leaf + agent: *test-agent + run: | + set -euo pipefail + captain run roc-lsp + outputs: + test-results: + - path: tmp/captain/roc-lsp.json + + # fx / http / json / watch platform tests — Zig-native (b.addTest), reuse the unit runner's + # rwx-v1-json reporter via -Droc-test-runner. Needs pos-cli inputs (the fx platform fixtures + # import CONTRIBUTING/profiling). Replaces the rest of the old plain `unit-misc` task. + - key: platform-tests + use: [build-ci-x64, captain] + filter: *pos-cli + agent: *test-agent + run: | + set -euo pipefail + captain run roc-platforms + outputs: + test-results: + - path: tmp/captain/roc-platforms.json + + # System packages not on the rwx/base image (verified via probe), pulled into + # their own cached tasks. Leaves `use:` them so the apt layer is shared and + # not re-installed inline on every run. + - key: system-file + run: | + set -euo pipefail + sudo apt-get update && sudo apt-get install -y file + + - key: system-valgrind + run: | + set -euo pipefail + # libc6-dbg is required for Valgrind's function redirections. + sudo apt-get update && sudo apt-get install -y valgrind libc6-dbg + + - key: static-link + use: [build-release-x64, system-file] + filter: *pos-leaf + run: | + set -euo pipefail + file ./zig-out/bin/roc + file ./zig-out/bin/roc | grep "statically linked" + + # ============================================================ VALGRIND anchor + # Special config: ReleaseFast musl/x86_64_v3, -Dstrip=false + larger shared + # memory (build.zig valgrind path). Distinct from the release anchor. + - key: build-valgrind-x64 + use: [code, zig-x64, zig-deps-x64] + filter: *pos-build + tool-cache: ci-build-valgrind-x64 + timeout: 45m + agent: + cpus: 32 + memory: 64gb + run: | + set -euo pipefail + # Build ONLY what the valgrind leaf uses (roc + snapshot + test_runner + the str/int + # platform hosts), not the full default install. The default install also builds + # playground, eval/lsp runners, repro-* and playground tests — all with -Dstrip=false + # debug info that valgrind never runs. (libhost.a for x64musl is build-generated, not + # committed, so build-test-hosts is required for the str/int apps.) + zig build roc build-snapshot-tool build-test-runner build-test-hosts \ + -Dfuzz -Dsystem-afl=false -Doptimize=ReleaseFast -Dcpu=x86_64_v3 \ + -Dtarget=x86_64-linux-musl -Dstrip=false -Dshared-memory-size=268435456 + + - key: valgrind + use: [build-valgrind-x64, system-valgrind] + filter: *pos-leaf + agent: *test-agent + timeout: 30m + run: | + set -euo pipefail + valgrind --version + ./zig-out/bin/test_runner ./zig-out/bin/roc str --mode=valgrind --app=test/str/app.roc + ./zig-out/bin/test_runner ./zig-out/bin/roc int --mode=valgrind --app=test/int/app.roc + # Reuse the snapshot tool the build-valgrind-x64 anchor already built (inherited via + # use:) instead of rebuilding it from scratch here: valgrind_snapshots.sh rebuilds with + # the default target, which misses the anchor's -Dcpu/-Dtarget cache key (~406s wasted). + SNAPSHOT_BIN=./zig-out/bin/snapshot ./ci/valgrind_snapshots.sh + + # ============================================================ arm64 leg + # arch is run-level, so the arm64 matrix is an embedded run with its own base. + - key: arm64 + call: ${{ run.dir }}/ci-arm64.yml + init: + commit-sha: ${{ init.commit-sha }} diff --git a/build.zig b/build.zig index 108240aa10e..95de8c32d1c 100644 --- a/build.zig +++ b/build.zig @@ -122,6 +122,26 @@ fn isNativeishOrMusl(target: ResolvedTarget) bool { (target.query.isNativeAbi() or target.result.abi.isMusl()); } +/// Target for build-time host tools (builtin_compiler, the ci/ check exes, …). It uses the +/// host's arch/OS/ABI but pins a FIXED baseline CPU instead of the auto-detected native CPU +/// that `b.graph.host` carries. CI runners are ephemeral and vary by CPU model (e.g. AMD +/// EPYC 9R45 on the 32c anchor vs 9R14 on a 2c leaf); native targeting bakes the runner's CPU +/// into Zig's compile cache key, so a tool built on one agent never cache-hits on another. +/// That miss cascades: builtin_compiler → Builtin.bin → compiled_builtins → every artifact +/// embedding the builtins recompiles from scratch. A baseline CPU is a subset of every agent's +/// CPU (so the tool still runs everywhere) and the tools' output is CPU-independent, so pinning +/// it only stabilizes the cache key. Using a canonical os version range (via resolveTargetQuery) +/// also avoids cross-agent kernel-version differences leaking into the key. +fn hostToolsTarget(b: *std.Build) ResolvedTarget { + const host = b.graph.host.result; + return b.resolveTargetQuery(.{ + .cpu_arch = host.cpu.arch, + .os_tag = host.os.tag, + .abi = host.abi, + .cpu_model = .baseline, + }); +} + const NativeSharedArchiveTarget = struct { resolved: ResolvedTarget, roc_name: []const u8, @@ -1624,12 +1644,17 @@ fn createAndRunBuiltinCompiler( flag_enable_tracy: ?[]const u8, roc_files: []const []const u8, ) BuiltinCompilerRun { - // Build and run the compiler + // Build and run the compiler. Runs at build time on the *host*, but pin a baseline-CPU + // host target (not native b.graph.host) so the cache key is identical across CI runners + // with different CPUs — otherwise this exe (and the builtins it bakes) recompiles per + // agent and cascades into recompiling everything that embeds compiled_builtins. See + // hostToolsTarget. + const host_tools = hostToolsTarget(b); const builtin_compiler_exe = b.addExecutable(.{ .name = "builtin_compiler", .root_module = b.createModule(.{ .root_source_file = b.path("src/build/builtin_compiler/main.zig"), - .target = b.graph.host, // this runs at build time on the *host* machine! + .target = host_tools, // Kept Debug deliberately: this exe publishes + serializes the // builtin CheckedModuleArtifact (a few seconds of Debug run), but building // it ReleaseFast would optimize the whole check/eval closure (incl. the @@ -1643,7 +1668,7 @@ fn createAndRunBuiltinCompiler( .link_libc = true, }), }); - configureBackend(builtin_compiler_exe, b.graph.host); + configureBackend(builtin_compiler_exe, host_tools); // Add only the minimal modules needed for parsing/checking builtin_compiler_exe.root_module.addImport("base", roc_modules.base); @@ -1664,7 +1689,7 @@ fn createAndRunBuiltinCompiler( // is added here as a standalone module rooted at compile_time_finalization.zig. const comptime_finalizer_module = b.createModule(.{ .root_source_file = b.path("src/eval/compile_time_finalization.zig"), - .target = b.graph.host, + .target = host_tools, .optimize = .Debug, .link_libc = true, .imports = &.{ @@ -1686,7 +1711,7 @@ fn createAndRunBuiltinCompiler( builtin_compiler_exe.root_module.addImport("comptime_finalizer", comptime_finalizer_module); // Add tracy support (required by parse/can/check modules) - add_tracy(b, roc_modules.build_options, builtin_compiler_exe, b.graph.host, false, flag_enable_tracy); + add_tracy(b, roc_modules.build_options, builtin_compiler_exe, host_tools, false, flag_enable_tracy); // Run the builtin compiler to generate .bin files in zig-out/builtins/ const run_builtin_compiler = b.addRunArtifact(builtin_compiler_exe); @@ -2224,6 +2249,37 @@ fn absoluteBuildPath(b: *std.Build, path: []const u8) []const u8 { return b.pathFromRoot(path); } +/// Tag a standalone plain-unit test's run step for the rwx-v1-json reporter (suite name + +/// file, and the output dir when set), mirroring what createModuleTests does for the 28 +/// modules. No-op when the custom runner is off. Lets these tests join the Captain suite. +fn tagCaptainRun( + b: *std.Build, + run_step: *std.Build.Step.Run, + enabled: bool, + rwx_out: []const u8, + suite_name: []const u8, + suite_file: []const u8, +) void { + if (!enabled) return; + run_step.addArgs(&.{ + b.fmt("--roc-test-suite={s}", .{suite_name}), + b.fmt("--roc-test-file={s}", .{suite_file}), + }); + if (rwx_out.len != 0) run_step.addArg(b.fmt("--roc-test-rwx-out={s}", .{rwx_out})); +} + +/// Captain partition membership: true if `suite_file` is in the comma-separated `csv` +/// (an empty csv means "no subset requested" → all files run). Used by the module loop and +/// the standalone plain-unit tests so the same -Droc-test-files selection covers both. +fn rocTestFileSelected(csv: []const u8, suite_file: []const u8) bool { + if (csv.len == 0) return true; + var it = std.mem.tokenizeScalar(u8, csv, ','); + while (it.next()) |entry| { + if (std.mem.eql(u8, std.mem.trim(u8, entry, " \t"), suite_file)) return true; + } + return false; +} + pub fn build(b: *std.Build) void { configureZigCacheEnvironment(b); @@ -2329,6 +2385,26 @@ pub fn build(b: *std.Build) void { const eval_no_fork = b.option(bool, "eval-no-fork", "Run eval tests in-process instead of through fork isolation") orelse false; const eval_time_worker = b.option(bool, "eval-time-worker", "Print eval worker startup timing instrumentation") orelse false; const glue_release_tag = b.option([]const u8, "glue-release-tag", "Nightly release tag used in generated glue package URLs"); + // Opt-in: build module unit tests against roc's custom partitioned runner + // (test/zig_test_runner.zig) which adds an rwx-v1-json reporter + Captain-compatible + // selection. Default off → Zig's default runner (no behavior change for local `zig build`). + // Turned on in CI so RWX Captain gets per-test results, retry, and partitioning. + const roc_test_runner = b.option(bool, "roc-test-runner", "Build Zig unit tests against roc's custom partitioned runner + rwx-v1-json reporter (CI/Captain)") orelse false; + // rwx-v1-json reporter output directory. Empty = no reporter (parity mode). Each module + // writes /zig-tests-.json; Captain globs+merges them. Requires -Droc-test-runner. + const roc_test_rwx_out = b.option([]const u8, "roc-test-rwx-out", "Directory for the rwx-v1-json test reporter output (empty = disabled)") orelse ""; + // Captain partitioning: comma-separated subset of module root files (matching the + // partition globs / each test's location.file) to run this partition. Empty = all. + // Captain expands {{ testFiles }} into this option per RWX_PARALLEL_INDEX/TOTAL. + const roc_test_files_csv = b.option([]const u8, "roc-test-files", "Captain partition: comma-separated module root files to run (empty = all)") orelse ""; + // run-test-zig scope split (so Captain-partitioned runs report counts that match what ran): + // default → module tests + non-module standalone tests (LSP integration, + // snapshot-tool, builtin-doc, lir/trmc, cli-unit, watch, platform). + // -Droc-test-files= → ONLY the listed module tests (non-module excluded) — the + // partition path; each partition's count == Captain's count. + // -Droc-test-nonmodule-only → ONLY the non-module standalone tests (run once, unpartitioned). + const roc_test_nonmodule_only = b.option(bool, "roc-test-nonmodule-only", "run-test-zig: run only the non-module standalone tests (LSP/snapshot-tool/platform/etc.)") orelse false; + const roc_test_modules_only = b.option(bool, "roc-test-modules-only", "run-test-zig: run only the 28 module unit tests (exclude the non-module standalone tests)") orelse false; if (shared_memory_size) |size| { if (size == 0) { std.log.err("-Dshared-memory-size must be greater than 0", .{}); @@ -2525,7 +2601,7 @@ pub fn build(b: *std.Build) void { .name = "zig_lints", .root_module = b.createModule(.{ .root_source_file = b.path("ci/zig_lints.zig"), - .target = b.graph.host, + .target = hostToolsTarget(b), .optimize = .Debug, }), }); @@ -2533,7 +2609,7 @@ pub fn build(b: *std.Build) void { .name = "tidy", .root_module = b.createModule(.{ .root_source_file = b.path("ci/tidy.zig"), - .target = b.graph.host, + .target = hostToolsTarget(b), .optimize = .Debug, }), }); @@ -2541,7 +2617,7 @@ pub fn build(b: *std.Build) void { .name = "check_test_wiring", .root_module = b.createModule(.{ .root_source_file = b.path("ci/check_test_wiring.zig"), - .target = b.graph.host, + .target = hostToolsTarget(b), .optimize = .Debug, }), }); @@ -2549,7 +2625,7 @@ pub fn build(b: *std.Build) void { .name = "minici", .root_module = b.createModule(.{ .root_source_file = b.path("src/build/minici.zig"), - .target = b.graph.host, + .target = hostToolsTarget(b), .optimize = .Debug, }), }); @@ -2755,7 +2831,12 @@ pub fn build(b: *std.Build) void { .link_libc = true, }), }); - b.installArtifact(test_runner_exe); + const test_runner_install = b.addInstallArtifact(test_runner_exe, .{}); + b.getInstallStep().dependOn(&test_runner_install.step); + // Named step so CI can build just the test_runner (e.g. the valgrind anchor, which only + // needs roc + snapshot + test_runner + test hosts, not the full default install). + const build_test_runner_step = b.step("build-test-runner", "Build the CLI/valgrind test_runner"); + build_test_runner_step.dependOn(&test_runner_install.step); // Store CLI runner step reference so we can add glue host dependency later. var run_cli_test_step: ?*std.Build.Step = null; @@ -3478,7 +3559,7 @@ pub fn build(b: *std.Build) void { // Build playground integration tests - now enabled for all optimization modes. const playground_test_install = blk: { - const playground_test_optimize: std.builtin.OptimizeMode = if (optimize == .Debug) .ReleaseSafe else optimize; + const playground_test_optimize: std.builtin.OptimizeMode = if (optimize == .Debug) .ReleaseSmall else optimize; const playground_wasm_target = b.resolveTargetQuery(.{ .cpu_arch = .wasm32, .os_tag = .freestanding, @@ -3924,7 +4005,7 @@ pub fn build(b: *std.Build) void { build_test_zig_step.dependOn(&install_stack_overflow_test_helper.step); // Create and add module tests - const module_tests_result = roc_modules.createModuleTests(b, target, optimize, zstd, test_filters); + const module_tests_result = roc_modules.createModuleTests(b, target, optimize, zstd, test_filters, roc_test_runner, roc_test_rwx_out); const tests_summary = TestsSummaryStep.create(b, test_filters, module_tests_result.forced_passes); if (builtin.os.tag == .windows) { // Zig 0.16's Windows test runner IPC can time out while many Roc test @@ -3983,6 +4064,23 @@ pub fn build(b: *std.Build) void { build_test_zig_step.dependOn(&guarded_list_violation_exe.step); run_test_zig_step.dependOn(run_guarded_list_violations_step); + // Captain partitioning: when -Droc-test-files names a subset of module root files, only + // *run* those modules in this partition. Every module is still built (the anchor needs + // them); we just gate which run steps the run-test-zig aggregate depends on. + const roc_test_files_selected = roc_test_files_csv.len != 0; + // Scope split (see the -Droc-test-nonmodule-only option). A partition (-Droc-test-files) + // is implicitly module-only so its reported count matches what ran. + const include_modules = !roc_test_nonmodule_only; + const include_non_module = roc_test_nonmodule_only or + (!roc_test_files_selected and !roc_test_modules_only); + // Custom runner for the standalone plain-unit tests (snapshot-tool, builtin-doc, lir-inline, + // trmc-lir, cli) — same wiring createModuleTests applies to the 28 modules, so these join the + // Captain suite too. null → Zig's default runner (opt-out path unchanged). + const captain_runner: ?Step.Compile.TestRunner = if (roc_test_runner) + .{ .path = b.path("test/zig_test_runner.zig"), .mode = .server } + else + null; + for (module_tests_result.tests) |module_test| { // Add compiled builtins to tests that canonicalize ordinary modules. if (std.mem.eql(u8, module_test.test_step.name, "can") or std.mem.eql(u8, module_test.test_step.name, "check") or std.mem.eql(u8, module_test.test_step.name, "eval") or std.mem.eql(u8, module_test.test_step.name, "compile") or std.mem.eql(u8, module_test.test_step.name, "lsp_unit") or std.mem.eql(u8, module_test.test_step.name, "lsp_integration")) { @@ -4074,6 +4172,17 @@ pub fn build(b: *std.Build) void { // Create run step that accepts command line args (including --test-filter) const individual_run = b.addRunArtifact(module_test.test_step); + if (roc_test_runner) { + // Mirror the suite tagging applied to the aggregate run step (in createModuleTests) + // so a standalone `run-test-zig-module-` also emits a tagged rwx-v1-json file. + individual_run.addArgs(&.{ + b.fmt("--roc-test-suite={s}", .{module_test.suite_name}), + b.fmt("--roc-test-file={s}", .{module_test.suite_file}), + }); + if (roc_test_rwx_out.len != 0) { + individual_run.addArg(b.fmt("--roc-test-rwx-out={s}", .{roc_test_rwx_out})); + } + } if (run_args.len != 0) { individual_run.addArgs(run_args); } @@ -4084,7 +4193,13 @@ pub fn build(b: *std.Build) void { b.default_step.dependOn(&module_test.test_step.step); build_test_zig_step.dependOn(&module_test.test_step.step); - tests_summary.addRun(&module_test.run_step.step); + + // Partition gate: only run this module in the aggregate when it's in the selected + // subset (or no subset was requested). csv membership matched on the repo-relative + // root file (== each test's reported location.file, what Captain balances over). + if (include_modules and rocTestFileSelected(roc_test_files_csv, module_test.suite_file)) { + tests_summary.addRun(&module_test.run_step.step); + } } const lsp_integration_test_harness_module = createTestHarnessModule(b, roc_modules); @@ -4123,7 +4238,7 @@ pub fn build(b: *std.Build) void { b.default_step.dependOn(&lsp_integration_runner_exe.step); build_test_zig_step.dependOn(&lsp_integration_runner_exe.step); - run_test_zig_step.dependOn(&run_lsp_integration.step); + if (include_non_module) run_test_zig_step.dependOn(&run_lsp_integration.step); // Add snapshot tool test const enable_snapshot_tests = b.option(bool, "snapshot-tests", "Enable snapshot tests") orelse true; @@ -4137,6 +4252,7 @@ pub fn build(b: *std.Build) void { .link_libc = true, }), .filters = test_filters, + .test_runner = captain_runner, }); roc_modules.addAll(snapshot_test); snapshot_test.root_module.addImport("compiled_builtins", compiled_builtins_module); @@ -4168,7 +4284,10 @@ pub fn build(b: *std.Build) void { if (run_args.len != 0) { run_snapshot_test.addArgs(run_args); } - tests_summary.addRun(&run_snapshot_test.step); + tagCaptainRun(b, run_snapshot_test, roc_test_runner, roc_test_rwx_out, "snapshot_tool", "src/snapshot_tool/main.zig"); + if (include_modules and rocTestFileSelected(roc_test_files_csv, "src/snapshot_tool/main.zig")) { + tests_summary.addRun(&run_snapshot_test.step); + } const run_snapshot_tool_test_step = b.step( "run-test-zig-snapshot-tool", @@ -4192,6 +4311,7 @@ pub fn build(b: *std.Build) void { .link_libc = true, }), .filters = test_filters, + .test_runner = captain_runner, }); roc_modules.addAll(builtin_doc_test); builtin_doc_test.root_module.addImport("compiled_builtins", compiled_builtins_module); @@ -4220,7 +4340,10 @@ pub fn build(b: *std.Build) void { run_builtin_doc_test.addArgs(run_args); } - tests_summary.addRun(&run_builtin_doc_test.step); + tagCaptainRun(b, run_builtin_doc_test, roc_test_runner, roc_test_rwx_out, "builtin_doc", "src/eval/test/builtin_doc_tests.zig"); + if (include_modules and rocTestFileSelected(roc_test_files_csv, "src/eval/test/builtin_doc_tests.zig")) { + tests_summary.addRun(&run_builtin_doc_test.step); + } const run_builtin_doc_test_step = b.step( "run-test-zig-builtin-doc", @@ -4238,6 +4361,7 @@ pub fn build(b: *std.Build) void { .link_libc = true, }), .filters = test_filters, + .test_runner = captain_runner, }); roc_modules.addAll(lir_inline_test); lir_inline_test.root_module.addImport("compiled_builtins", compiled_builtins_module); @@ -4266,7 +4390,10 @@ pub fn build(b: *std.Build) void { run_lir_inline_test.addArgs(run_args); } - tests_summary.addRun(&run_lir_inline_test.step); + tagCaptainRun(b, run_lir_inline_test, roc_test_runner, roc_test_rwx_out, "lir_inline", "src/eval/test/lir_inline_test.zig"); + if (include_modules and rocTestFileSelected(roc_test_files_csv, "src/eval/test/lir_inline_test.zig")) { + tests_summary.addRun(&run_lir_inline_test.step); + } const run_lir_inline_test_step = b.step( "run-test-zig-lir-inline", @@ -4283,6 +4410,7 @@ pub fn build(b: *std.Build) void { .link_libc = true, }), .filters = test_filters, + .test_runner = captain_runner, }); roc_modules.addAll(trmc_lir_test); trmc_lir_test.root_module.addImport("compiled_builtins", compiled_builtins_module); @@ -4311,7 +4439,10 @@ pub fn build(b: *std.Build) void { run_trmc_lir_test.addArgs(run_args); } - tests_summary.addRun(&run_trmc_lir_test.step); + tagCaptainRun(b, run_trmc_lir_test, roc_test_runner, roc_test_rwx_out, "trmc_lir", "src/eval/test/trmc_lir_test.zig"); + if (include_modules and rocTestFileSelected(roc_test_files_csv, "src/eval/test/trmc_lir_test.zig")) { + tests_summary.addRun(&run_trmc_lir_test.step); + } const run_trmc_lir_test_step = b.step( "run-test-zig-trmc-lir", @@ -4331,6 +4462,7 @@ pub fn build(b: *std.Build) void { .link_libc = true, }), .filters = test_filters, + .test_runner = captain_runner, }); roc_modules.addAll(cli_test); linkWatchPlatformLibs(cli_test, target); @@ -4360,7 +4492,10 @@ pub fn build(b: *std.Build) void { if (run_args.len != 0) { run_cli_test.addArgs(run_args); } - tests_summary.addRun(&run_cli_test.step); + tagCaptainRun(b, run_cli_test, roc_test_runner, roc_test_rwx_out, "cli", "src/cli/main.zig"); + if (include_modules and rocTestFileSelected(roc_test_files_csv, "src/cli/main.zig")) { + tests_summary.addRun(&run_cli_test.step); + } const run_cli_main_test_step = b.step( "run-test-zig-cli-main", @@ -4381,6 +4516,7 @@ pub fn build(b: *std.Build) void { .link_libc = true, }), .filters = test_filters, + .test_runner = captain_runner, }); roc_modules.addAll(watch_test); add_tracy(b, roc_modules.build_options, watch_test, target, false, flag_enable_tracy); @@ -4394,7 +4530,10 @@ pub fn build(b: *std.Build) void { if (run_args.len != 0) { run_watch_test.addArgs(run_args); } - tests_summary.addRun(&run_watch_test.step); + // Suite name "watch_cli" (not "watch") so its ids don't collide with the watch *module* + // test that already runs in Captain (same src/watch/watch.zig source → same test names). + tagCaptainRun(b, run_watch_test, roc_test_runner, roc_test_rwx_out, "watch_cli", "src/watch/watch.zig"); + if (include_non_module) tests_summary.addRun(&run_watch_test.step); const run_watch_cli_test_step = b.step( "run-test-zig-watch-cli", @@ -4805,6 +4944,7 @@ pub fn build(b: *std.Build) void { .link_libc = true, }), .filters = test_filters, + .test_runner = captain_runner, }); const run_fx_platform_test = b.addRunArtifact(fx_platform_test); @@ -4817,7 +4957,8 @@ pub fn build(b: *std.Build) void { run_fx_platform_test.step.dependOn(final_static_data_platform_step); // Ensure roc binary is built before running the test (tests invoke roc CLI) run_fx_platform_test.step.dependOn(build_roc_step); - tests_summary.addRun(&run_fx_platform_test.step); + tagCaptainRun(b, run_fx_platform_test, roc_test_runner, roc_test_rwx_out, "fx_platform", "src/cli/test/fx_platform_test.zig"); + if (include_non_module) tests_summary.addRun(&run_fx_platform_test.step); const run_fx_platform_zig_test_step = b.step( "run-test-zig-fx-platform", @@ -4900,6 +5041,7 @@ pub fn build(b: *std.Build) void { .link_libc = true, }), .filters = test_filters, + .test_runner = captain_runner, }); const run_http_header_decoder_platform_test = b.addRunArtifact(http_header_decoder_platform_test); @@ -4911,6 +5053,8 @@ pub fn build(b: *std.Build) void { run_http_header_decoder_platform_test.step.dependOn(final_http_host_step); run_http_header_decoder_platform_test.step.dependOn(&install_http_app.step); run_http_header_decoder_platform_test.step.dependOn(build_roc_step); + // The dedicated run-test-zig-http-header-decoder-platform step is what Captain invokes. + tagCaptainRun(b, run_http_header_decoder_platform_test, roc_test_runner, roc_test_rwx_out, "http_platform", "src/cli/test/http_header_decoder_platform_test.zig"); const run_http_header_decoder_platform_test_for_summary = b.addRunArtifact(http_header_decoder_platform_test); run_http_header_decoder_platform_test_for_summary.setEnvironmentVariable("ROC_HTTP_HEADER_DECODER_PREBUILT_EXE", http_app_installed_path); @@ -4920,7 +5064,7 @@ pub fn build(b: *std.Build) void { run_http_header_decoder_platform_test_for_summary.step.dependOn(final_http_host_step); run_http_header_decoder_platform_test_for_summary.step.dependOn(&install_http_app.step); run_http_header_decoder_platform_test_for_summary.step.dependOn(build_roc_step); - tests_summary.addRun(&run_http_header_decoder_platform_test_for_summary.step); + if (include_non_module) tests_summary.addRun(&run_http_header_decoder_platform_test_for_summary.step); const run_http_header_decoder_platform_zig_test_step = b.step( "run-test-zig-http-header-decoder-platform", @@ -5017,6 +5161,7 @@ pub fn build(b: *std.Build) void { .link_libc = true, }), .filters = test_filters, + .test_runner = captain_runner, }); const run_json_decoder_platform_test = b.addRunArtifact(json_decoder_platform_test); @@ -5032,6 +5177,8 @@ pub fn build(b: *std.Build) void { run_json_decoder_platform_test.step.dependOn(&install_json_camel_app.step); run_json_decoder_platform_test.step.dependOn(&install_json_camel_direct_app.step); run_json_decoder_platform_test.step.dependOn(build_roc_step); + // The dedicated run-test-zig-json-decoder-platform step is what Captain invokes. + tagCaptainRun(b, run_json_decoder_platform_test, roc_test_runner, roc_test_rwx_out, "json_platform", "src/cli/test/json_decoder_platform_test.zig"); const run_json_decoder_platform_test_for_summary = b.addRunArtifact(json_decoder_platform_test); run_json_decoder_platform_test_for_summary.setEnvironmentVariable("ROC_JSON_DECODER_PREBUILT_EXE", json_app_installed_path); @@ -5045,7 +5192,7 @@ pub fn build(b: *std.Build) void { run_json_decoder_platform_test_for_summary.step.dependOn(&install_json_camel_app.step); run_json_decoder_platform_test_for_summary.step.dependOn(&install_json_camel_direct_app.step); run_json_decoder_platform_test_for_summary.step.dependOn(build_roc_step); - tests_summary.addRun(&run_json_decoder_platform_test_for_summary.step); + if (include_non_module) tests_summary.addRun(&run_json_decoder_platform_test_for_summary.step); const run_json_decoder_platform_zig_test_step = b.step( "run-test-zig-json-decoder-platform", diff --git a/src/build/modules.zig b/src/build/modules.zig index bb91cd8824c..ad6b9dc6042 100644 --- a/src/build/modules.zig +++ b/src/build/modules.zig @@ -272,6 +272,12 @@ fn targetMatchesHost(target: ResolvedTarget) bool { pub const ModuleTest = struct { test_step: *Step.Compile, run_step: *Step.Run, + /// Module name (e.g. "collections"); used by the rwx-v1-json reporter as the + /// per-binary output filename and the test-id prefix. + suite_name: []const u8, + /// Repo-relative path of the module's root source file (e.g. "src/collections/mod.zig"); + /// used as the rwx-v1-json `location.file` and as the unit Captain partitions over. + suite_file: []const u8, }; /// Bundles the per-module test steps with accounting for forced passes (aggregators + @@ -697,6 +703,14 @@ pub const RocModules = struct { optimize: OptimizeMode, zstd: ?*Dependency, test_filters: []const []const u8, + // When true, build each module test against roc's custom partitioned runner + // (test/zig_test_runner.zig) instead of Zig's default runner, and tag each run + // step with its suite name + file so the runner's rwx-v1-json reporter can attribute + // results. Gated by the -Droc-test-runner build option (CI/Captain only). + roc_test_runner: bool, + // Directory for the rwx-v1-json reporter output (empty = reporter disabled). Passed to + // each module's run step as --roc-test-rwx-out so the runner writes per-module results. + roc_test_rwx_out: []const u8, ) ModuleTestsResult { const test_configs = [_]ModuleType{ .collections, @@ -742,6 +756,14 @@ pub const RocModules = struct { const wrappers = wrapperTestCount(b, module_type, module); forced_passes += wrappers; } + // Roc's partitioned runner keeps Zig's server-mode protocol + leak detection + // but adds the rwx-v1-json reporter and Captain-compatible selection. Opt-in: + // when off, module tests use Zig's default runner (no behavior change). + const test_runner: ?Step.Compile.TestRunner = if (roc_test_runner) + .{ .path = b.path("test/zig_test_runner.zig"), .mode = .server } + else + null; + const test_step = b.addTest(.{ .name = b.fmt("{s}", .{@tagName(module_type)}), .root_module = b.createModule(.{ @@ -757,6 +779,7 @@ pub const RocModules = struct { .link_libc = true, }), .filters = filter_injection.filters, + .test_runner = test_runner, }); // Watch module needs Core Foundation and FSEvents on macOS (only when not cross-compiling) @@ -778,9 +801,34 @@ pub const RocModules = struct { const run_step = b.addRunArtifact(test_step); + // Repo-relative path of the module's root source file. All 28 module configs + // are created via b.path("src/.../mod.zig") (a .src_path LazyPath); fall back to + // the module name for any non-path root (none today). + const suite_name = @tagName(module_type); + const suite_file: []const u8 = switch (module.root_source_file.?) { + .src_path => |sp| sp.sub_path, + else => suite_name, + }; + + // Tag the run with its suite identity so the custom runner's rwx-v1-json reporter + // can name its output file (zig-tests-.json), set each test's location.file, + // and prefix test ids for global uniqueness. Only valid for the custom runner — + // Zig's default runner rejects unknown args, so gate on roc_test_runner. + if (roc_test_runner) { + run_step.addArgs(&.{ + b.fmt("--roc-test-suite={s}", .{suite_name}), + b.fmt("--roc-test-file={s}", .{suite_file}), + }); + if (roc_test_rwx_out.len != 0) { + run_step.addArg(b.fmt("--roc-test-rwx-out={s}", .{roc_test_rwx_out})); + } + } + tests[i] = .{ .test_step = test_step, .run_step = run_step, + .suite_name = suite_name, + .suite_file = suite_file, }; } diff --git a/src/build/test_harness.zig b/src/build/test_harness.zig index 6eb9fe44035..662b8100a44 100644 --- a/src/build/test_harness.zig +++ b/src/build/test_harness.zig @@ -520,6 +520,18 @@ pub const StandardArgs = struct { /// result to stdout, and loops until stdin EOFs. Used by the Windows /// Child-based executor to amortize process-boot cost across many tests. worker_stream: bool = false, + /// rwx-v1-json reporter output directory (Captain). Null = reporter disabled. + /// Set by `--roc-test-rwx-out=`. + rwx_out: ?[]const u8 = null, + /// Suite name used for rwx-v1-json ids (`::`) and the output filename. + /// Set by `--roc-test-suite=`. + rwx_suite: ?[]const u8 = null, + /// Reported `location.file` for every test in the rwx-v1-json output. + /// Set by `--roc-test-file=`. + rwx_file: ?[]const u8 = null, + /// Path to a v1.TestResults JSON whose test ids restrict this run (Captain targeted + /// retry via `{{ jsonFilePath }}`). Set by `--roc-test-only-json=`. + rwx_only_json: ?[]const u8 = null, /// Remaining positional args (runner-specific) positional: []const []const u8 = &.{}, }; @@ -714,6 +726,162 @@ fn appendJsonString(out: *std.ArrayList(u8), allocator: Allocator, value: []cons try out.append(allocator, '"'); } +/// rwx-v1-json schema URL (Captain's native, framework-agnostic test-results schema). +pub const rwx_schema_url = "https://raw.githubusercontent.com/rwx-research/test-results-schema/main/v1.json"; + +const max_only_json_bytes: usize = 64 * 1024 * 1024; + +/// One test outcome for the rwx-v1-json reporter. `status_kind` is the already-mapped rwx +/// status string — one of "successful", "failed", "skipped", "timedOut". The caller maps its +/// own (richer) status enum to this (e.g. crash→"failed", timeout→"timedOut", leak→"failed"). +pub const RwxRecord = struct { + name: []const u8, + status_kind: []const u8, + duration_ns: u64, + /// Optional failure detail rendered inline in RWX; emitted only for non-pass/skip statuses. + message: ?[]const u8 = null, +}; + +/// rwx-v1-json id for a test: "::". Globally unique across suites (a bare test +/// name is only unique within one runner). Caller owns the result. +pub fn rwxTestId(allocator: Allocator, suite: []const u8, name: []const u8) Allocator.Error![]u8 { + return std.fmt.allocPrint(allocator, "{s}::{s}", .{ suite, name }); +} + +fn appendRwxSummaryField(out: *std.ArrayList(u8), allocator: Allocator, name: []const u8, value: usize) !void { + try out.appendSlice(allocator, ",\n "); + try appendJsonString(out, allocator, name); + try out.appendSlice(allocator, ": "); + try appendU64(out, allocator, @intCast(value)); +} + +/// Write `records` as `/.json` in rwx-v1-json (Captain's native schema) so a +/// bespoke fork-based runner's results render inline in RWX and feed targeted retry / flake +/// detection. Mirrors the unit runner's reporter (test/zig_test_runner.zig:423-519). id = +/// "::", location.file = `file`. Failure messages are included inline. +pub fn writeRwxResults( + allocator: Allocator, + io: std.Io, + out_dir: []const u8, + suite: []const u8, + file: []const u8, + records: []const RwxRecord, +) !void { + var n_failed: usize = 0; + var n_skipped: usize = 0; + var n_successful: usize = 0; + var n_timed_out: usize = 0; + for (records) |rec| { + if (std.mem.eql(u8, rec.status_kind, "skipped")) { + n_skipped += 1; + } else if (std.mem.eql(u8, rec.status_kind, "timedOut")) { + n_timed_out += 1; + } else if (std.mem.eql(u8, rec.status_kind, "successful")) { + n_successful += 1; + } else { + n_failed += 1; + } + } + const any_failure = (n_failed + n_timed_out) > 0; + + var out = std.ArrayList(u8).empty; + defer out.deinit(allocator); + + try out.appendSlice(allocator, "{\n \"$schema\": "); + try appendJsonString(&out, allocator, rwx_schema_url); + try out.appendSlice(allocator, ",\n \"framework\": { \"language\": \"other\", \"kind\": \"other\", \"providedLanguage\": \"Zig\", \"providedKind\": \"roc\" }"); + try out.appendSlice(allocator, ",\n \"summary\": {\n \"status\": { \"kind\": "); + try appendJsonString(&out, allocator, if (any_failure) "failed" else "successful"); + try out.appendSlice(allocator, " }"); + try appendRwxSummaryField(&out, allocator, "tests", records.len); + try appendRwxSummaryField(&out, allocator, "otherErrors", 0); + try appendRwxSummaryField(&out, allocator, "retries", 0); + try appendRwxSummaryField(&out, allocator, "canceled", 0); + try appendRwxSummaryField(&out, allocator, "failed", n_failed); + try appendRwxSummaryField(&out, allocator, "pended", 0); + try appendRwxSummaryField(&out, allocator, "quarantined", 0); + try appendRwxSummaryField(&out, allocator, "skipped", n_skipped); + try appendRwxSummaryField(&out, allocator, "successful", n_successful); + try appendRwxSummaryField(&out, allocator, "timedOut", n_timed_out); + try appendRwxSummaryField(&out, allocator, "todo", 0); + try out.appendSlice(allocator, "\n },\n \"tests\": ["); + + for (records, 0..) |rec, idx| { + if (idx > 0) try out.append(allocator, ','); + try out.appendSlice(allocator, "\n { \"id\": "); + const id = try rwxTestId(allocator, suite, rec.name); + defer allocator.free(id); + try appendJsonString(&out, allocator, id); + try out.appendSlice(allocator, ", \"name\": "); + try appendJsonString(&out, allocator, rec.name); + try out.appendSlice(allocator, ", \"location\": { \"file\": "); + try appendJsonString(&out, allocator, file); + try out.appendSlice(allocator, " }, \"attempt\": { \"durationInNanoseconds\": "); + try appendU64(&out, allocator, rec.duration_ns); + try out.appendSlice(allocator, ", \"status\": { \"kind\": "); + try appendJsonString(&out, allocator, rec.status_kind); + const is_failure = !std.mem.eql(u8, rec.status_kind, "successful") and + !std.mem.eql(u8, rec.status_kind, "skipped"); + if (is_failure) { + if (rec.message) |msg| { + if (msg.len > 0) { + try out.appendSlice(allocator, ", \"message\": "); + try appendJsonString(&out, allocator, msg); + } + } + } + try out.appendSlice(allocator, " } } }"); + } + try out.appendSlice(allocator, "\n ]\n}\n"); + + try std.Io.Dir.cwd().createDirPath(io, out_dir); + const path = try std.fmt.allocPrint(allocator, "{s}/{s}.json", .{ out_dir, suite }); + defer allocator.free(path); + var file_handle = try std.Io.Dir.cwd().createFile(io, path, .{}); + defer file_handle.close(io); + try file_handle.writeStreamingAll(io, out.items); +} + +/// Set of test ids parsed from a v1.TestResults JSON (Captain's `{{ jsonFilePath }}`), used to +/// restrict a run to just the failing tests for targeted retry. Caller calls `deinit()`. +pub const OnlyJsonIds = struct { + parsed: std.json.Parsed(Doc), + set: std.StringHashMap(void), + + const Doc = struct { + tests: []const struct { + id: ?[]const u8 = null, + } = &.{}, + }; + + pub fn contains(self: *const OnlyJsonIds, id: []const u8) bool { + return self.set.contains(id); + } + + pub fn deinit(self: *OnlyJsonIds) void { + self.set.deinit(); + self.parsed.deinit(); + } +}; + +/// Load the set of test ids to re-run from a v1.TestResults JSON file. Returns null on any +/// error (missing / unreadable / malformed) — callers then run the full suite. +pub fn loadOnlyJsonIds(allocator: Allocator, io: std.Io, path: []const u8) ?OnlyJsonIds { + const contents = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(max_only_json_bytes)) catch return null; + defer allocator.free(contents); + const parsed = std.json.parseFromSlice( + OnlyJsonIds.Doc, + allocator, + contents, + .{ .ignore_unknown_fields = true, .allocate = .alloc_always }, + ) catch return null; + var set = std.StringHashMap(void).init(allocator); + for (parsed.value.tests) |t| { + if (t.id) |id| set.put(id, {}) catch {}; + } + return .{ .parsed = parsed, .set = set }; +} + /// Parse standard harness flags from an argv-style slice. pub fn parseStandardArgsFromSlice(raw_args: []const []const u8, allocator: Allocator) !StandardArgs { var filters: std.ArrayListUnmanaged([]const u8) = .empty; @@ -765,6 +933,14 @@ pub fn parseStandardArgsFromSlice(raw_args: []const []const u8, allocator: Alloc } } else if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) { args.help_requested = true; + } else if (std.mem.startsWith(u8, arg, "--roc-test-rwx-out=")) { + args.rwx_out = arg["--roc-test-rwx-out=".len..]; + } else if (std.mem.startsWith(u8, arg, "--roc-test-suite=")) { + args.rwx_suite = arg["--roc-test-suite=".len..]; + } else if (std.mem.startsWith(u8, arg, "--roc-test-file=")) { + args.rwx_file = arg["--roc-test-file=".len..]; + } else if (std.mem.startsWith(u8, arg, "--roc-test-only-json=")) { + args.rwx_only_json = arg["--roc-test-only-json=".len..]; } else if (!std.mem.startsWith(u8, arg, "--")) { try positional.append(allocator, arg); } diff --git a/src/cli/test/parallel_cli_runner.zig b/src/cli/test/parallel_cli_runner.zig index a677d4f8f25..ca1b3b7df93 100644 --- a/src/cli/test/parallel_cli_runner.zig +++ b/src/cli/test/parallel_cli_runner.zig @@ -557,7 +557,10 @@ fn appendPlatformSpecs( for (io_specs) |spec| { if (skipIoSpecOnHost(spec)) continue; - const name = try fmtTestName(allocator, spec.roc_file, opt); + // Include the spec description: a single roc_file can have multiple io_specs + // (e.g. test/fx/stdin_while_uaf.roc), which would otherwise produce identical + // " []" names and collide as duplicate Captain test ids. + const name = try fmtSpecTestName(allocator, spec.roc_file, opt, spec.description); const case = CliCase{ .id = cases.items.len, .suite = .platforms, @@ -606,6 +609,13 @@ fn fmtTestName(allocator: Allocator, roc_file: []const u8, opt: OptMode) CliRunn return std.fmt.allocPrint(allocator, "{s} [{s}]", .{ roc_file, opt.cliName() }); } +/// Like fmtTestName but appends the spec description (when present) so io_specs that share a +/// roc_file get distinct, globally-unique names (and therefore distinct Captain test ids). +fn fmtSpecTestName(allocator: Allocator, roc_file: []const u8, opt: OptMode, description: []const u8) CliRunnerError![]const u8 { + if (description.len == 0) return fmtTestName(allocator, roc_file, opt); + return std.fmt.allocPrint(allocator, "{s} [{s}]: {s}", .{ roc_file, opt.cliName(), description }); +} + fn caseRocFile(case: CliCase) ?[]const u8 { return switch (case.body) { .platform => |platform| platform.roc_file, @@ -6562,6 +6572,10 @@ fn printUsage() void { \\ --glue-opt Glue execution mode; supported value: interpreter \\ --glue-full-targets Run opt-in non-default glue compile targets \\ --verbose Show PASS results with timing + \\ --roc-test-rwx-out= Write rwx-v1-json results to /.json (Captain) + \\ --roc-test-suite= Suite name for rwx ids + filename (default: cli) + \\ --roc-test-file= Reported location.file for every test + \\ --roc-test-only-json= Run only tests whose id is listed (Captain retry) \\ , .{}); } @@ -6730,8 +6744,28 @@ pub fn main(init: std.process.Init) CliRunnerError!void { roc_binary_path; glue_execution_mode = parsed.glue_options.execution_mode; - const tests = try buildCases(spec_arena.allocator(), args.filters, args.include_llvm, parsed.suites, parsed.glue_options); + var tests = try buildCases(spec_arena.allocator(), args.filters, args.include_llvm, parsed.suites, parsed.glue_options); if (tests.len == 0) return; + + // Targeted retry (Captain's {{ jsonFilePath }}): keep only cases whose rwx id is listed. + // Applied before the worker branches so Windows worker modes (which re-apply filters for + // stable indices) see the same restricted set. + if (args.rwx_only_json) |only_path| { + if (harness.loadOnlyJsonIds(gpa, init.io, only_path)) |loaded| { + var only = loaded; + defer only.deinit(); + const suite = args.rwx_suite orelse "cli"; + var kept: std.ArrayListUnmanaged(CliCase) = .empty; + for (tests) |case| { + const id = harness.rwxTestId(gpa, suite, case.name) catch continue; + defer gpa.free(id); + if (only.contains(id)) kept.append(spec_arena.allocator(), case) catch {}; + } + tests = kept.items; + if (tests.len == 0) return; + } + } + const timeout_ms = effectiveTimeoutMs(args, parsed.suites); // Worker mode: parent spawned us with `--worker ` to run a single @@ -6820,6 +6854,32 @@ pub fn main(init: std.process.Init) CliRunnerError!void { try writeStatsJson(gpa, init.io, path, tests, results, spans); } + // rwx-v1-json reporter for Captain (no-op unless --roc-test-rwx-out is set). Built before + // the message-free loop below since failure messages are referenced inline. + if (args.rwx_out) |rwx_dir| { + const suite = args.rwx_suite orelse "cli"; + const file = args.rwx_file orelse "src/cli/test/parallel_cli_runner.zig"; + if (gpa.alloc(harness.RwxRecord, tests.len)) |rwx_records| { + defer gpa.free(rwx_records); + for (tests, 0..) |case, i| { + const r = results[i]; + const kind: []const u8 = switch (r.status) { + .pass => "successful", + .skip => "skipped", + .timeout => "timedOut", + .build_failed, .run_failed, .crash, .infra_error => "failed", + }; + rwx_records[i] = .{ + .name = case.name, + .status_kind = kind, + .duration_ns = r.duration_ns, + .message = r.message, + }; + } + harness.writeRwxResults(gpa, init.io, rwx_dir, suite, file, rwx_records) catch {}; + } else |_| {} + } + for (results) |r| { if (r.stderr_capture) |s| gpa.free(s); if (r.stdout_capture) |s| gpa.free(s); diff --git a/src/eval/test/parallel_runner.zig b/src/eval/test/parallel_runner.zig index 018b1b7531f..8a3c902e8c8 100644 --- a/src/eval/test/parallel_runner.zig +++ b/src/eval/test/parallel_runner.zig @@ -1725,6 +1725,10 @@ fn printHelp() void { \\ LLVM uses a separate 420000ms backend budget. \\ LLVM eval lock slots match the worker count. \\ --llvm Include the LLVM backend. Default: skip LLVM. + \\ --roc-test-rwx-out= Write rwx-v1-json results to /.json (Captain). + \\ --roc-test-suite= Suite name for rwx ids + filename (default: eval). + \\ --roc-test-file= Reported location.file for every test. + \\ --roc-test-only-json= Run only tests whose id is listed (Captain retry). \\ \\COVERAGE: \\ Use `zig build run-coverage-eval` to build with coverage instrumentation. @@ -2203,6 +2207,25 @@ pub fn main(init: std.process.Init) RunnerError!void { } trace_worker.stamp("filter pass"); + // Targeted retry (Captain's {{ jsonFilePath }}): keep only tests whose rwx id is listed. + // Applied here, alongside --filter, so the Windows worker modes that re-apply the filters + // (for stable indices) see the same restricted set. + if (cli.rwx_only_json) |only_path| { + if (harness.loadOnlyJsonIds(gpa, io, only_path)) |loaded| { + var only = loaded; + defer only.deinit(); + const suite = cli.rwx_suite orelse "eval"; + var kept: std.ArrayListUnmanaged(TestCase) = .empty; + for (filtered_buf.items) |tc| { + const id = harness.rwxTestId(gpa, suite, tc.name) catch continue; + defer gpa.free(id); + if (only.contains(id)) kept.append(gpa, tc) catch {}; + } + filtered_buf.deinit(gpa); + filtered_buf = kept; + } + } + const tests = filtered_buf.items; if (tests.len == 0) { if (cli.filters.len == 0) { @@ -2432,6 +2455,32 @@ pub fn main(init: std.process.Init) RunnerError!void { try writeStatsJson(gpa, io, path, tests, results, spans); } + // rwx-v1-json reporter for Captain (no-op unless --roc-test-rwx-out is set). Built before + // the message-free loop below since failure messages are referenced inline. + if (cli.rwx_out) |rwx_dir| { + const suite = cli.rwx_suite orelse "eval"; + const file = cli.rwx_file orelse "src/eval/test/eval_tests.zig"; + if (gpa.alloc(harness.RwxRecord, tests.len)) |rwx_records| { + defer gpa.free(rwx_records); + for (tests, 0..) |tc, i| { + const r = results[i]; + const kind: []const u8 = switch (r.status) { + .pass => "successful", + .skip => "skipped", + .timeout => "timedOut", + .fail, .crash => "failed", + }; + rwx_records[i] = .{ + .name = tc.name, + .status_kind = kind, + .duration_ns = r.duration_ns, + .message = r.message, + }; + } + harness.writeRwxResults(gpa, io, rwx_dir, suite, file, rwx_records) catch {}; + } else |_| {} + } + // Free GPA-duped messages after all reporting that may reference them. for (results) |r| { if (r.message) |msg| { diff --git a/src/lsp/test/parallel_integration_runner.zig b/src/lsp/test/parallel_integration_runner.zig index 45796e8848b..721827efb33 100644 --- a/src/lsp/test/parallel_integration_runner.zig +++ b/src/lsp/test/parallel_integration_runner.zig @@ -390,6 +390,10 @@ fn printUsage() void { \\ --timeout Per-spec timeout in ms (default: 120000) \\ --verbose Show PASS results with timing \\ --stats-json Write MiniCI harness stats JSON + \\ --roc-test-rwx-out= Write rwx-v1-json results to /.json (Captain) + \\ --roc-test-suite= Suite name for rwx ids + filename (default: lsp) + \\ --roc-test-file= Reported location.file for every test + \\ --roc-test-only-json= Run only specs whose id is listed (Captain retry) \\ , .{}); } @@ -414,12 +418,30 @@ pub fn main(init: std.process.Init) RunnerMainError!void { return; } - const specs = try buildSpecs(arena, args.filters); + var specs = try buildSpecs(arena, args.filters); if (specs.len == 0) { std.debug.print("No LSP integration specs matched filters.\n", .{}); return; } + // Targeted retry (Captain's {{ jsonFilePath }}): keep only specs whose rwx id is listed. + // Applied before the worker branches so worker modes see the same restricted set. + if (args.rwx_only_json) |only_path| { + if (harness.loadOnlyJsonIds(gpa, init.io, only_path)) |loaded| { + var only = loaded; + defer only.deinit(); + const suite = args.rwx_suite orelse "lsp"; + var kept: std.ArrayListUnmanaged(integration.Spec) = .empty; + for (specs) |spec| { + const id = harness.rwxTestId(gpa, suite, spec.name) catch continue; + defer gpa.free(id); + if (only.contains(id)) kept.append(arena, spec) catch {}; + } + specs = kept.items; + if (specs.len == 0) return; + } + } + if (args.worker_index) |idx| { if (idx >= specs.len) std.process.exit(2); var worker_arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); @@ -481,6 +503,31 @@ pub fn main(init: std.process.Init) RunnerMainError!void { try writeStatsJson(gpa, init.io, path, specs, results, spans); } + // rwx-v1-json reporter for Captain (no-op unless --roc-test-rwx-out is set). Built before + // the loop below since it frees the failure messages referenced inline. + if (args.rwx_out) |rwx_dir| { + const suite = args.rwx_suite orelse "lsp"; + const file = args.rwx_file orelse "src/lsp/test/parallel_integration_runner.zig"; + if (gpa.alloc(harness.RwxRecord, specs.len)) |rwx_records| { + defer gpa.free(rwx_records); + for (specs, results, 0..) |spec, result, i| { + const kind: []const u8 = switch (result.status) { + .pass => "successful", + .skip => "skipped", + .timeout => "timedOut", + .fail, .crash => "failed", + }; + rwx_records[i] = .{ + .name = spec.name, + .status_kind = kind, + .duration_ns = result.duration_ns, + .message = result.message, + }; + } + harness.writeRwxResults(gpa, init.io, rwx_dir, suite, file, rwx_records) catch {}; + } else |_| {} + } + var has_failure = false; for (results) |result| { if (result.message) |message| gpa.free(message); diff --git a/test/playground-integration/main.zig b/test/playground-integration/main.zig index fec6737970e..2c00235ae23 100644 --- a/test/playground-integration/main.zig +++ b/test/playground-integration/main.zig @@ -999,6 +999,7 @@ fn runTests( test_cases: []const TestCase, wasm_path: []const u8, stats_events: ?*std.ArrayListUnmanaged(harness.StatsEvent), + rwx_records: ?*std.ArrayListUnmanaged(harness.RwxRecord), ) anyerror!TestStats { var stats = TestStats{ .total = test_cases.len, @@ -1037,6 +1038,19 @@ fn runTests( events.items[event_index].worker_index = 0; } }; + defer if (rwx_records) |recs| { + const kind: []const u8 = switch (case_result) { + .passed => "successful", + .skipped => "skipped", + .failed => "failed", + }; + recs.append(arena, .{ + .name = case.name, + .status_kind = kind, + .duration_ns = relativeNs(case_start, nanoTimestamp()), + .message = case_message, + }) catch {}; + }; if (case.skip) { logDebug("\n[INFO] Skipping test case: {s}\n", .{case.name}); @@ -1183,6 +1197,10 @@ pub fn main(init: std.process.Init) anyerror!void { var wasm_path: ?[]const u8 = null; var stats_args: PlaygroundStatsArgs = .{}; + var rwx_out: ?[]const u8 = null; + var rwx_suite: ?[]const u8 = null; + var rwx_file: ?[]const u8 = null; + var rwx_only_json: ?[]const u8 = null; var case_filters = std.ArrayList([]const u8).empty; defer case_filters.deinit(allocator); var i: usize = 1; @@ -1220,6 +1238,14 @@ pub fn main(init: std.process.Init) anyerror!void { return; } try case_filters.append(allocator, args[i]); + } else if (std.mem.startsWith(u8, arg, "--roc-test-rwx-out=")) { + rwx_out = arg["--roc-test-rwx-out=".len..]; + } else if (std.mem.startsWith(u8, arg, "--roc-test-suite=")) { + rwx_suite = arg["--roc-test-suite=".len..]; + } else if (std.mem.startsWith(u8, arg, "--roc-test-file=")) { + rwx_file = arg["--roc-test-file=".len..]; + } else if (std.mem.startsWith(u8, arg, "--roc-test-only-json=")) { + rwx_only_json = arg["--roc-test-only-json=".len..]; } else if (!std.mem.startsWith(u8, arg, "--")) { // Positional argument - treat as WASM path wasm_path = arg; @@ -1363,6 +1389,23 @@ pub fn main(init: std.process.Init) anyerror!void { test_cases = kept; } + // Targeted retry (Captain's {{ jsonFilePath }}): keep only cases whose rwx id is listed. + if (rwx_only_json) |only_path| { + if (harness.loadOnlyJsonIds(allocator, std_io, only_path)) |loaded| { + var only = loaded; + defer only.deinit(); + const suite = rwx_suite orelse "playground"; + var kept = std.ArrayList(TestCase).empty; + for (test_cases.items) |case| { + const id = harness.rwxTestId(allocator, suite, case.name) catch continue; + defer allocator.free(id); + if (only.contains(id)) try kept.append(allocator, case); + } + test_cases.deinit(allocator); + test_cases = kept; + } + } + logDebug("[INFO] Starting Playground Integration Tests...\n", .{}); logDebug("[INFO] Running {} test cases\n", .{test_cases.items.len}); @@ -1370,7 +1413,11 @@ pub fn main(init: std.process.Init) anyerror!void { defer stats_events.deinit(allocator); const maybe_stats_events: ?*std.ArrayListUnmanaged(harness.StatsEvent) = if (stats_args.json_path != null) &stats_events else null; - const stats = try runTests(std_io, allocator, gpa.allocator(), test_cases.items, playground_wasm_path, maybe_stats_events); + var rwx_records: std.ArrayListUnmanaged(harness.RwxRecord) = .empty; + defer rwx_records.deinit(allocator); + const maybe_rwx_records: ?*std.ArrayListUnmanaged(harness.RwxRecord) = if (rwx_out != null) &rwx_records else null; + + const stats = try runTests(std_io, allocator, gpa.allocator(), test_cases.items, playground_wasm_path, maybe_stats_events, maybe_rwx_records); logDebug("\nAll Playground Integration Tests Completed!\n", .{}); logDebug("Final Results: {}/{} passed ({d:0.}%)\n", .{ stats.passed, stats.total, stats.successRate() }); @@ -1388,6 +1435,13 @@ pub fn main(init: std.process.Init) anyerror!void { }); } + // rwx-v1-json reporter for Captain (no-op unless --roc-test-rwx-out is set). + if (rwx_out) |rwx_dir| { + const suite = rwx_suite orelse "playground"; + const file = rwx_file orelse "test/playground-integration/main.zig"; + harness.writeRwxResults(allocator, std_io, rwx_dir, suite, file, rwx_records.items) catch {}; + } + // Exit with error if any tests failed if (stats.failed > 0) { return error.TestsFailed; diff --git a/test/zig_test_runner.zig b/test/zig_test_runner.zig index 98a9ce9081f..4a00c33bc17 100644 --- a/test/zig_test_runner.zig +++ b/test/zig_test_runner.zig @@ -26,15 +26,33 @@ const RunnerConfig = struct { partition_count: usize = 1, timing_name: ?[]const u8 = null, timing_generation_ns: ?i128 = null, + // rwx-v1-json reporter (RWX Captain). When rwx_out_dir is set, write + // /zig-tests-.json on completion. suite_name/suite_file tag the + // results for Captain (per-binary output filename, test-id prefix, location.file). + rwx_out_dir: ?[]const u8 = null, + suite_name: ?[]const u8 = null, + suite_file: ?[]const u8 = null, + // Targeted retry: path to an rwx-v1-json file (Captain's {{ jsonFilePath }}) listing the + // tests to re-run. When set, only tests whose id (::) appears are selected. + only_json: ?[]const u8 = null, }; -const TimingRecord = struct { +const ResultStatus = enum { pass, skip, fail }; + +const TestRecord = struct { test_index: u32, duration_ns: u64, + status: ResultStatus, + // Treated as failures by the rwx reporter (leak detection / logged errors are roc's + // additional pass criteria beyond the test function's own return). + leaked: bool = false, + logged_errors: bool = false, }; const timing_dir = ".zig-cache/roc-test-timings"; const max_timing_file_bytes = 16 * 1024 * 1024; +const max_only_json_bytes = 64 * 1024 * 1024; +const rwx_schema_url = "https://raw.githubusercontent.com/rwx-research/test-results-schema/main/v1.json"; pub fn main(init: std.process.Init.Minimal) void { @disableInstrumentation(); @@ -97,6 +115,14 @@ fn parseArgs(args: []const []const u8) RunnerConfig { arg["--roc-test-timing-generation-ns=".len..], 10, ) catch @panic("unable to parse --roc-test-timing-generation-ns"); + } else if (std.mem.startsWith(u8, arg, "--roc-test-rwx-out=")) { + config.rwx_out_dir = arg["--roc-test-rwx-out=".len..]; + } else if (std.mem.startsWith(u8, arg, "--roc-test-suite=")) { + config.suite_name = arg["--roc-test-suite=".len..]; + } else if (std.mem.startsWith(u8, arg, "--roc-test-file=")) { + config.suite_file = arg["--roc-test-file=".len..]; + } else if (std.mem.startsWith(u8, arg, "--roc-test-only-json=")) { + config.only_json = arg["--roc-test-only-json=".len..]; } else { std.debug.panic("unrecognized command line argument: {s}", .{arg}); } @@ -229,6 +255,57 @@ fn loadTimingWeights(allocator: std.mem.Allocator, config: RunnerConfig) TimingW return weights; } +/// rwx-v1-json id for a test: "::". Globally unique across module +/// binaries (the zig name alone is only unique within a binary). Caller owns the result. +fn testId(allocator: std.mem.Allocator, suite_name: ?[]const u8, test_name: []const u8) std.mem.Allocator.Error![]u8 { + const suite = suite_name orelse "zig"; + return std.fmt.allocPrint(allocator, "{s}::{s}", .{ suite, test_name }); +} + +/// Load the set of test ids to re-run from Captain's {{ jsonFilePath }} rwx-v1-json file. +/// Returns null if no retry file is configured. Caller deinits + frees the arena via the +/// returned parsed handle. +const OnlyJsonIds = struct { + parsed: std.json.Parsed(Doc), + set: std.StringHashMap(void), + + const Doc = struct { + tests: []const struct { + id: ?[]const u8 = null, + } = &.{}, + }; + + fn deinit(self: *OnlyJsonIds) void { + self.set.deinit(); + self.parsed.deinit(); + } +}; + +fn loadOnlyJsonIds(allocator: std.mem.Allocator, path: []const u8) ?OnlyJsonIds { + const contents = Io.Dir.cwd().readFileAlloc( + runner_io, + path, + allocator, + .limited(max_only_json_bytes), + ) catch return null; + defer allocator.free(contents); + + const parsed = std.json.parseFromSlice( + OnlyJsonIds.Doc, + allocator, + contents, + // alloc_always: dupe strings into the parsed arena so the ids survive freeing + // `contents` below (alloc_if_needed would leave them aliasing the freed buffer). + .{ .ignore_unknown_fields = true, .allocate = .alloc_always }, + ) catch return null; + + var set = std.StringHashMap(void).init(allocator); + for (parsed.value.tests) |t| { + if (t.id) |id| set.put(id, {}) catch {}; + } + return .{ .parsed = parsed, .set = set }; +} + const WeightedTest = struct { index: u32, weight: u64, @@ -288,6 +365,25 @@ fn buildSelectedIndices(allocator: std.mem.Allocator, config: RunnerConfig) std. } }.lessThan); + // Targeted retry: keep only the tests Captain asked us to re-run (by id). Each module + // binary runs only the failing tests it actually owns; binaries with no matches run none. + if (config.only_json) |only_path| { + if (loadOnlyJsonIds(allocator, only_path)) |loaded| { + var ids = loaded; + defer ids.deinit(); + + var kept = std.ArrayList(u32).empty; + errdefer kept.deinit(allocator); + for (selected.items) |test_i| { + const id = try testId(allocator, config.suite_name, builtin.test_functions[@intCast(test_i)].name); + defer allocator.free(id); + if (ids.set.contains(id)) try kept.append(allocator, test_i); + } + selected.deinit(allocator); + return kept.toOwnedSlice(allocator); + } + } + return selected.toOwnedSlice(allocator); } @@ -299,7 +395,7 @@ fn nowNs() u64 { fn writeTimingRecords( allocator: std.mem.Allocator, config: RunnerConfig, - records: []const TimingRecord, + records: []const TestRecord, ) void { const name = config.timing_name orelse return; const generation_ns = config.timing_generation_ns orelse return; @@ -324,6 +420,104 @@ fn writeTimingRecords( }) catch {}; } +// rwx-v1-json structs (https://github.com/rwx-research/test-results-schema/blob/main/v1.json). +// Zig has no native Captain framework, so we report as the generic "other" framework, which +// Captain ingests directly (rwx-v1-json is its own canonical schema — no custom parser needed). +const RwxStatus = struct { kind: []const u8 }; +const RwxLocation = struct { file: []const u8 }; +const RwxAttempt = struct { durationInNanoseconds: u64, status: RwxStatus }; +const RwxTest = struct { + id: []const u8, + name: []const u8, + location: RwxLocation, + attempt: RwxAttempt, +}; +const RwxFramework = struct { + language: []const u8 = "other", + kind: []const u8 = "other", + providedLanguage: []const u8 = "Zig", + providedKind: []const u8 = "roc", +}; +const RwxSummary = struct { + status: RwxStatus, + tests: usize, + otherErrors: usize = 0, + retries: usize = 0, + canceled: usize = 0, + failed: usize, + pended: usize = 0, + quarantined: usize = 0, + skipped: usize, + successful: usize, + timedOut: usize = 0, + todo: usize = 0, +}; +const RwxDoc = struct { + @"$schema": []const u8 = rwx_schema_url, + framework: RwxFramework = .{}, + summary: RwxSummary, + tests: []const RwxTest, +}; + +/// Write this binary's tests as /zig-tests-.json. No-op when the reporter +/// is not enabled (-Droc-test-rwx-out unset). Best-effort: never fails the test run. +fn writeRwxResults( + allocator: std.mem.Allocator, + config: RunnerConfig, + records: []const TestRecord, +) void { + const out_dir = config.rwx_out_dir orelse return; + const suite = config.suite_name orelse "zig"; + const file = config.suite_file orelse suite; + + var arena_inst = std.heap.ArenaAllocator.init(allocator); + defer arena_inst.deinit(); + const arena = arena_inst.allocator(); + + var tests = std.ArrayList(RwxTest).empty; + var n_pass: usize = 0; + var n_fail: usize = 0; + var n_skip: usize = 0; + for (records) |rec| { + const ti: usize = @intCast(rec.test_index); + if (ti >= builtin.test_functions.len) continue; + const name = builtin.test_functions[ti].name; + // roc treats a leak or a logged error as a failure, beyond the test fn's own result. + const failed = rec.status == .fail or rec.leaked or rec.logged_errors; + const kind: []const u8 = if (rec.status == .skip) "skipped" else if (failed) "failed" else "successful"; + if (rec.status == .skip) { + n_skip += 1; + } else if (failed) { + n_fail += 1; + } else { + n_pass += 1; + } + tests.append(arena, .{ + .id = testId(arena, suite, name) catch continue, + .name = name, + .location = .{ .file = file }, + .attempt = .{ .durationInNanoseconds = rec.duration_ns, .status = .{ .kind = kind } }, + }) catch continue; + } + + const doc = RwxDoc{ + .summary = .{ + .status = .{ .kind = if (n_fail > 0) "failed" else "successful" }, + .tests = tests.items.len, + .failed = n_fail, + .skipped = n_skip, + .successful = n_pass, + }, + .tests = tests.items, + }; + + const json = std.json.Stringify.valueAlloc(arena, doc, .{ .whitespace = .indent_2 }) catch return; + + Io.Dir.cwd().createDirPath(runner_io, out_dir) catch return; + const path = std.fmt.allocPrint(arena, "{s}/zig-tests-{s}.json", .{ out_dir, suite }) catch return; + Io.Dir.cwd().writeFile(runner_io, .{ .sub_path = path, .data = json }) catch {}; +} + fn mainServer(init: std.process.Init.Minimal, config: RunnerConfig) anyerror!void { @disableInstrumentation(); @@ -343,14 +537,15 @@ fn mainServer(init: std.process.Init.Minimal, config: RunnerConfig) anyerror!voi var selected_indices: []u32 = &.{}; defer if (selected_indices.len != 0) allocator.free(selected_indices); - var timing_records = std.ArrayList(TimingRecord).empty; - defer timing_records.deinit(allocator); + var test_records = std.ArrayList(TestRecord).empty; + defer test_records.deinit(allocator); while (true) { const hdr = try server.receiveMessage(); switch (hdr.tag) { .exit => { - writeTimingRecords(allocator, config, timing_records.items); + writeTimingRecords(allocator, config, test_records.items); + writeRwxResults(allocator, config, test_records.items); return std.process.exit(0); }, @@ -422,15 +617,24 @@ fn mainServer(init: std.process.Init.Minimal, config: RunnerConfig) anyerror!voi break :status .fail; }, }; - timing_records.append(allocator, .{ - .test_index = selected_indices[metadata_index], - .duration_ns = nowNs() - started_ns, - }) catch {}; + const duration_ns = nowNs() - started_ns; testing.io_instance.deinit(); const leak_count = testing.allocator_instance.detectLeaks(); testing.allocator_instance.deinitWithoutLeakChecks(); + test_records.append(allocator, .{ + .test_index = selected_indices[metadata_index], + .duration_ns = duration_ns, + .status = switch (status) { + .pass => .pass, + .skip => .skip, + else => .fail, + }, + .leaked = leak_count > 0, + .logged_errors = log_err_count > 0, + }) catch {}; + try server.serveTestResults(.{ .index = metadata_index, .flags = .{ @@ -465,8 +669,8 @@ fn mainTerminal(init: std.process.Init.Minimal, config: RunnerConfig) void { @panic("unable to build selected test indices"); }; defer allocator.free(selected_indices); - var timing_records = std.ArrayList(TimingRecord).empty; - defer timing_records.deinit(allocator); + var test_records = std.ArrayList(TestRecord).empty; + defer test_records.deinit(allocator); const selected_count = selected_indices.len; var ok_count: usize = 0; @@ -490,10 +694,6 @@ fn mainTerminal(init: std.process.Init.Minimal, config: RunnerConfig) void { .argv0 = .init(init.args), .environ = init.environ, }); - defer { - testing.io_instance.deinit(); - if (testing.allocator_instance.deinit() == .leak) leaks += 1; - } testing.log_level = .warn; testing.environ = init.environ; log_err_count = 0; @@ -503,50 +703,64 @@ fn mainTerminal(init: std.process.Init.Minimal, config: RunnerConfig) void { std.debug.print("{d}/{d} {s}...", .{ selected_i, selected_count, test_fn.name }); } const started_ns = nowNs(); - if (test_fn.func()) |_| { - timing_records.append(allocator, .{ - .test_index = test_i, - .duration_ns = nowNs() - started_ns, - }) catch {}; - ok_count += 1; - test_node.end(); - if (!have_tty) std.debug.print("OK\n", .{}); - } else |err| switch (err) { - error.SkipZigTest => { - timing_records.append(allocator, .{ - .test_index = test_i, - .duration_ns = nowNs() - started_ns, - }) catch {}; + // Capture the outcome and dump the failure trace here (before any further fallible + // calls clobber the error-return trace), then tear down to learn the leak result. + var status: ResultStatus = .pass; + var caught_err: ?anyerror = null; + if (test_fn.func()) |_| {} else |err| { + caught_err = err; + switch (err) { + error.SkipZigTest => status = .skip, + else => { + status = .fail; + if (@errorReturnTrace()) |trace| std.debug.dumpErrorReturnTrace(trace); + }, + } + } + const duration_ns = nowNs() - started_ns; + const logged_errors = log_err_count > 0; + + testing.io_instance.deinit(); + const leaked = testing.allocator_instance.deinit() == .leak; + if (leaked) leaks += 1; + + switch (status) { + .pass => { + ok_count += 1; + if (!have_tty) std.debug.print("OK\n", .{}); + }, + .skip => { skip_count += 1; if (have_tty) { std.debug.print("{d}/{d} {s}...SKIP\n", .{ selected_i, selected_count, test_fn.name }); } else { std.debug.print("SKIP\n", .{}); } - test_node.end(); }, - else => { - timing_records.append(allocator, .{ - .test_index = test_i, - .duration_ns = nowNs() - started_ns, - }) catch {}; + .fail => { fail_count += 1; if (have_tty) { std.debug.print("{d}/{d} {s}...FAIL ({t})\n", .{ - selected_i, selected_count, test_fn.name, err, + selected_i, selected_count, test_fn.name, caught_err.?, }); } else { - std.debug.print("FAIL ({t})\n", .{err}); - } - if (@errorReturnTrace()) |trace| { - std.debug.dumpErrorReturnTrace(trace); + std.debug.print("FAIL ({t})\n", .{caught_err.?}); } - test_node.end(); }, } + test_node.end(); + + test_records.append(allocator, .{ + .test_index = test_i, + .duration_ns = duration_ns, + .status = status, + .leaked = leaked, + .logged_errors = logged_errors, + }) catch {}; } root_node.end(); - writeTimingRecords(allocator, config, timing_records.items); + writeTimingRecords(allocator, config, test_records.items); + writeRwxResults(allocator, config, test_records.items); if (ok_count == selected_count) { std.debug.print("All {d} tests passed.\n", .{ok_count}); From fab6fa349681e006b4b79144bbce102db15c329b Mon Sep 17 00:00:00 2001 From: Dylan Penka Date: Tue, 30 Jun 2026 12:00:05 -0400 Subject: [PATCH 2/9] build.zig: pin a fixed CPU model for host-arch builds (cross-agent cache stability) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Host-arch builds left cpu_model unset for aarch64, which resolves to the building machine's NATIVE CPU (Zig Query.cpu_model defaults to .determined_by_arch_os → native on a host build). x86_64 already pinned x86_64_v3, but aarch64 did not. On CI this makes the compile-cache key agent-specific: RWX runners vary by CPU model, so test binaries built by a build anchor never cache-hit on a differently-provisioned leaf agent, and the leaf rebuilds the whole suite (the Debug leaf OOMs; the ReleaseFast leaf hits the 10-min timeout). Same class of bug as the existing hostToolsTarget fix. Centralize the policy in a stableHostCpuModel(arch) helper (x86_64_v3 for x86_64, baseline otherwise) and apply it at all host-arch target sites: the default target, getReleaseTargetQuery, nativeSharedArchiveTarget, and the fx/http platform host libs. Note: this changes the DEFAULT aarch64 build (including local dev) to baseline ARMv8 instead of native — consistent with how x86_64 already pins x86_64_v3, and it makes distributed arm binaries deterministic. Pass -Dcpu=native for a machine-tuned local build. --- build.zig | 47 ++++++++++++++++++++++++++++++----------------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/build.zig b/build.zig index 95de8c32d1c..ea99d4398dd 100644 --- a/build.zig +++ b/build.zig @@ -151,11 +151,11 @@ fn nativeSharedArchiveTarget(b: *std.Build, target: ResolvedTarget) NativeShared if (target.result.os.tag == .linux) { return switch (target.result.cpu.arch) { .x86_64 => .{ - .resolved = b.resolveTargetQuery(.{ .cpu_arch = .x86_64, .os_tag = .linux, .abi = .gnu }), + .resolved = b.resolveTargetQuery(.{ .cpu_arch = .x86_64, .os_tag = .linux, .abi = .gnu, .cpu_model = stableHostCpuModel(.x86_64) }), .roc_name = "x64glibc", }, .aarch64 => .{ - .resolved = b.resolveTargetQuery(.{ .cpu_arch = .aarch64, .os_tag = .linux, .abi = .gnu }), + .resolved = b.resolveTargetQuery(.{ .cpu_arch = .aarch64, .os_tag = .linux, .abi = .gnu, .cpu_model = stableHostCpuModel(.aarch64) }), .roc_name = "arm64glibc", }, else => .{ @@ -177,9 +177,25 @@ fn withRocMacosDeploymentTarget(b: *std.Build, target: ResolvedTarget) ResolvedT return b.resolveTargetQuery(roc_target.macos_deployment.query(target.result.cpu.arch)); } +/// CPU model for HOST-arch artifacts that one CI agent builds and another consumes (a build anchor → +/// test leaf, or a restored cross-run tool-cache). Agents vary by CPU model, so leaving `cpu_model` +/// unset bakes the *building* agent's native CPU into Zig's compile cache key — the artifact then +/// never cache-hits on a differently-provisioned agent and the consumer recompiles it from scratch. +/// We hit this twice: first as host-tool churn (see hostToolsTarget), then as arm test-binary +/// OOM/timeouts. Pin a FIXED model anywhere such an artifact is built: +/// - x86_64: x86_64_v3 (AVX2/BMI2, and AVX-512-free so Valgrind can emulate musl startup code) +/// - else: baseline (a subset of every agent's CPU, so the artifact still runs anywhere) +/// Build-time-only host tools use the stricter all-baseline hostToolsTarget instead. +fn stableHostCpuModel(arch: std.Target.Cpu.Arch) std.Target.Query.CpuModel { + return if (arch == .x86_64) + .{ .explicit = &std.Target.x86.cpu.x86_64_v3 } + else + .baseline; +} + /// Returns the optimal target query for release builds on the current host. /// - Linux: Uses musl for fully static binaries -/// - x86_64: Uses x86_64_v3 for modern CPU features (AVX2, BMI2, etc.) +/// - CPU model is pinned via stableHostCpuModel for cross-agent cache stability. fn getReleaseTargetQuery() std.Target.Query { var query: std.Target.Query = .{}; @@ -188,10 +204,7 @@ fn getReleaseTargetQuery() std.Target.Query { query.abi = .musl; } - // Use x86_64_v3 CPU model for x86_64 (enables AVX2, BMI2, etc.) - if (builtin.target.cpu.arch == .x86_64) { - query.cpu_model = .{ .explicit = &std.Target.x86.cpu.x86_64_v3 }; - } + query.cpu_model = stableHostCpuModel(builtin.target.cpu.arch); return query; } @@ -2355,12 +2368,12 @@ pub fn build(b: *std.Build) void { .abi = if (builtin.target.os.tag == .linux) .musl else null, }; - // Use x86_64_v3 (AVX2, no AVX-512) for Valgrind compatibility. - // Valgrind 3.22 can't emulate AVX-512 EVEX instructions in musl startup code. - // This matches the release target (getReleaseTargetQuery) which also uses x86_64_v3. - if (builtin.target.cpu.arch == .x86_64) { - default_target_query.cpu_model = .{ .explicit = &std.Target.x86.cpu.x86_64_v3 }; - } + // Pin a fixed CPU model (see stableHostCpuModel) so the compile cache key is stable across + // CI agents — otherwise test binaries the build anchor compiles never cache-hit on a + // differently-provisioned leaf agent and the leaf rebuilds the whole suite (Debug leaf OOMs, + // ReleaseFast leaf hits the 10-min timeout). On x86_64 this is x86_64_v3, which also keeps + // musl startup Valgrind-safe (no AVX-512 EVEX, which Valgrind 3.22 can't emulate). + default_target_query.cpu_model = stableHostCpuModel(builtin.target.cpu.arch); break :blk b.standardTargetOptions(.{ .default_target = default_target_query }); }; @@ -4845,8 +4858,8 @@ pub fn build(b: *std.Build) void { const native_fx_target_dir = roc_target.RocTarget.fromStdTarget(target.result).toName(); const fx_host_target, const fx_host_target_dir: ?[]const u8 = switch (target.result.os.tag) { .linux => switch (target.result.cpu.arch) { - .x86_64 => .{ b.resolveTargetQuery(.{ .cpu_arch = .x86_64, .os_tag = .linux, .abi = .musl }), "x64musl" }, - .aarch64 => .{ b.resolveTargetQuery(.{ .cpu_arch = .aarch64, .os_tag = .linux, .abi = .musl }), "arm64musl" }, + .x86_64 => .{ b.resolveTargetQuery(.{ .cpu_arch = .x86_64, .os_tag = .linux, .abi = .musl, .cpu_model = stableHostCpuModel(.x86_64) }), "x64musl" }, + .aarch64 => .{ b.resolveTargetQuery(.{ .cpu_arch = .aarch64, .os_tag = .linux, .abi = .musl, .cpu_model = stableHostCpuModel(.aarch64) }), "arm64musl" }, else => .{ target, native_fx_target_dir }, }, .windows => switch (target.result.cpu.arch) { @@ -4968,8 +4981,8 @@ pub fn build(b: *std.Build) void { const http_host_target, const http_host_target_dir: ?[]const u8 = switch (target.result.os.tag) { .linux => switch (target.result.cpu.arch) { - .x86_64 => .{ b.resolveTargetQuery(.{ .cpu_arch = .x86_64, .os_tag = .linux, .abi = .musl }), "x64musl" }, - .aarch64 => .{ b.resolveTargetQuery(.{ .cpu_arch = .aarch64, .os_tag = .linux, .abi = .musl }), "arm64musl" }, + .x86_64 => .{ b.resolveTargetQuery(.{ .cpu_arch = .x86_64, .os_tag = .linux, .abi = .musl, .cpu_model = stableHostCpuModel(.x86_64) }), "x64musl" }, + .aarch64 => .{ b.resolveTargetQuery(.{ .cpu_arch = .aarch64, .os_tag = .linux, .abi = .musl, .cpu_model = stableHostCpuModel(.aarch64) }), "arm64musl" }, else => .{ target, null }, }, .windows => switch (target.result.cpu.arch) { From b0dec21c56b156af96ed1109ee6e8b87fe0aa0f3 Mon Sep 17 00:00:00 2001 From: Dylan Penka Date: Tue, 30 Jun 2026 12:00:18 -0400 Subject: [PATCH 3/9] RWX CI: install Rust for glue tests + right-size agents (~47% less compute) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rust: the rebase pulled in RustGlue regression tests (run-test-cli, suite cli-platforms) that shell out to rustc. GitHub runners ship rustc for free; the rwx/base image does not, so platforms failed with error.FileNotFound. Add a rust-lang/install 1.0.7 task (rust 1.83.0) on both legs, used only by the platforms task (cli-llvm filters glue out). Sizing: every heavy task ran on a 32c agent, but roc's builds are serial-bound (the roc binary is the long pole) so most barely used the cores. RWX bills per vCPU-second with no per-GB charge, so cores are the cost lever. Right-size each task to its real CPU/memory footprint: serial builds (valgrind) and off-critical-path builds (cross-compile, build-ci) drop to 4-8c; tracy to 8c; build-release-arm64 to 16c. build-release-x64 is kept at 32c — it is genuinely parallel, and shrinking it cost both time and money. Memory class (CO/GP/MO) follows each task's measured peak at its chosen core count. Result (same-warmth A/B): full-run compute -47% ($6.32 -> $3.38/run), heavy tasks -51%, wall-clock flat (681s -> ~699s). Detail in tmp/rwx-migration/agent-sizing-experiment.md. --- .rwx/ci-arm64.yml | 23 ++++++++++++++++------- .rwx/ci.yml | 38 ++++++++++++++++++++++++-------------- 2 files changed, 40 insertions(+), 21 deletions(-) diff --git a/.rwx/ci-arm64.yml b/.rwx/ci-arm64.yml index 2181d8ac763..84642180da7 100644 --- a/.rwx/ci-arm64.yml +++ b/.rwx/ci-arm64.yml @@ -89,6 +89,15 @@ tasks: - key: captain call: rwx/install-captain 1.1.7 + # Rust toolchain — the CLI glue matrix (run-test-cli, suite cli-platforms) compiles generated + # Rust glue with `rustc`. GHA gets it free from the runner image; rwx/base has none. The leaf + # exports ~/.cargo/bin onto PATH (arch auto-detected via uname -m, so aarch64 here). Only the + # `platforms` task needs it. + - key: rust-lang + call: rust-lang/install 1.0.7 + with: + rust-version: 1.83.0 + # System packages not on the rwx/base image, pulled into their own cached tasks. - key: system-file run: | @@ -116,9 +125,9 @@ tasks: filter: *pos-build tool-cache: ci-build-ci-arm64 timeout: 45m # arm64 build compiles roc + kcov from source; exceeds the 10m default - agent: - cpus: 32 - memory: 64gb + agent: # GP 8c/32g — Debug anchor; builds kcov too, so 8c parallelism needs >16g (OOM'd at CO 8c/16g) + cpus: 8 + memory: 32gb run: | set -euo pipefail # -Droc-test-runner: pre-build the custom-runner test binaries so the unit tasks cache-hit @@ -142,7 +151,7 @@ tasks: - legal_details - zig-out - .captain # Captain suite config (.captain/config.yml); without it `captain run` finds no command - agent: &test-agent + agent: &test-agent # CPU-bound test leaves: CO 8c/16g cpus: 8 memory: 16gb run: | @@ -160,7 +169,7 @@ tasks: # file, so it keeps CONTRIBUTING/profiling on top of the leaf inputs (only # CONTRIBUTING reader on this leg, so the list is inline rather than anchored). - key: platforms - use: [build-ci-arm64, captain] + use: [build-ci-arm64, captain, rust-lang] filter: - build.zig - build.zig.zon @@ -203,8 +212,8 @@ tasks: filter: *pos-build tool-cache: ci-build-release-arm64 timeout: 45m - agent: - cpus: 32 + agent: # GP 16c/64g — parallel + critical path (arm leg); 16c is the balance point, needs 64g at 16c + cpus: 16 memory: 64gb run: | set -euo pipefail diff --git a/.rwx/ci.yml b/.rwx/ci.yml index 474854353ec..6109f79143a 100644 --- a/.rwx/ci.yml +++ b/.rwx/ci.yml @@ -80,6 +80,16 @@ tasks: - key: captain call: rwx/install-captain 1.1.7 + # Rust toolchain — the CLI glue matrix (run-test-cli, suite cli-platforms) compiles generated + # Rust glue with `rustc` (src/cli/test/parallel_cli_runner.zig:compileGeneratedRustGlue). GHA + # gets rustc free from the GitHub runner image; the rwx/base image has none, so install it here. + # The leaf exports ~/.cargo/bin onto PATH (carried across use:, like the $HOME Zig toolchain). + # Only the `platforms` task needs it (cli-llvm filters glue out). + - key: rust-lang + call: rust-lang/install 1.0.7 + with: + rust-version: 1.83.0 + # ============================================================ source-only gates # Pure source/grep/perl checks — no compiled binaries needed, so they run in # parallel with the build instead of waiting on the anchor (build.zig:4091-4115). @@ -150,9 +160,9 @@ tasks: filter: *pos-build tool-cache: ci-build-ci-x64 # persist .zig-cache + ~/.cache/zig → warm runs skip the build-runner compile & recompile only changed modules timeout: 45m - agent: - cpus: 32 - memory: 64gb + agent: # CO 8c/16g — Debug anchor; off critical path, feeds gates+leaves so keep it reasonably fast (4c gated the run) + cpus: 8 + memory: 16gb run: | set -euo pipefail # -Droc-test-runner so the module + standalone test binaries are compiled ONCE here with @@ -286,7 +296,7 @@ tasks: # CLI platform tests (interpreter + dev) + minimal roc-check smoke (debug roc). - key: platforms - use: [build-ci-x64, captain] + use: [build-ci-x64, captain, rust-lang] filter: *pos-cli agent: *test-agent run: | @@ -432,9 +442,9 @@ tasks: # Match the build anchors: Compute-Optimized 32c/64gb. A full `zig build` of roc is # memory-heavy (LLVM/link peak) — 32gb (CO 16c) OOMs; 64gb is the next CO tier (32c) and is # what the anchors use to build roc with even more pressure (-Droc-test-runner -Dfuzz). - agent: - cpus: 32 - memory: 64gb + agent: # MO 4c/32g — off critical path; 4c is a free cost win, 32g holds the 32-bit-target peak + cpus: 4 + memory: 32gb parallel: matrix: target: @@ -475,9 +485,9 @@ tasks: - tracy tool-cache: ci-tracy-check timeout: 45m - agent: - cpus: 16 - memory: 32gb + agent: # CO 8c/16g — -Dno-bin compile-check; off-path. 8c (not 2c) is cheaper AND faster; needs 16g (8g stalls) + cpus: 8 + memory: 16gb run: | set -euo pipefail zig build -Dno-bin -Dfuzz -Dtracy=./tracy @@ -491,7 +501,7 @@ tasks: filter: *pos-build tool-cache: ci-build-release-x64 timeout: 45m - agent: + agent: # CO 32c/64g — kept large: genuinely parallel ReleaseFast build; shrinking cost both time and money cpus: 32 memory: 64gb run: | @@ -570,9 +580,9 @@ tasks: filter: *pos-build tool-cache: ci-build-valgrind-x64 timeout: 45m - agent: - cpus: 32 - memory: 64gb + agent: # MO 4c/32g — serial build (cores don't help); memory headroom for the full-DWARF -Dstrip=false build + cpus: 4 + memory: 32gb run: | set -euo pipefail # Build ONLY what the valgrind leaf uses (roc + snapshot + test_runner + the str/int From a3735ec7f9c55f4de8043a4ced3e615bc6d4293a Mon Sep 17 00:00:00 2001 From: Dylan Penka Date: Tue, 30 Jun 2026 15:09:16 -0400 Subject: [PATCH 4/9] test runner: survive Zig's crash-recovery re-spawn When a test hard-aborts (e.g. a std.debug.panic in a Debug build), Zig's build runner re-spawns the test binary to continue the remaining tests, replaying run_test against its cached metadata *without* re-sending query_test_metadata (std.Build.Step.Run only queries when its metadata is null). Our partitioned runner only populates selected_indices on query_test_metadata, so the re-spawned process indexed an empty slice and panicked ("index out of bounds: index N, len 0") on every re-spawn. That secondary panic masked the genuine failure and broke Zig's crash-recovery: it couldn't continue past the aborting test, so the rest of that binary's tests were dropped from the results (observed ~140 tests missing from the reported total). Rebuild selected_indices lazily in the run_test handler when it's empty. buildSelectedIndices is deterministic for a given config, so the index Zig replays from its cached metadata still maps correctly. The crash is now isolated to the offending test (reported as crashed) and the rest of the suite completes; a defensive bounds check replaces the raw slice panic. The stock Zig runner is immune because it indexes builtin.test_functions directly; only our partition indirection needed this. --- test/zig_test_runner.zig | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/test/zig_test_runner.zig b/test/zig_test_runner.zig index 4a00c33bc17..ff8bab40605 100644 --- a/test/zig_test_runner.zig +++ b/test/zig_test_runner.zig @@ -600,6 +600,27 @@ fn mainServer(init: std.process.Init.Minimal, config: RunnerConfig) anyerror!voi log_err_count = 0; const metadata_index = try server.receiveBody_u32(); + + // Normally `query_test_metadata` runs first and populates `selected_indices`. + // But when a test hard-aborts (e.g. a `std.debug.panic` in a Debug build), Zig's + // build runner re-spawns this binary to continue the remaining tests, replaying + // `run_test` against the metadata it cached from the first run *without* + // re-querying (std.Build.Step.Run only sends `query_test_metadata` when its + // metadata is null). The stock runner is immune because it indexes + // `builtin.test_functions` directly; our partition indirection is not. Rebuild + // lazily so the cached index still maps. `buildSelectedIndices` is deterministic + // for a given config, so the index stays valid; this keeps the crash isolated to + // the offending test (so the rest of the suite still reports) instead of masking + // it with a runner-internal index error on every re-spawn. + if (selected_indices.len == 0) { + selected_indices = try buildSelectedIndices(allocator, config); + } + if (metadata_index >= selected_indices.len) { + std.debug.panic( + "run_test index {d} out of range for {d} selected test(s)", + .{ metadata_index, selected_indices.len }, + ); + } const test_fn = builtin.test_functions[selected_indices[metadata_index]]; try server.serveStringMessage(.test_started, &.{}); From 634bf1d1e2df5860d934ec2726ba1d45db03b96d Mon Sep 17 00:00:00 2001 From: Dylan Penka Date: Thu, 2 Jul 2026 13:52:54 -0400 Subject: [PATCH 5/9] Size up the agent --- .rwx/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.rwx/ci.yml b/.rwx/ci.yml index 6109f79143a..a1c546db30b 100644 --- a/.rwx/ci.yml +++ b/.rwx/ci.yml @@ -160,9 +160,9 @@ tasks: filter: *pos-build tool-cache: ci-build-ci-x64 # persist .zig-cache + ~/.cache/zig → warm runs skip the build-runner compile & recompile only changed modules timeout: 45m - agent: # CO 8c/16g — Debug anchor; off critical path, feeds gates+leaves so keep it reasonably fast (4c gated the run) + agent: # GP 8c/32g — Debug anchor; 8c parallelism OOM'd at 16g on newer main (same as build-ci-arm64) → 32g floor for build tasks. Off crit path but feeds gates+leaves, keep 8c (4c gated the run). cpus: 8 - memory: 16gb + memory: 32gb run: | set -euo pipefail # -Droc-test-runner so the module + standalone test binaries are compiled ONCE here with From f32268ec53e028b2954c0370afe9bc56d3c0c112 Mon Sep 17 00:00:00 2001 From: Dylan Penka Date: Tue, 7 Jul 2026 17:27:31 -0400 Subject: [PATCH 6/9] RWX CI: replace compile tool caches with a build-runner precompile task Warm tool-cache restores saved ~0 compile time on real PRs: Zig caches whole compilation units, the roc binary embeds ~all of src/, and builtin_compiler imports the compiler core, so any source change reruns the builtins bake and cascades into every binary embedding compiled_builtins. Meanwhile the caches cost 6.5-13 GB layer downloads per anchor, split the inherited filesystem so every leaf pulled ~24.6 GB instead of ~14 GB when warm, and expired on a 48h TTL anyway. Dropped from all compile tasks; the vault stays for the tool caches packages declare internally. The one genuinely cacheable piece was the fixed prelude: compiling build.zig itself into the build runner (~94s at 4 cores), paid separately by every task that invokes `zig build` without an anchor upstream. New zig-build-runner-* tasks compile it once via the configure-only `zig build --list-steps` into $HOME/.zig-cache; consumers inherit it through use:. Their filter is exactly the runner's compile closure (build.zig, build.zig.zon, src/build, src/target), so source-only PRs content-cache HIT and get the prebuilt runner for free - no TTL, and it works on cold runs, which the tool cache never did. Also exclude test/snapshots from the anchor filters (negation): snapshot .md files are runtime data for the snapshot tool, not a compile input, so snapshot-only PRs now content-HIT every anchor instead of recompiling the world. Leaves keep them (snapshot_tool's unit test reads the dir at runtime). Validated across three full runs (identical code): valgrind anchor (crit path) 637s -> 565s, build-ci-x64 503s -> 433s, runner phase 3-19s on all anchors, unit-debug test count identical pre/post filter change (3207). --- .rwx/ci-arm64.yml | 32 ++++++++++-- .rwx/ci.yml | 124 +++++++++++++++++++++++++++++++--------------- 2 files changed, 110 insertions(+), 46 deletions(-) diff --git a/.rwx/ci-arm64.yml b/.rwx/ci-arm64.yml index 84642180da7..08c51dcd503 100644 --- a/.rwx/ci-arm64.yml +++ b/.rwx/ci-arm64.yml @@ -14,6 +14,8 @@ base: config: rwx/base 1.1.1 arch: arm64 +# Vault for the tool caches PACKAGES declare internally (git/clone, …) — our own compile +# tasks declare none (see ci.yml for the rationale). tool-cache: vault: dylan-roc @@ -28,7 +30,7 @@ tasks: # Extracted into $HOME (OUTSIDE the workspace) with the local zig build cache # pinned to $HOME/.zig-cache, so the positive, workspace-relative input filters - # stay pure-source without whiting out the toolchain or busting the tool-cache. + # stay pure-source without whiting out the toolchain. - key: zig-arm64 run: | set -euo pipefail @@ -62,6 +64,7 @@ tasks: - build.zig.zon - src - test + - "!test/snapshots" # runtime data for the snapshot tool, not a compile input — see ci.yml - ci - vendor - legal_details # embedded into the roc binary via addAnonymousImport (build.zig:5042) @@ -84,6 +87,24 @@ tasks: attempt=$((attempt+1)); echo ">>> transient fetch failure; retry ${attempt}/${max} in 10s" >&2; sleep 10 done + # Build-runner precompile — compile build.zig itself once into $HOME/.zig-cache so both + # anchors inherit it instead of each paying the ~90s runner compile. Narrow filter = + # the runner's exact compile inputs, so source-only PRs content-cache HIT here. + # See ci.yml zig-build-runner-x64 for the full rationale. + - key: zig-build-runner-arm64 + use: [code, zig-arm64, zig-deps-arm64] + filter: + - build.zig + - build.zig.zon + - src/build + - src/target + agent: # CO 4c/8g — one mostly-serial compile; more cores don't help it + cpus: 4 + memory: 8gb + run: | + set -euo pipefail + zig build --list-steps + # Captain CLI — inline per-test results, retry, and partitioning for the unit suites # (shared .captain/config.yml). Secret-free; RWX provides the access token automatically. - key: captain @@ -121,9 +142,10 @@ tasks: # Debug anchor (arm64 native; defaults to musl per build.zig:2217). No retry: # deps are pre-fetched, so this builds offline and fails fast on real errors. - key: build-ci-arm64 - use: [code, zig-arm64, system-kcov, zig-deps-arm64] + use: [code, zig-arm64, system-kcov, zig-deps-arm64, zig-build-runner-arm64] filter: *pos-build - tool-cache: ci-build-ci-arm64 + # No tool-cache (see ci.yml build-ci-x64): warm restores saved ~0s exec while adding + # multi-GB layer downloads to this anchor and every leaf that inherits it. timeout: 45m # arm64 build compiles roc + kcov from source; exceeds the 10m default agent: # GP 8c/32g — Debug anchor; builds kcov too, so 8c parallelism needs >16g (OOM'd at CO 8c/16g) cpus: 8 @@ -208,9 +230,9 @@ tasks: # Release anchor (arm64). No retry (deps pre-fetched). - key: build-release-arm64 - use: [code, zig-arm64, system-kcov, zig-deps-arm64] + use: [code, zig-arm64, system-kcov, zig-deps-arm64, zig-build-runner-arm64] filter: *pos-build - tool-cache: ci-build-release-arm64 + # No tool-cache (see ci.yml build-release-x64): ReleaseFast gains ~0 from a warm restore. timeout: 45m agent: # GP 16c/64g — parallel + critical path (arm leg); 16c is the balance point, needs 64g at 16c cpus: 16 diff --git a/.rwx/ci.yml b/.rwx/ci.yml index a1c546db30b..d8d1fa46097 100644 --- a/.rwx/ci.yml +++ b/.rwx/ci.yml @@ -23,8 +23,12 @@ base: image: ubuntu:24.04 config: rwx/base 1.1.1 -# Tool caches (incremental compile state) live in the scratch dylan-roc vault. -# It's unlocked, so CLI runs can write; migrates to the proper cache vault at step 3. +# Vault for tool caches. Our own compile tasks declare NO tool-cache (measured 2026-07: warm +# restores saved ~0 compile time — any src change reruns the builtins bake, cascading into +# everything — while costing multi-GB layer downloads on every anchor AND leaf, with a 48h TTL +# besides; the one real cacheable piece, the ~90s build-runner compile of build.zig, is hoisted +# into zig-build-runner-* on the content cache instead). The vault stays for the tool caches +# that PACKAGES (git/clone, …) declare internally. Migrates to the Roc org's vault at rollout. tool-cache: vault: dylan-roc @@ -90,42 +94,31 @@ tasks: with: rust-version: 1.83.0 - # ============================================================ source-only gates - # Pure source/grep/perl checks — no compiled binaries needed, so they run in - # parallel with the build instead of waiting on the anchor (build.zig:4091-4115). + # Dependency fetch — the single network boundary. Fetches all build.zig.zon deps + # once into Zig's global cache (~/.cache/zig); the whole filesystem carries + # across `use:`, so the build anchors compile offline (no per-anchor fetch idle, + # and the flaky HttpConnectionClosing surface is confined to this one retried task). # POSITIVE input filter: include ONLY the build inputs. Anything outside this set # (docs, design/, devtools/, examples/, root *.md, …) is whited out, so changes to # them are a content-cache HIT. Safe because the toolchain + zig cache live in # $HOME (outside the workspace, so the filter never touches them). `&pos-build` is - # defined here (first filtered task) and referenced by the anchors + zig-deps. - - key: source-gates + # defined here (first filtered task) and referenced by the gates + anchors. + - key: zig-deps-x64 use: [code, zig-x64] filter: &pos-build - build.zig - build.zig.zon - src - test + # Snapshot .md files are runtime DATA for the snapshot tool (and its unit test) — nothing + # the build anchors compile reads them. Excluding them means snapshot-only PRs (common: + # new/updated .md tests with no src change) content-cache HIT every anchor instead of + # recompiling the world. Leaves keep them via &pos-leaf (snapshot_tool's unit test does + # `try openDir("test/snapshots")` at runtime — fails loudly if ever whited out). + - "!test/snapshots" - ci - vendor - legal_details # embedded into the roc binary via addAnonymousImport (build.zig:5042) - run: | - set -euo pipefail - zig build run-check-zig-format - zig build run-check-type-checker-patterns - zig build run-check-enum-from-int-zero - zig build run-check-unused-suppression - zig build run-check-semantic-audit - zig build run-check-postcheck-architecture - zig build run-check-panic - zig build run-check-cli-global-stdio - - # Dependency fetch — the single network boundary. Fetches all build.zig.zon deps - # once into Zig's global cache (~/.cache/zig); the whole filesystem carries - # across `use:`, so the build anchors compile offline (no per-anchor fetch idle, - # and the flaky HttpConnectionClosing surface is confined to this one retried task). - - key: zig-deps-x64 - use: [code, zig-x64] - filter: *pos-build run: | # The ONLY network boundary — retry transient fetch flakes here (faithful # port of GHA .github/actions/flaky-retry) so the build anchors run offline @@ -144,6 +137,48 @@ tasks: attempt=$((attempt+1)); echo ">>> transient fetch failure; retry ${attempt}/${max} in 10s" >&2; sleep 10 done + # ============================================================ build-runner precompile + # Every `zig build` invocation first compiles build.zig itself into the build-runner + # executable — ~94s at 4c, paid separately by every task that runs `zig build` without an + # anchor upstream (3 anchors, tracy-check, source-gates, 6 cross targets). Compile it ONCE + # here into $HOME/.zig-cache (ZIG_LOCAL_CACHE_DIR — outside the workspace filters) with the + # configure-only `zig build --list-steps`; consumers inherit it via use: and skip straight + # to compiling real code. The NARROW filter is the point: the runner's compile inputs are + # exactly build.zig + src/build/* + src/target/* (+ dep build scripts, covered by the + # zig-deps-x64 use: edge), so source-only PRs content-cache HIT here and get the prebuilt + # runner for free — no tool-cache, no 48h TTL. Configure does no other fs reads (the check + # steps' src/ walks run at make time, not under --list-steps), so the whited-out tree is safe. + - key: zig-build-runner-x64 + use: [code, zig-x64, zig-deps-x64] + filter: + - build.zig + - build.zig.zon + - src/build + - src/target + agent: # CO 4c/8g — one mostly-serial compile; more cores don't help it + cpus: 4 + memory: 8gb + run: | + set -euo pipefail + zig build --list-steps + + # ============================================================ source-only gates + # Pure source/grep/perl checks — no compiled binaries needed, so they run in + # parallel with the build instead of waiting on the anchor (build.zig:4091-4115). + - key: source-gates + use: [code, zig-x64, zig-build-runner-x64] + filter: *pos-build + run: | + set -euo pipefail + zig build run-check-zig-format + zig build run-check-type-checker-patterns + zig build run-check-enum-from-int-zero + zig build run-check-unused-suppression + zig build run-check-semantic-audit + zig build run-check-postcheck-architecture + zig build run-check-panic + zig build run-check-cli-global-stdio + # ============================================================ DEBUG anchor # The single cached Debug compile. On Linux x86_64 the build defaults to musl + # x86_64_v3 (build.zig:2215-2227) and Debug optimize (:2229), matching GHA. @@ -152,13 +187,16 @@ tasks: # No retry: deps are pre-fetched by zig-deps-x64, so this runs offline and fails # fast on real compile errors. - key: build-ci-x64 - use: [code, zig-x64, zig-deps-x64] + use: [code, zig-x64, zig-deps-x64, zig-build-runner-x64] # Positive filter: only the build inputs affect this anchor's cache key, so any # change outside them (docs, design/, examples/, root *.md, …) is a HIT. The zig # toolchain + local cache live in $HOME (outside the workspace), so they survive - # this workspace-relative filter and the tool-cache warm-restore is untouched. + # this workspace-relative filter. filter: *pos-build - tool-cache: ci-build-ci-x64 # persist .zig-cache + ~/.cache/zig → warm runs skip the build-runner compile & recompile only changed modules + # No tool-cache: measured (2026-07) — a warm restore saved ~0s exec on a source change + # (Zig recompiles ~everything anyway) but cost a 13 GB layer download here AND split the + # inherited filesystem so every leaf pulled ~24.6 GB (tool-cache + task layer) instead of + # ~14 GB. Plus the 48h TTL means it was cold after any quiet spell. timeout: 45m agent: # GP 8c/32g — Debug anchor; 8c parallelism OOM'd at 16g on newer main (same as build-ci-arm64) → 32g floor for build tasks. Off crit path but feeds gates+leaves, keep 8c (4c gated the run). cpus: 8 @@ -431,13 +469,11 @@ tasks: exit "$rc" # Compile-only matrix (6 targets), structured like a build anchor: offline build off the deps - # boundary, own per-target tool-cache. Per-target cache name is REQUIRED — a tool cache is - # initial + 3 incremental layers and resets on the 4th, so sharing one name across the 6 matrix - # variants would thrash (each target reset the others). + # boundary. No tool-cache (see build-ci-x64): warm restores don't meaningfully cut a fresh + # zig compile, and 6 per-target caches at multi-GB layers were pure download+storage cost. - key: zig-cross-compile - use: [code, zig-x64, zig-deps-x64, zig-deps-cross-x64] + use: [code, zig-x64, zig-deps-x64, zig-deps-cross-x64, zig-build-runner-x64] filter: *pos-build - tool-cache: ci-zig-cross-${{ parallel.target }} timeout: 45m # Match the build anchors: Compute-Optimized 32c/64gb. A full `zig build` of roc is # memory-heavy (LLVM/link peak) — 32gb (CO 16c) OOMs; 64gb is the next CO tier (32c) and is @@ -468,10 +504,10 @@ tasks: path: tracy # tracy-check is a full compiler compile (with Tracy instrumentation), not a - # cheap check — it needs compile-class CPU + a longer timeout + its own - # tool-cache (distinct -Dtracy config, so it can't share build-ci's cache). + # cheap check — it needs compile-class CPU + a longer timeout. No tool-cache + # (see build-ci-x64): its warm layers ran ~8.8 GB for ~no exec saving. - key: tracy-check - use: [code, zig-x64, zig-deps-x64, tracy-src] + use: [code, zig-x64, zig-deps-x64, zig-build-runner-x64, tracy-src] # pos-build inputs plus the tracy/ clone this build consumes (-Dtracy=./tracy). # Can't reuse *pos-build (it lacks tracy/), so the include set is inline. filter: @@ -479,11 +515,11 @@ tasks: - build.zig.zon - src - test + - "!test/snapshots" # compile-only check; snapshot data is never an input (see &pos-build) - ci - vendor - legal_details - tracy - tool-cache: ci-tracy-check timeout: 45m agent: # CO 8c/16g — -Dno-bin compile-check; off-path. 8c (not 2c) is cheaper AND faster; needs 16g (8g stalls) cpus: 8 @@ -497,9 +533,10 @@ tasks: # sharing). Serves release unit tests + the static-link assertion on the # distribution binary. - key: build-release-x64 - use: [code, zig-x64, zig-deps-x64] + use: [code, zig-x64, zig-deps-x64, zig-build-runner-x64] filter: *pos-build - tool-cache: ci-build-release-x64 + # No tool-cache (see build-ci-x64): ReleaseFast is serial-bound on the roc binary; a warm + # restore saved ~0s while its layers grew to ~13 GB. timeout: 45m agent: # CO 32c/64g — kept large: genuinely parallel ReleaseFast build; shrinking cost both time and money cpus: 32 @@ -576,9 +613,14 @@ tasks: # Special config: ReleaseFast musl/x86_64_v3, -Dstrip=false + larger shared # memory (build.zig valgrind path). Distinct from the release anchor. - key: build-valgrind-x64 - use: [code, zig-x64, zig-deps-x64] + use: [code, zig-x64, zig-deps-x64, zig-build-runner-x64] filter: *pos-build - tool-cache: ci-build-valgrind-x64 + # No tool-cache (the last one dropped): its warm value was the fixed prelude — the + # build-runner compile (~94s at 4c, now covered on the content cache by + # zig-build-runner-x64) + the test-platform host libs (small) — while real src changes + # rerun builtin_compiler, whose bake cascades into everything embedding compiled_builtins, + # so compiler incrementality never survived a PR anyway. Dropping ALL tool caches also + # frees CI from needing a vault at all. timeout: 45m agent: # MO 4c/32g — serial build (cores don't help); memory headroom for the full-DWARF -Dstrip=false build cpus: 4 From 4311f41572d040753039fbbc5e454f0ac6477b6c Mon Sep 17 00:00:00 2001 From: Dylan Penka Date: Wed, 8 Jul 2026 11:35:38 -0400 Subject: [PATCH 7/9] update git/clone package version --- .rwx/ci-arm64.yml | 20 ++++++++++---------- .rwx/ci.yml | 28 ++++++++++++++-------------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/.rwx/ci-arm64.yml b/.rwx/ci-arm64.yml index 08c51dcd503..5afd8969ed9 100644 --- a/.rwx/ci-arm64.yml +++ b/.rwx/ci-arm64.yml @@ -23,7 +23,7 @@ tasks: # No .git here — preserving it busts the cache key every commit. Only the # snapshot zero-drift check needs git, via the separate code-git clone below. - key: code - call: git/clone 2.0.7 + call: git/clone 2.0.8 with: repository: https://github.com/roc-lang/roc.git ref: ${{ init.commit-sha }} @@ -64,10 +64,10 @@ tasks: - build.zig.zon - src - test - - "!test/snapshots" # runtime data for the snapshot tool, not a compile input — see ci.yml + - "!test/snapshots" # runtime data for the snapshot tool, not a compile input — see ci.yml - ci - vendor - - legal_details # embedded into the roc binary via addAnonymousImport (build.zig:5042) + - legal_details # embedded into the roc binary via addAnonymousImport (build.zig:5042) run: | set +e pattern="HttpConnectionClosing|EndOfStream|503|Timeout|WriteFailed|TemporaryNameServerFailure|cannot download|ConnectionResetByPeer|ConnectionTimedOut|FetchFailed|invalid HTTP response|bad HTTP response" @@ -98,7 +98,7 @@ tasks: - build.zig.zon - src/build - src/target - agent: # CO 4c/8g — one mostly-serial compile; more cores don't help it + agent: # CO 4c/8g — one mostly-serial compile; more cores don't help it cpus: 4 memory: 8gb run: | @@ -146,8 +146,8 @@ tasks: filter: *pos-build # No tool-cache (see ci.yml build-ci-x64): warm restores saved ~0s exec while adding # multi-GB layer downloads to this anchor and every leaf that inherits it. - timeout: 45m # arm64 build compiles roc + kcov from source; exceeds the 10m default - agent: # GP 8c/32g — Debug anchor; builds kcov too, so 8c parallelism needs >16g (OOM'd at CO 8c/16g) + timeout: 45m # arm64 build compiles roc + kcov from source; exceeds the 10m default + agent: # GP 8c/32g — Debug anchor; builds kcov too, so 8c parallelism needs >16g (OOM'd at CO 8c/16g) cpus: 8 memory: 32gb run: | @@ -172,8 +172,8 @@ tasks: - vendor - legal_details - zig-out - - .captain # Captain suite config (.captain/config.yml); without it `captain run` finds no command - agent: &test-agent # CPU-bound test leaves: CO 8c/16g + - .captain # Captain suite config (.captain/config.yml); without it `captain run` finds no command + agent: &test-agent # CPU-bound test leaves: CO 8c/16g cpus: 8 memory: 16gb run: | @@ -201,7 +201,7 @@ tasks: - vendor - legal_details - zig-out - - .captain # Captain suite config; without it `captain run` finds no command + - .captain # Captain suite config; without it `captain run` finds no command # Data files imported by the CLI test fixtures (run-test-cli): test/fx # imports CONTRIBUTING/profiling/…, test/echo/all_syntax_test.roc imports # the repo-root README.md. Both must survive the filter (FILE NOT FOUND otherwise). @@ -234,7 +234,7 @@ tasks: filter: *pos-build # No tool-cache (see ci.yml build-release-x64): ReleaseFast gains ~0 from a warm restore. timeout: 45m - agent: # GP 16c/64g — parallel + critical path (arm leg); 16c is the balance point, needs 64g at 16c + agent: # GP 16c/64g — parallel + critical path (arm leg); 16c is the balance point, needs 64g at 16c cpus: 16 memory: 64gb run: | diff --git a/.rwx/ci.yml b/.rwx/ci.yml index d8d1fa46097..264b7e45175 100644 --- a/.rwx/ci.yml +++ b/.rwx/ci.yml @@ -8,7 +8,7 @@ on: cli: init: commit-sha: ${{ event.git.sha }} - pr-number: '' + pr-number: "" # Match GHA's `concurrency: cancel-in-progress` (grouped per ref): a new commit on a PR cancels # the superseded run. Scoped to PR runs via `if` so local `rwx run` iterations (pr-number empty) @@ -38,7 +38,7 @@ tasks: # the cache key on every commit and bust hits for the build anchors and leaves. # Only the two git-specific checks use the separate `code-git` clone below. - key: code - call: git/clone 2.0.7 + call: git/clone 2.0.8 with: repository: https://github.com/roc-lang/roc.git ref: ${{ init.commit-sha }} @@ -47,7 +47,7 @@ tasks: # zero-drift `git diff`, and run-check-git-lints which otherwise skips itself). # This one busts cache per-commit by design; the expensive anchors don't. - key: code-git - call: git/clone 2.0.7 + call: git/clone 2.0.8 with: repository: https://github.com/roc-lang/roc.git ref: ${{ init.commit-sha }} @@ -118,7 +118,7 @@ tasks: - "!test/snapshots" - ci - vendor - - legal_details # embedded into the roc binary via addAnonymousImport (build.zig:5042) + - legal_details # embedded into the roc binary via addAnonymousImport (build.zig:5042) run: | # The ONLY network boundary — retry transient fetch flakes here (faithful # port of GHA .github/actions/flaky-retry) so the build anchors run offline @@ -155,7 +155,7 @@ tasks: - build.zig.zon - src/build - src/target - agent: # CO 4c/8g — one mostly-serial compile; more cores don't help it + agent: # CO 4c/8g — one mostly-serial compile; more cores don't help it cpus: 4 memory: 8gb run: | @@ -198,7 +198,7 @@ tasks: # inherited filesystem so every leaf pulled ~24.6 GB (tool-cache + task layer) instead of # ~14 GB. Plus the 48h TTL means it was cold after any quiet spell. timeout: 45m - agent: # GP 8c/32g — Debug anchor; 8c parallelism OOM'd at 16g on newer main (same as build-ci-arm64) → 32g floor for build tasks. Off crit path but feeds gates+leaves, keep 8c (4c gated the run). + agent: # GP 8c/32g — Debug anchor; 8c parallelism OOM'd at 16g on newer main (same as build-ci-arm64) → 32g floor for build tasks. Off crit path but feeds gates+leaves, keep 8c (4c gated the run). cpus: 8 memory: 32gb run: | @@ -264,7 +264,7 @@ tasks: - vendor - legal_details - zig-out - - .captain # Captain suite config (.captain/config.yml); without it `captain run` finds no command + - .captain # Captain suite config (.captain/config.yml); without it `captain run` finds no command agent: &test-agent cpus: 8 memory: 16gb @@ -317,7 +317,7 @@ tasks: - vendor - legal_details - zig-out - - .captain # Captain suite config; without it `captain run` finds no command + - .captain # Captain suite config; without it `captain run` finds no command # Data files imported by the CLI test fixtures (run-test-cli): test/fx # imports CONTRIBUTING/profiling/…, test/echo/all_syntax_test.roc imports # the repo-root README.md. Both must survive the filter or the tests fail @@ -478,7 +478,7 @@ tasks: # Match the build anchors: Compute-Optimized 32c/64gb. A full `zig build` of roc is # memory-heavy (LLVM/link peak) — 32gb (CO 16c) OOMs; 64gb is the next CO tier (32c) and is # what the anchors use to build roc with even more pressure (-Droc-test-runner -Dfuzz). - agent: # MO 4c/32g — off critical path; 4c is a free cost win, 32g holds the 32-bit-target peak + agent: # MO 4c/32g — off critical path; 4c is a free cost win, 32g holds the 32-bit-target peak cpus: 4 memory: 32gb parallel: @@ -497,7 +497,7 @@ tasks: # ============================================================ tracy compile-check # Anti-bitrot: ensure the Tracy-instrumented build still compiles (-Dno-bin). - key: tracy-src - call: git/clone 2.0.7 + call: git/clone 2.0.8 with: repository: https://github.com/wolfpld/tracy.git ref: v0.11.0 @@ -515,13 +515,13 @@ tasks: - build.zig.zon - src - test - - "!test/snapshots" # compile-only check; snapshot data is never an input (see &pos-build) + - "!test/snapshots" # compile-only check; snapshot data is never an input (see &pos-build) - ci - vendor - legal_details - tracy timeout: 45m - agent: # CO 8c/16g — -Dno-bin compile-check; off-path. 8c (not 2c) is cheaper AND faster; needs 16g (8g stalls) + agent: # CO 8c/16g — -Dno-bin compile-check; off-path. 8c (not 2c) is cheaper AND faster; needs 16g (8g stalls) cpus: 8 memory: 16gb run: | @@ -538,7 +538,7 @@ tasks: # No tool-cache (see build-ci-x64): ReleaseFast is serial-bound on the roc binary; a warm # restore saved ~0s while its layers grew to ~13 GB. timeout: 45m - agent: # CO 32c/64g — kept large: genuinely parallel ReleaseFast build; shrinking cost both time and money + agent: # CO 32c/64g — kept large: genuinely parallel ReleaseFast build; shrinking cost both time and money cpus: 32 memory: 64gb run: | @@ -622,7 +622,7 @@ tasks: # so compiler incrementality never survived a PR anyway. Dropping ALL tool caches also # frees CI from needing a vault at all. timeout: 45m - agent: # MO 4c/32g — serial build (cores don't help); memory headroom for the full-DWARF -Dstrip=false build + agent: # MO 4c/32g — serial build (cores don't help); memory headroom for the full-DWARF -Dstrip=false build cpus: 4 memory: 32gb run: | From 127ceb081596b66c82d4db12efa3c633f06229cf Mon Sep 17 00:00:00 2001 From: Dylan Penka Date: Wed, 8 Jul 2026 13:29:37 -0400 Subject: [PATCH 8/9] RWX CI: split gates by git-dependence + hoist shared aliases The old gates/git-lints split claimed to keep gates cacheable, but gates also used code-git (tidy + zig-lints need a worktree) so both tasks missed every commit. Invert the split by what actually needs git: - gates: the non-git checks (test-wiring, builtin-format, fx-coverage, serialization-sizes) on the git-less clone with the pos-build filter, so docs- and snapshot-only changes cache-hit instead of paying the ~150s re-check. - git-gates: the worktree-dependent checks (zig-lints, tidy, git-lints, plan.md gate) on code-git; re-runs every commit by design but is only ~15s of exec. Also move the shared YAML anchors (pos-build, pos-leaf, pos-cli, test-agent) into top-level aliases: blocks in both files, compose pos-cli from pos-leaf via array flattening (deduping the two hand-copied filter lists on the arm64 leg), and trim stale comments. Validated on RWX: gates+git-gates run 7cdd4352 green with every check's success output present in the logs; cli-llvm run 5deb2cd2 green with the flattened pos-cli filter (fixture imports found). --- .rwx/ci-arm64.yml | 104 +++++++++------------- .rwx/ci.yml | 217 ++++++++++++++++------------------------------ 2 files changed, 114 insertions(+), 207 deletions(-) diff --git a/.rwx/ci-arm64.yml b/.rwx/ci-arm64.yml index 5afd8969ed9..9c16c78e3cf 100644 --- a/.rwx/ci-arm64.yml +++ b/.rwx/ci-arm64.yml @@ -1,9 +1,3 @@ -# arm64 Linux leg — embedded run (called from ci.yml). arch is run-level, so the -# arm64 matrix lives in its own definition with base.arch: arm64. Runs the -# arch-sensitive subset of the x64 DAG plus kcov parser coverage (arm64-only: -# x86_64 hits a Zig DWARF bug, so coverage runs here today). -# on.cli lets this leg be iterated directly (`rwx run .rwx/ci-arm64.yml --target -# build-ci-arm64`); it's ignored when this file is embedded via ci.yml. on: cli: init: @@ -19,6 +13,40 @@ base: tool-cache: vault: dylan-roc +# Shared filters/agent shapes — same as ci.yml (see there for the full rationale). +aliases: + pos-build: &pos-build + - build.zig + - build.zig.zon + - src + - test + - "!test/snapshots" # runtime data for the snapshot tool, not a compile input — see ci.yml + - ci + - vendor + - legal_details # embedded into the roc binary via addAnonymousImport (build.zig:5042) + + pos-leaf: &pos-leaf + - build.zig + - build.zig.zon + - src + - test + - ci + - vendor + - legal_details + - zig-out + - .captain # Captain suite config (.captain/config.yml); without it `captain run` finds no command + + # Leaf inputs + data files imported by the CLI/platform test fixtures (test/fx imports + # CONTRIBUTING/profiling/…, test/echo/all_syntax_test.roc imports the repo-root README.md). + pos-cli: &pos-cli + - *pos-leaf # arrays flatten, so this composes pos-leaf + the two extra entries + - CONTRIBUTING/profiling + - README.md + + test-agent: &test-agent # CPU-bound test leaves: CO 8c/16g + cpus: 8 + memory: 16gb + tasks: # No .git here — preserving it busts the cache key every commit. Only the # snapshot zero-drift check needs git, via the separate code-git clone below. @@ -56,18 +84,10 @@ tasks: # sync). Both land in ~/.cache/zig, which carries across `use:`. # POSITIVE input filter (include only build inputs). Toolchain + local zig cache # live in $HOME (outside the workspace), so this workspace-relative filter never - # drops them. `&pos-build` defined here (first filtered task on this leg). + # drops them. - key: zig-deps-arm64 use: [code, zig-arm64] - filter: &pos-build - - build.zig - - build.zig.zon - - src - - test - - "!test/snapshots" # runtime data for the snapshot tool, not a compile input — see ci.yml - - ci - - vendor - - legal_details # embedded into the roc binary via addAnonymousImport (build.zig:5042) + filter: *pos-build run: | set +e pattern="HttpConnectionClosing|EndOfStream|503|Timeout|WriteFailed|TemporaryNameServerFailure|cannot download|ConnectionResetByPeer|ConnectionTimedOut|FetchFailed|invalid HTTP response|bad HTTP response" @@ -156,26 +176,11 @@ tasks: # and only run them (see ci.yml build-ci-x64). zig build build-ci -Droc-test-runner -Dfuzz -Dsystem-afl=false - # Test-execution leaves: CPU-bound, memory-light → compute-optimized 8c/16g. - # Leaf positive filter: build inputs + the anchor's compiled binaries (zig-out), - # consumed via the filesystem layer. `.zig-cache` + toolchain are in $HOME, so - # they're inherited unfiltered. `&pos-leaf` defined here (first leaf). # Captain-partitioned module unit tests (see .captain/config.yml, suite roc-unit-debug). - key: unit-debug use: [build-ci-arm64, captain] - filter: &pos-leaf - - build.zig - - build.zig.zon - - src - - test - - ci - - vendor - - legal_details - - zig-out - - .captain # Captain suite config (.captain/config.yml); without it `captain run` finds no command - agent: &test-agent # CPU-bound test leaves: CO 8c/16g - cpus: 8 - memory: 16gb + filter: *pos-leaf + agent: *test-agent run: | set -euo pipefail captain run roc-unit-debug @@ -186,27 +191,9 @@ tasks: # NB: snapshot tests are arch-independent (compiler front-end output, not # codegen), so they run once on x64 (ci.yml) — not duplicated here. - # run-test-cli exercises the fx platform tests (test/fx/file_import_str.roc imports - # CONTRIBUTING/profiling/...), and this leaf roc-checks a CONTRIBUTING/profiling - # file, so it keeps CONTRIBUTING/profiling on top of the leaf inputs (only - # CONTRIBUTING reader on this leg, so the list is inline rather than anchored). - key: platforms use: [build-ci-arm64, captain, rust-lang] - filter: - - build.zig - - build.zig.zon - - src - - test - - ci - - vendor - - legal_details - - zig-out - - .captain # Captain suite config; without it `captain run` finds no command - # Data files imported by the CLI test fixtures (run-test-cli): test/fx - # imports CONTRIBUTING/profiling/…, test/echo/all_syntax_test.roc imports - # the repo-root README.md. Both must survive the filter (FILE NOT FOUND otherwise). - - CONTRIBUTING/profiling - - README.md + filter: *pos-cli agent: *test-agent run: | set -euo pipefail @@ -270,18 +257,7 @@ tasks: # unit-misc). Needs pos-cli inputs (fx fixtures import CONTRIBUTING/profiling + README.md). - key: platform-tests use: [build-ci-arm64, captain] - filter: - - build.zig - - build.zig.zon - - src - - test - - ci - - vendor - - legal_details - - zig-out - - .captain - - CONTRIBUTING/profiling - - README.md + filter: *pos-cli agent: *test-agent run: | set -euo pipefail diff --git a/.rwx/ci.yml b/.rwx/ci.yml index 264b7e45175..c01f12738c1 100644 --- a/.rwx/ci.yml +++ b/.rwx/ci.yml @@ -1,5 +1,4 @@ on: - # Match GHA (ci_manager.yml) — CI runs on pull requests. No push trigger (GHA has none here). github: pull_request: init: @@ -10,9 +9,6 @@ on: commit-sha: ${{ event.git.sha }} pr-number: "" -# Match GHA's `concurrency: cancel-in-progress` (grouped per ref): a new commit on a PR cancels -# the superseded run. Scoped to PR runs via `if` so local `rwx run` iterations (pr-number empty) -# aren't cancelled. Pool ids are org-global, so the id is repo-qualified to avoid collisions. concurrency-pools: - id: roc-lang/roc:ci-pr-${{ init.pr-number }} if: ${{ init.pr-number != '' }} @@ -23,29 +19,65 @@ base: image: ubuntu:24.04 config: rwx/base 1.1.1 -# Vault for tool caches. Our own compile tasks declare NO tool-cache (measured 2026-07: warm -# restores saved ~0 compile time — any src change reruns the builtins bake, cascading into -# everything — while costing multi-GB layer downloads on every anchor AND leaf, with a 48h TTL -# besides; the one real cacheable piece, the ~90s build-runner compile of build.zig, is hoisted -# into zig-build-runner-* on the content cache instead). The vault stays for the tool caches -# that PACKAGES (git/clone, …) declare internally. Migrates to the Roc org's vault at rollout. +# Only used by packages for now. We don't currently use tool caches for our compiles, since any +# source change ends up rebuilding everything, and using a tool cache increases the size of +# the layers pulled at the beginning of the task tool-cache: vault: dylan-roc +aliases: + # Build-input filter: only these paths affect a task's cache key, so changes outside + # them (docs, design/, examples/, root *.md, …) are a HIT. The zig toolchain + local + # cache live in $HOME (outside the workspace), so workspace-relative filters never + # touch them. + pos-build: &pos-build + - build.zig + - build.zig.zon + - src + - test + # Snapshot .md files are runtime DATA for the snapshot tool, not build inputs — + # excluding them lets snapshot-only PRs cache-hit the anchors. Leaves keep them + # via pos-leaf (snapshot_tool's unit test reads test/snapshots at runtime). + - "!test/snapshots" + - ci + - vendor + - legal_details # embedded into the roc binary via addAnonymousImport (build.zig:5042) + + # Test-leaf filter: build inputs + the anchor's compiled binaries (zig-out, consumed + # at the cwd-relative project path) + Captain config. `.zig-cache` + toolchain are in + # $HOME, so they're inherited unfiltered. + pos-leaf: &pos-leaf + - build.zig + - build.zig.zon + - src + - test + - ci + - vendor + - legal_details + - zig-out + - .captain # Captain suite config (.captain/config.yml); without it `captain run` finds no command + + # CLI-test filter: leaf inputs + data files imported by the run-test-cli fixtures + # (test/fx imports CONTRIBUTING/profiling/…, test/echo/all_syntax_test.roc imports + # the repo-root README.md — the tests fail FILE NOT FOUND without them). + pos-cli: &pos-cli + - *pos-leaf # arrays flatten, so this composes pos-leaf + the two extra entries + - CONTRIBUTING/profiling + - README.md + + # Test-execution leaves: CPU-bound and memory-light, but OOM on the default 2c/8g — + # compute-optimized 8c/16g. Cheap lint/format/check leaves stay on the default. + test-agent: &test-agent + cpus: 8 + memory: 16gb + tasks: - # ============================================================ setup - # Clone roc (public repo, tokenless). NO .git here: preserving it would change - # the cache key on every commit and bust hits for the build anchors and leaves. - # Only the two git-specific checks use the separate `code-git` clone below. - key: code call: git/clone 2.0.8 with: repository: https://github.com/roc-lang/roc.git ref: ${{ init.commit-sha }} - # Git-bearing clone — ONLY for checks that genuinely need .git (snapshot - # zero-drift `git diff`, and run-check-git-lints which otherwise skips itself). - # This one busts cache per-commit by design; the expensive anchors don't. - key: code-git call: git/clone 2.0.8 with: @@ -53,13 +85,7 @@ tasks: ref: ${{ init.commit-sha }} preserve-git-dir: true - # Install pinned Zig 0.16.0 (no RWX zig package exists). Extracted into $HOME - # (OUTSIDE the workspace) and PATH-ed there; $PATH is auto-merged across `use:` - # deps. Living outside /var/mint-workspace is what lets the positive, workspace- - # relative input filters stay pure-source without whiting out the toolchain. - # The local zig build cache is pinned to $HOME/.zig-cache for the same reason - # (so the anchors' positive filter need not list — and thereby churn on — it). - # Dependency-download is isolated here as the single retryable network boundary. + # Install zig and add to PATH (with resilience retry) - key: zig-x64 run: | set -euo pipefail @@ -78,52 +104,20 @@ tasks: echo "$HOME/.zig-cache" > "$RWX_ENV/ZIG_LOCAL_CACHE_DIR" "$HOME/${ZIG_DIR}/zig" version - # Captain CLI — inline per-test results, targeted retry, and timing-balanced - # partitioning for the Zig unit suites (see .captain/config.yml). Secret-free: - # RWX provides Captain its access token automatically. - key: captain call: rwx/install-captain 1.1.7 - # Rust toolchain — the CLI glue matrix (run-test-cli, suite cli-platforms) compiles generated - # Rust glue with `rustc` (src/cli/test/parallel_cli_runner.zig:compileGeneratedRustGlue). GHA - # gets rustc free from the GitHub runner image; the rwx/base image has none, so install it here. - # The leaf exports ~/.cargo/bin onto PATH (carried across use:, like the $HOME Zig toolchain). - # Only the `platforms` task needs it (cli-llvm filters glue out). - key: rust-lang call: rust-lang/install 1.0.7 with: rust-version: 1.83.0 - # Dependency fetch — the single network boundary. Fetches all build.zig.zon deps - # once into Zig's global cache (~/.cache/zig); the whole filesystem carries - # across `use:`, so the build anchors compile offline (no per-anchor fetch idle, - # and the flaky HttpConnectionClosing surface is confined to this one retried task). - # POSITIVE input filter: include ONLY the build inputs. Anything outside this set - # (docs, design/, devtools/, examples/, root *.md, …) is whited out, so changes to - # them are a content-cache HIT. Safe because the toolchain + zig cache live in - # $HOME (outside the workspace, so the filter never touches them). `&pos-build` is - # defined here (first filtered task) and referenced by the gates + anchors. + # Fetches network dependencies for zig build - key: zig-deps-x64 use: [code, zig-x64] - filter: &pos-build - - build.zig - - build.zig.zon - - src - - test - # Snapshot .md files are runtime DATA for the snapshot tool (and its unit test) — nothing - # the build anchors compile reads them. Excluding them means snapshot-only PRs (common: - # new/updated .md tests with no src change) content-cache HIT every anchor instead of - # recompiling the world. Leaves keep them via &pos-leaf (snapshot_tool's unit test does - # `try openDir("test/snapshots")` at runtime — fails loudly if ever whited out). - - "!test/snapshots" - - ci - - vendor - - legal_details # embedded into the roc binary via addAnonymousImport (build.zig:5042) + filter: *pos-build run: | - # The ONLY network boundary — retry transient fetch flakes here (faithful - # port of GHA .github/actions/flaky-retry) so the build anchors run offline - # and need no retry of their own. RWX injects `set -e`; `set +e` lets us - # inspect+retry, and $PIPESTATUS (bash) reads the exit through the tee pipe. + # `set +e` lets us inspect and retry fetch errors # NOTE: `--fetch` only gets EAGER build.zig.zon deps. x86_64 build-ci needs # only those (it skips kcov), so no lazy `zig fetch` is required here. set +e @@ -137,17 +131,7 @@ tasks: attempt=$((attempt+1)); echo ">>> transient fetch failure; retry ${attempt}/${max} in 10s" >&2; sleep 10 done - # ============================================================ build-runner precompile - # Every `zig build` invocation first compiles build.zig itself into the build-runner - # executable — ~94s at 4c, paid separately by every task that runs `zig build` without an - # anchor upstream (3 anchors, tracy-check, source-gates, 6 cross targets). Compile it ONCE - # here into $HOME/.zig-cache (ZIG_LOCAL_CACHE_DIR — outside the workspace filters) with the - # configure-only `zig build --list-steps`; consumers inherit it via use: and skip straight - # to compiling real code. The NARROW filter is the point: the runner's compile inputs are - # exactly build.zig + src/build/* + src/target/* (+ dep build scripts, covered by the - # zig-deps-x64 use: edge), so source-only PRs content-cache HIT here and get the prebuilt - # runner for free — no tool-cache, no 48h TTL. Configure does no other fs reads (the check - # steps' src/ walks run at make time, not under --list-steps), so the whited-out tree is safe. + # Compiles `build.zig` into the build-runner executable for use downstream. - key: zig-build-runner-x64 use: [code, zig-x64, zig-deps-x64] filter: @@ -155,16 +139,14 @@ tasks: - build.zig.zon - src/build - src/target - agent: # CO 4c/8g — one mostly-serial compile; more cores don't help it + agent: cpus: 4 memory: 8gb run: | set -euo pipefail zig build --list-steps - # ============================================================ source-only gates - # Pure source/grep/perl checks — no compiled binaries needed, so they run in - # parallel with the build instead of waiting on the anchor (build.zig:4091-4115). + # Don't need compiled binaries so we can run in parallel with build - key: source-gates use: [code, zig-x64, zig-build-runner-x64] filter: *pos-build @@ -179,59 +161,45 @@ tasks: zig build run-check-panic zig build run-check-cli-global-stdio - # ============================================================ DEBUG anchor - # The single cached Debug compile. On Linux x86_64 the build defaults to musl + - # x86_64_v3 (build.zig:2215-2227) and Debug optimize (:2229), matching GHA. - # Consumed by every debug-config leaf; eval/cli --llvm are runtime flags (LLVM - # is statically linked unconditionally) so they reuse this anchor too. - # No retry: deps are pre-fetched by zig-deps-x64, so this runs offline and fails - # fast on real compile errors. - key: build-ci-x64 use: [code, zig-x64, zig-deps-x64, zig-build-runner-x64] - # Positive filter: only the build inputs affect this anchor's cache key, so any - # change outside them (docs, design/, examples/, root *.md, …) is a HIT. The zig - # toolchain + local cache live in $HOME (outside the workspace), so they survive - # this workspace-relative filter. filter: *pos-build - # No tool-cache: measured (2026-07) — a warm restore saved ~0s exec on a source change - # (Zig recompiles ~everything anyway) but cost a 13 GB layer download here AND split the - # inherited filesystem so every leaf pulled ~24.6 GB (tool-cache + task layer) instead of - # ~14 GB. Plus the 48h TTL means it was cold after any quiet spell. + # No tool cache today because zig ends up recompiling everything anyways when source changes. timeout: 45m - agent: # GP 8c/32g — Debug anchor; 8c parallelism OOM'd at 16g on newer main (same as build-ci-arm64) → 32g floor for build tasks. Off crit path but feeds gates+leaves, keep 8c (4c gated the run). + agent: cpus: 8 memory: 32gb run: | set -euo pipefail # -Droc-test-runner so the module + standalone test binaries are compiled ONCE here with # the custom runner; the Captain unit partitions then cache-hit and only RUN (no per- - # partition recompile). Doesn't affect the roc compiler / check tools / other runners. + # partition recompile). zig build build-ci -Droc-test-runner -Dfuzz -Dsystem-afl=false - # Tool/roc-dependent checks (need the prebuilt check tools + debug roc). - # Uses code-git: run-check-tidy (git ls-files) and run-check-zig-lints - # (git diff vs origin/main) both require a git worktree. + # Tool/roc-dependent checks that don't need git. Filtered like the build, so + # docs- or snapshot-only changes cache-hit instead of paying the ~150s re-check + # (run-check-builtin-format relinks roc, the long pole). - key: gates - use: [build-ci-x64, code-git] + use: [build-ci-x64] + filter: *pos-build run: | set -euo pipefail - zig build run-check-zig-lints - zig build run-check-tidy zig build run-check-test-wiring zig build run-check-builtin-format zig build run-check-fx-platform-test-coverage zig build run-test-serialization-sizes - # git-lints needs .git (run-check-git-lints skips itself without a worktree), - # so it uses code-git. Kept separate from `gates` so the other checks stay - # cacheable across commits. - - key: git-lints + # Checks that need a real git worktree, via code-git: zig-lints (git diff), + # tidy (git ls-files), git-lints (skips itself without .git), and the plan.md + # check. Re-runs every commit by design, but is only ~15s of exec. + - key: git-gates use: [build-ci-x64, code-git] run: | set -euo pipefail + zig build run-check-zig-lints + zig build run-check-tidy zig build run-check-git-lints - # GHA ci_zig.yml `check-once` parity: no tracked plan.md files allowed. Needs a real - # git worktree (git ls-files), which code-git provides and the .git-less `code` clone does not. + # GHA ci_zig.yml `check-once` parity: no tracked plan.md files allowed. matches="$(git ls-files -- ':(glob)**/plan.md')" if [ -n "$matches" ]; then echo "Tracked plan.md files are not allowed:" @@ -239,9 +207,6 @@ tasks: exit 1 fi - # Test-execution leaves need more than the default 2c/8g (they OOM), but are - # CPU-bound and memory-light — so compute-optimized 8c/16g (not 8c/32g) via the - # &test-agent alias. Cheap lint/format/check leaves stay on 2c/8g. # Captain runs roc's custom runner (-Droc-test-runner; binaries pre-built by the anchor so # this only RUNS them), emits rwx-v1-json per module, and gives inline per-test results + # "retry failed tests" via outputs.test-results. See .captain/config.yml (roc-unit-debug). @@ -251,23 +216,8 @@ tasks: # `parallel: N` here if the suite grows enough to be worth it. - key: unit-debug use: [build-ci-x64, captain] - # Leaf positive filter: build inputs + the anchor's compiled binaries (zig-out). - # zig-out is consumed via the filesystem layer (zig build run-* / ./zig-out/bin - # expect it at the cwd-relative project path). `.zig-cache` + toolchain are in - # $HOME, so they're inherited unfiltered. Defined here (first leaf). - filter: &pos-leaf - - build.zig - - build.zig.zon - - src - - test - - ci - - vendor - - legal_details - - zig-out - - .captain # Captain suite config (.captain/config.yml); without it `captain run` finds no command - agent: &test-agent - cpus: 8 - memory: 16gb + filter: *pos-leaf + agent: *test-agent run: | set -euo pipefail captain run roc-unit-debug @@ -302,28 +252,9 @@ tasks: test-results: - path: tmp/captain/roc-eval.json - # run-test-cli exercises the fx platform tests, whose test/fx/file_import_str.roc - # imports CONTRIBUTING/profiling/bench_repeated_check_ORIGINAL.roc — so this leaf - # (and `platforms`) must keep CONTRIBUTING/profiling on top of the leaf inputs. - # `&pos-cli` defined here; referenced by `platforms` below. - key: cli-llvm use: [build-ci-x64, captain] - filter: &pos-cli - - build.zig - - build.zig.zon - - src - - test - - ci - - vendor - - legal_details - - zig-out - - .captain # Captain suite config; without it `captain run` finds no command - # Data files imported by the CLI test fixtures (run-test-cli): test/fx - # imports CONTRIBUTING/profiling/…, test/echo/all_syntax_test.roc imports - # the repo-root README.md. Both must survive the filter or the tests fail - # with FILE NOT FOUND. - - CONTRIBUTING/profiling - - README.md + filter: *pos-cli agent: *test-agent run: | set -euo pipefail From 29709d17f429696e9842e03c75cf71cdaff66f2f Mon Sep 17 00:00:00 2001 From: Dylan Penka Date: Wed, 8 Jul 2026 13:54:48 -0400 Subject: [PATCH 9/9] RWX CI: trim comments + compose tracy-check filter from pos-build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comment-only cleanup across both files (the detailed rationale lives in the migration notes / git history), plus one real change: tracy-check's filter now reuses the pos-build alias via array flattening instead of an inline copy — resolves to the identical path set. --- .rwx/ci-arm64.yml | 58 ++++---------------------- .rwx/ci.yml | 102 ++++------------------------------------------ 2 files changed, 15 insertions(+), 145 deletions(-) diff --git a/.rwx/ci-arm64.yml b/.rwx/ci-arm64.yml index 9c16c78e3cf..90550051980 100644 --- a/.rwx/ci-arm64.yml +++ b/.rwx/ci-arm64.yml @@ -8,7 +8,7 @@ base: config: rwx/base 1.1.1 arch: arm64 -# Vault for the tool caches PACKAGES declare internally (git/clone, …) — our own compile +# Vault for the tool caches PACKAGES declare internally (git/clone, …), our own compile # tasks declare none (see ci.yml for the rationale). tool-cache: vault: dylan-roc @@ -34,7 +34,7 @@ aliases: - vendor - legal_details - zig-out - - .captain # Captain suite config (.captain/config.yml); without it `captain run` finds no command + - .captain # Leaf inputs + data files imported by the CLI/platform test fixtures (test/fx imports # CONTRIBUTING/profiling/…, test/echo/all_syntax_test.roc imports the repo-root README.md). @@ -48,8 +48,6 @@ aliases: memory: 16gb tasks: - # No .git here — preserving it busts the cache key every commit. Only the - # snapshot zero-drift check needs git, via the separate code-git clone below. - key: code call: git/clone 2.0.8 with: @@ -77,14 +75,7 @@ tasks: echo "$HOME/.zig-cache" > "$RWX_ENV/ZIG_LOCAL_CACHE_DIR" "$HOME/${ZIG_DIR}/zig" version - # The ONLY network boundary — retry transient fetch flakes here so the build - # anchors run offline and need no retry. `--fetch` fetches only EAGER deps; - # kcov is a LAZY dep (build.zig.zon `lazy = true`) that arm64 build-ci needs, - # so we additionally `zig fetch` it (URL read from build.zig.zon to stay in - # sync). Both land in ~/.cache/zig, which carries across `use:`. - # POSITIVE input filter (include only build inputs). Toolchain + local zig cache - # live in $HOME (outside the workspace), so this workspace-relative filter never - # drops them. + # Fetches network dependencies for zig build - key: zig-deps-arm64 use: [code, zig-arm64] filter: *pos-build @@ -107,10 +98,7 @@ tasks: attempt=$((attempt+1)); echo ">>> transient fetch failure; retry ${attempt}/${max} in 10s" >&2; sleep 10 done - # Build-runner precompile — compile build.zig itself once into $HOME/.zig-cache so both - # anchors inherit it instead of each paying the ~90s runner compile. Narrow filter = - # the runner's exact compile inputs, so source-only PRs content-cache HIT here. - # See ci.yml zig-build-runner-x64 for the full rationale. + # Precompile build runner - key: zig-build-runner-arm64 use: [code, zig-arm64, zig-deps-arm64] filter: @@ -118,35 +106,26 @@ tasks: - build.zig.zon - src/build - src/target - agent: # CO 4c/8g — one mostly-serial compile; more cores don't help it + agent: cpus: 4 memory: 8gb run: | set -euo pipefail zig build --list-steps - # Captain CLI — inline per-test results, retry, and partitioning for the unit suites - # (shared .captain/config.yml). Secret-free; RWX provides the access token automatically. - key: captain call: rwx/install-captain 1.1.7 - # Rust toolchain — the CLI glue matrix (run-test-cli, suite cli-platforms) compiles generated - # Rust glue with `rustc`. GHA gets it free from the runner image; rwx/base has none. The leaf - # exports ~/.cargo/bin onto PATH (arch auto-detected via uname -m, so aarch64 here). Only the - # `platforms` task needs it. - key: rust-lang call: rust-lang/install 1.0.7 with: rust-version: 1.83.0 - # System packages not on the rwx/base image, pulled into their own cached tasks. - key: system-file run: | set -euo pipefail sudo apt-get update && sudo apt-get install -y file - # kcov build/link deps. On arm64 `zig build build-ci` builds kcov (coverage - # tools); x86_64 build-ci skips kcov (Zig DWARF bug), so this is arm64-only. - key: system-kcov run: | set -euo pipefail @@ -159,24 +138,17 @@ tasks: sudo apt-get update && sudo apt-get install -y \ pkg-config libdw-dev libelf-dev libcurl4-openssl-dev zlib1g-dev - # Debug anchor (arm64 native; defaults to musl per build.zig:2217). No retry: - # deps are pre-fetched, so this builds offline and fails fast on real errors. - key: build-ci-arm64 use: [code, zig-arm64, system-kcov, zig-deps-arm64, zig-build-runner-arm64] filter: *pos-build - # No tool-cache (see ci.yml build-ci-x64): warm restores saved ~0s exec while adding - # multi-GB layer downloads to this anchor and every leaf that inherits it. - timeout: 45m # arm64 build compiles roc + kcov from source; exceeds the 10m default - agent: # GP 8c/32g — Debug anchor; builds kcov too, so 8c parallelism needs >16g (OOM'd at CO 8c/16g) + timeout: 45m + agent: cpus: 8 memory: 32gb run: | set -euo pipefail - # -Droc-test-runner: pre-build the custom-runner test binaries so the unit tasks cache-hit - # and only run them (see ci.yml build-ci-x64). zig build build-ci -Droc-test-runner -Dfuzz -Dsystem-afl=false - # Captain-partitioned module unit tests (see .captain/config.yml, suite roc-unit-debug). - key: unit-debug use: [build-ci-arm64, captain] filter: *pos-leaf @@ -188,9 +160,6 @@ tasks: test-results: - path: tmp/captain/roc-unit-debug.json - # NB: snapshot tests are arch-independent (compiler front-end output, not - # codegen), so they run once on x64 (ci.yml) — not duplicated here. - - key: platforms use: [build-ci-arm64, captain, rust-lang] filter: *pos-cli @@ -203,10 +172,6 @@ tasks: test-results: - path: tmp/captain/roc-cli-platforms.json - # Parser coverage via kcov (arm64-only). kcov is built from the build.zig.zon - # dep; it needs the system-kcov libs at runtime too. - # system-kcov must precede build-ci-arm64 in `use:` because build-ci-arm64 - # itself uses system-kcov (a dependency must be listed before its dependents). - key: coverage-parser use: [system-kcov, build-ci-arm64] filter: *pos-leaf @@ -215,21 +180,17 @@ tasks: set -euo pipefail zig build run-coverage-parser - # Release anchor (arm64). No retry (deps pre-fetched). - key: build-release-arm64 use: [code, zig-arm64, system-kcov, zig-deps-arm64, zig-build-runner-arm64] filter: *pos-build - # No tool-cache (see ci.yml build-release-x64): ReleaseFast gains ~0 from a warm restore. timeout: 45m - agent: # GP 16c/64g — parallel + critical path (arm leg); 16c is the balance point, needs 64g at 16c + agent: cpus: 16 memory: 64gb run: | set -euo pipefail - # -Droc-test-runner: pre-build custom-runner test binaries so unit-release cache-hits. zig build build-ci -Droc-test-runner -Doptimize=ReleaseFast -Dfuzz -Dsystem-afl=false - # Module unit tests in ReleaseFast via Captain (single task — not worth partitioning). - key: unit-release use: [build-release-arm64, captain] filter: *pos-leaf @@ -241,7 +202,6 @@ tasks: test-results: - path: tmp/captain/roc-unit-release.json - # LSP integration specs via Captain (bespoke fork-based runner; was part of plain unit-misc). - key: lsp use: [build-ci-arm64, captain] filter: *pos-leaf @@ -253,8 +213,6 @@ tasks: test-results: - path: tmp/captain/roc-lsp.json - # fx / http / json / watch platform tests via Captain (Zig-native; was part of plain - # unit-misc). Needs pos-cli inputs (fx fixtures import CONTRIBUTING/profiling + README.md). - key: platform-tests use: [build-ci-arm64, captain] filter: *pos-cli diff --git a/.rwx/ci.yml b/.rwx/ci.yml index c01f12738c1..d6c18cdf6a5 100644 --- a/.rwx/ci.yml +++ b/.rwx/ci.yml @@ -207,13 +207,6 @@ tasks: exit 1 fi - # Captain runs roc's custom runner (-Droc-test-runner; binaries pre-built by the anchor so - # this only RUNS them), emits rwx-v1-json per module, and gives inline per-test results + - # "retry failed tests" via outputs.test-results. See .captain/config.yml (roc-unit-debug). - # NOT partitioned: with the anchor pre-build, running all ~2800 module tests is ~14s, so - # `parallel:` only saved ~9s wall while paying the ~64s/agent fixed cost (tool-cache download - # + build-graph eval) N times. The partition config/globs/flags stay wired — re-add - # `parallel: N` here if the suite grows enough to be worth it. - key: unit-debug use: [build-ci-x64, captain] filter: *pos-leaf @@ -225,12 +218,6 @@ tasks: test-results: - path: tmp/captain/roc-unit-debug.json - # 4c/8g — measured faster AND more consistent than 2c (4c ~167-174s vs 2c ~181-270s - # exec, benchmarked with cache:false). The snapshot *regeneration* is single-threaded - # under --debug (src/snapshot_tool/main.zig:726-728), but the rest of the exec (LLVM - # relink of snapshot_exe, write_compiled_builtins, build runner) is multi-threaded and - # benefits; 2c also contends more (high variance). 4 cores is the sweet spot — the - # single-threaded regen means more cores wouldn't help further. - key: snapshots use: [build-ci-x64, code-git] agent: @@ -321,36 +308,8 @@ tasks: zig build "run-repro-${target}" -- "$ROC_FUZZ_INPUT" done - # ============================================================ cross-compile (compile-only) - # GHA ci_zig.yml `zig-cross-compile`: compile-only checks that roc builds for each cross - # target (no execution). Runs on every build like the anchors; the *pos-build filter makes - # docs/test-only changes a HIT. x64-only — cross output is build-host-independent and GHA - # runs it x64-only too. - # - # The roc-cross-compile / on-target-run job (ci_cross_compile.yml) deliberately STAYS on GHA: - # it builds from 4 hosts (3 are mac/win — never on RWX) and its on-target check runs every - # host's binary together on one target runner. RWX can't reproduce that — arch is run-level so - # arm64 is an embedded run, and embedded runs are filesystem-isolated (only string init params - # cross the boundary, never the cross-built binary). So it's part of the mac/win GHA remainder. - # - # Each cross target lazily pulls its roc_deps_ (roc-bootstrap prebuilt deps). `zig build - # --fetch` only gets EAGER deps, so pre-fetch the lazy per-target deps here as a dedicated, - # retryable network boundary — kept separate from zig-deps-x64 so cross-dep churn doesn't bust - # the native anchor deps cache. Mirrors the arm64 zig-deps lazy-fetch pattern. - key: zig-deps-cross-x64 use: [code, zig-x64] - # Narrow key: this task only reads build.zig.zon (greps the dep URLs) but build.zig MUST be in - # the filter too — `zig fetch ` errors "no build.zig file found" without a project root - # (validated). Keying on just these two — NOT *pos-build — means a src/test change is a HIT here; - # it only re-runs when build.zig OR build.zig.zon changes. - # No tool-cache on purpose: `zig fetch ` is URL-keyed (it downloads, THEN hashes), so it - # re-downloads even when the package is already in the global cache — verified: a warm 2.3 GB - # tool-cache restored, yet every dep still re-fetched. A tool-cache would just move the same - # ~2.3 GB over the wire for nothing. (Zig's hash-skip lives in `zig build`, not `zig fetch`.) - # Instead the 6 prebuilt bundles fetch in PARALLEL — independent downloads into distinct - # p/ dirs. `zig fetch` is CPU-bound on decompression (`.tar.xz`/`.zip`, user≈real), not - # network, so wall time scales with cores: 4 cpus runs the 6 extractions in ~2 waves (~100s vs - # the ~290s serial sum). Memory stays small (8gb) — fetch/extract isn't memory-heavy. agent: cpus: 4 memory: 8gb @@ -399,19 +358,13 @@ tasks: for p in $pids; do wait "$p" || rc=1; done exit "$rc" - # Compile-only matrix (6 targets), structured like a build anchor: offline build off the deps - # boundary. No tool-cache (see build-ci-x64): warm restores don't meaningfully cut a fresh - # zig compile, and 6 per-target caches at multi-GB layers were pure download+storage cost. - key: zig-cross-compile use: [code, zig-x64, zig-deps-x64, zig-deps-cross-x64, zig-build-runner-x64] filter: *pos-build timeout: 45m - # Match the build anchors: Compute-Optimized 32c/64gb. A full `zig build` of roc is - # memory-heavy (LLVM/link peak) — 32gb (CO 16c) OOMs; 64gb is the next CO tier (32c) and is - # what the anchors use to build roc with even more pressure (-Droc-test-runner -Dfuzz). - agent: # MO 4c/32g — off critical path; 4c is a free cost win, 32g holds the 32-bit-target peak + agent: cpus: 4 - memory: 32gb + memory: 32gb # OOMs with any less memory parallel: matrix: target: @@ -425,8 +378,6 @@ tasks: set -euo pipefail zig build -Dtarget=${{ parallel.target }} - # ============================================================ tracy compile-check - # Anti-bitrot: ensure the Tracy-instrumented build still compiles (-Dno-bin). - key: tracy-src call: git/clone 2.0.8 with: @@ -434,52 +385,32 @@ tasks: ref: v0.11.0 path: tracy - # tracy-check is a full compiler compile (with Tracy instrumentation), not a - # cheap check — it needs compile-class CPU + a longer timeout. No tool-cache - # (see build-ci-x64): its warm layers ran ~8.8 GB for ~no exec saving. + # tracy-check is a full compile with Tracy instrumentation - key: tracy-check use: [code, zig-x64, zig-deps-x64, zig-build-runner-x64, tracy-src] # pos-build inputs plus the tracy/ clone this build consumes (-Dtracy=./tracy). - # Can't reuse *pos-build (it lacks tracy/), so the include set is inline. filter: - - build.zig - - build.zig.zon - - src - - test - - "!test/snapshots" # compile-only check; snapshot data is never an input (see &pos-build) - - ci - - vendor - - legal_details + - *pos-build - tracy timeout: 45m - agent: # CO 8c/16g — -Dno-bin compile-check; off-path. 8c (not 2c) is cheaper AND faster; needs 16g (8g stalls) + agent: cpus: 8 memory: 16gb run: | set -euo pipefail zig build -Dno-bin -Dfuzz -Dtracy=./tracy - # ============================================================ RELEASE anchor - # ReleaseFast musl/x86_64_v3 — separate compilation from Debug (no artifact - # sharing). Serves release unit tests + the static-link assertion on the - # distribution binary. - key: build-release-x64 use: [code, zig-x64, zig-deps-x64, zig-build-runner-x64] filter: *pos-build - # No tool-cache (see build-ci-x64): ReleaseFast is serial-bound on the roc binary; a warm - # restore saved ~0s while its layers grew to ~13 GB. timeout: 45m - agent: # CO 32c/64g — kept large: genuinely parallel ReleaseFast build; shrinking cost both time and money + agent: cpus: 32 memory: 64gb run: | set -euo pipefail - # -Droc-test-runner: pre-build the custom-runner test binaries (see build-ci-x64) so the - # ReleaseFast unit partitions cache-hit and only run. zig build build-ci -Droc-test-runner -Doptimize=ReleaseFast -Dfuzz -Dsystem-afl=false - # Module unit tests in ReleaseFast via Captain (single task — see unit-debug; not worth - # partitioning). - key: unit-release use: [build-release-x64, captain] filter: *pos-leaf @@ -491,8 +422,6 @@ tasks: test-results: - path: tmp/captain/roc-unit-release.json - # LSP integration specs — bespoke fork-based runner, now in Captain (was part of the old - # plain `unit-misc` non-module run). rwx-v1-json from its parent via the shared reporter. - key: lsp use: [build-ci-x64, captain] filter: *pos-leaf @@ -504,9 +433,6 @@ tasks: test-results: - path: tmp/captain/roc-lsp.json - # fx / http / json / watch platform tests — Zig-native (b.addTest), reuse the unit runner's - # rwx-v1-json reporter via -Droc-test-runner. Needs pos-cli inputs (the fx platform fixtures - # import CONTRIBUTING/profiling). Replaces the rest of the old plain `unit-misc` task. - key: platform-tests use: [build-ci-x64, captain] filter: *pos-cli @@ -518,9 +444,6 @@ tasks: test-results: - path: tmp/captain/roc-platforms.json - # System packages not on the rwx/base image (verified via probe), pulled into - # their own cached tasks. Leaves `use:` them so the apt layer is shared and - # not re-installed inline on every run. - key: system-file run: | set -euo pipefail @@ -540,20 +463,11 @@ tasks: file ./zig-out/bin/roc file ./zig-out/bin/roc | grep "statically linked" - # ============================================================ VALGRIND anchor - # Special config: ReleaseFast musl/x86_64_v3, -Dstrip=false + larger shared - # memory (build.zig valgrind path). Distinct from the release anchor. - key: build-valgrind-x64 use: [code, zig-x64, zig-deps-x64, zig-build-runner-x64] filter: *pos-build - # No tool-cache (the last one dropped): its warm value was the fixed prelude — the - # build-runner compile (~94s at 4c, now covered on the content cache by - # zig-build-runner-x64) + the test-platform host libs (small) — while real src changes - # rerun builtin_compiler, whose bake cascades into everything embedding compiled_builtins, - # so compiler incrementality never survived a PR anyway. Dropping ALL tool caches also - # frees CI from needing a vault at all. timeout: 45m - agent: # MO 4c/32g — serial build (cores don't help); memory headroom for the full-DWARF -Dstrip=false build + agent: cpus: 4 memory: 32gb run: | @@ -582,8 +496,6 @@ tasks: # the default target, which misses the anchor's -Dcpu/-Dtarget cache key (~406s wasted). SNAPSHOT_BIN=./zig-out/bin/snapshot ./ci/valgrind_snapshots.sh - # ============================================================ arm64 leg - # arch is run-level, so the arm64 matrix is an embedded run with its own base. - key: arm64 call: ${{ run.dir }}/ci-arm64.yml init: