Canonical CI/CD documentation for Expanse (job catalog, rollup gate, regression gating, and the org-wide engineering standards the pipeline is built on). Design & Architecture: ARCHITECTURE.md · Testing Layers: TESTING.md · Performance Discipline: BENCHMARKING.md · Compatibility Gates: COMPAT.md · Release & Packaging: PACKAGING.md
Expanse is a drop-in replacement for the 20-year-old C libjudy library, with bindings across many language ecosystems. It leans on unsafe Rust, optimistic concurrency, low-level bit manipulation and precise C ABI compatibility. The CI pipeline is therefore a layered verification harness: each job enforces a correctness, memory-safety, or performance invariant.
Every Pull Request and push to main executes a matrix of parallel checks defined in .github/workflows/ci.yml. The branch ruleset (main-protection) requires exactly one status context — CI Gate / All Checks Passed — a rollup (ci-gate) that needs: every other job and fails if any of them failed or was cancelled. Because a job omitted from that rollup would fail open (its result unobserved), completeness is asserted in two places:
- the
lintjob runspython3 scripts/check_ci_gate.py, and - the
ci-gatejob's own self-check step parsesci.yml, builds the set of job ids, and fails if any id (other thanci-gate) is missing fromci-gate'sneeds:.
Because the ruleset requires only the rollup context, renaming a non-gate job does not require editing branch protection (the self-check guards completeness); only renaming ci-gate itself would. Scheduled Nightly workflows (nightly.yml) run the deep fuzzing and full-suite Miri validation out of band.
graph TD
PR[Pull Request / Push] --> DC[detect-changes<br/>dorny/paths-filter]
DC --> CORE[Core: lint, workspace tests<br/>ubuntu / macOS / windows / AArch64 Linux]
DC --> CROSS[Cross: RV64GC, RV32IMAC, RV32IMAC+Zbb,<br/>ESP32-C3, Cortex-M4, i686 32-bit, musl static]
DC --> SAFE[Safety: Miri, ASan, Loom, Fuzz smoke]
DC --> COMPAT[Compat: stock-libjudy oracle,<br/>php-judy Linux + Windows]
DC --> PERF[Perf: Callgrind instructions,<br/>memory-budget B/key]
DC --> BIND[Bindings: Python, Node, PHP, .NET,<br/>Java, Ruby, WASM, Go]
DC --> INTEG[Integrations: RocksDB MemTable<br/>none / ASan-UBSan / TSan]
CORE --> GATE[ci-gate<br/>CI Gate / All Checks Passed]
CROSS --> GATE
SAFE --> GATE
COMPAT --> GATE
PERF --> GATE
BIND --> GATE
INTEG --> GATE
GATE --> BP[Branch Protection]
ci.yml defines 35 jobs — 34 verification jobs plus the ci-gate rollup. They are grouped below by role. Each job gates on detect-changes so an unaffected subsystem's job cleanly skips (counting as passing) on a scoped PR, while main pushes and non-PR events run everything.
bench-baremetalappears in the Performance table below for completeness but lives inbench_baremetal.yml; it is/bench-triggered and is not one ofci-gate's dependencies.
| Job | Name | Role |
|---|---|---|
detect-changes |
Subsystems / Change Detection | dorny/paths-filter@v4 computes per-subsystem outputs (rust-src, tooling, perf-tooling, python, node, dotnet, java, docs, ruby, php, wasm, go, integrations) that downstream jobs gate on. |
| Job | Name | Role |
|---|---|---|
lint |
Core / Linter & Formatting | cargo clippy --workspace --all-targets -- -D warnings, cargo fmt --all --check, plus repo-consistency scripts: bump_version.py --check (multi-ecosystem version lockstep), check_abi_parity.py (C ABI symbol parity across bindings and pinned symbol floor ≥100), check_deletion_rationale.py (fail unrationalized file deletions vs base ref), check_test_floors.py (workspace test count floor ≥300), check_ci_gate.py (gate completeness), check_bench_suites.py (the /bench suite manifest against the workflow, the docs table, the crate [[bench]] targets, and each callgrind suite's arms declarations against the library_benchmark_group!s its bench source declares), and the --self-test suites of perf_report.py, bench_report.py, check_docs_hygiene.py, check_bench_suites.py, check_man_pages.py, check_man_examples.py, check_abi_parity.py, check_deletion_rationale.py, check_test_floors.py (so the §8.1 fail-loud and §8.2 no-hardcoded-prose assertions cannot rot). |
test |
Core / Workspace Tests (ubuntu / macOS / windows) | cargo test --workspace --exclude expanse-php across the three host OSes (glibc, Mach-O, PE/COFF ABI), with PROPTEST_CASES=500 (AGENTS.md §5 gate 4). expanse-php needs PHP headers and is covered by test-php / php-judy-*. |
test-aarch64 |
Core / AArch64 Linux Tests & Callgrind (Neoverse N2) | ubuntu-24.04-arm native execution — hardware capability census (lscpu, /proc/cpuinfo, sysfs cache-line size), asserted rather than printed so a fleet rotation fails instead of silently invalidating docs/HARDWARE.md §2.6; full workspace tests (PROPTEST_CASES=500), C ABI drop-in verification, C++20 header tests, and a Callgrind instruction regression gate on AArch64: on pull requests the job benches the merge base (--save-baseline=aarch64_smoke_base, non-fatal), compares HEAD against it, and runs the same perf_report.py --fail-on-regression --max-regression-pct 5.0 guard as callgrind-smoke, with the same allow-regression: <reason> override. NEON kernels already execute under the test job's macos-latest runner, which is arm64; what this lane adds is AArch64 Linux/glibc execution and Callgrind, which cannot run on macOS. |
man-examples |
Docs / Man Page Examples Compile and Match | Builds the C ABI, compiles each man page's EXAMPLES program against it, runs it, and diffs stdout against the Example Output block the page documents. check_man_pages.py validates form (troff hygiene, symbol coverage) and cannot tell whether the prose is true — it passed a page whose example printed the 3rd key while claiming the 2nd. Compiling alone would not have caught it either: the wrong program compiled and exited 0. Pinning the output is what makes it a gate. |
msrv |
Core / MSRV 1.88 Build | cargo check --workspace --exclude expanse-php --all-targets on the pinned floor toolchain (dtolnay/rust-toolchain@1.88.0). Every other job builds on stable, which never exercises rust-version. |
docs-lint |
Docs / Hygiene (time estimates, PII, provenance) | scripts/check_docs_hygiene.py over tracked markdown and the PR body: fatal on time estimates (§6) and on home-directory paths / private LAN IPs / denylisted hostnames (§7); advisory warning on a document that publishes unit-bearing numbers while carrying no (measured: …) provenance tag anywhere (§8.7 — file-scoped on purpose: the suite READMEs state provenance once in a header covering every table below, so a proximity rule would flag them and train readers to ignore the warning). Gated on the docs path filter, so docs-only PRs still run a check. Hostnames come from the DOCS_HOSTNAME_DENYLIST repository secret — never committed, and a secret rather than a variable because Actions echoes job env: values into the public run log and only secrets are masked. Fork PRs get no secrets, so the hostname check skips there and says so. |
| Job | Name | Role |
|---|---|---|
test-riscv64 |
Cross / RV64GC Cross-Compile (Linux) | riscv64gc-unknown-linux-gnu build. |
test-rv32 |
Cross / RV32IMAC Cross-Compile (Bare-Metal) | riscv32imac-unknown-none-elf #![no_std] check. |
test-rv32-zbb |
Cross / RV32IMAC + Zbb Cross-Compile (Bare-Metal) | Same target with the Zbb bit-manipulation extension enabled. |
test-esp32c3 |
Cross / ESP32-C3 Cross-Compile (Bare-Metal RV32IMC) | riscv32imc-unknown-none-elf #![no_std] check. |
test-cortex-m |
Cross / ARM Cortex-M4 Cross-Compile (Bare-Metal) | thumbv7em-none-eabihf #![no_std] check; C ABI staticlib build; links both STM32H747 harness images (integrations/stm32h747/build.sh) with arm-none-eabi-gcc -mfloat-abi=hard and asserts the Tag_ABI_VFP_args tag — the ARM linker rejects a soft/hard mismatch, so the link is the float-ABI assertion. Runs on rust-src or integrations changes. |
test-qemu-cortex-m3 |
Cross / ARM Cortex-M3 Execution (QEMU mps2-an385) | The ARM execution gate without hardware (#598 step 4): builds the C ABI staticlib for thumbv7m-none-eabi (soft float), links integrations/qemu-cortex-m3/smoke.c and runs it under qemu-system-arm -M mps2-an385 with semihosting; the smoke exercises the narrow map/set surface, ordered iteration, remove_range, and the sync32 protocol with a SysTick reader against a churning writer (any wrong value fails). Not cycle-accurate and cacheless: the M7/M4 numbers stay a hardware measurement. Runs on rust-src or integrations changes. |
test-x86_64-none |
Cross / x86_64 Bare-Metal Cross-Compile (x86_64-unknown-none) | x86_64-unknown-none 64-bit #![no_std] core engine check. |
test-i686 |
Cross / i686 32-bit Test Execution (Linux) | i686-unknown-linux-gnu — the only host-runnable 32-bit target; runs the real 32-bit trie test suite, the expanse-capi narrow-surface tests, the -m32 C smoke, and the 32-bit-only man pages' EXAMPLES programs via check_man_examples.py --narrow against the i686 cdylib. |
test-musl |
Cross / Musl Static C ABI (Linux) | x86_64-unknown-linux-musl static build, zero glibc leak. Retained as a deliberate concurrency diversity property: musl's distinct allocator geometry, scheduling, and static linkage widen race windows that glibc's timing hides (e.g. issue #477 surfaced on musl while passing glibc). |
| Job | Name | Role |
|---|---|---|
miri |
Safety / Tier 1 Miri Fast Smoke | Fast per-PR Miri smoke over the unsafe core (UB, provenance, Stacked/Tree Borrows). |
test-asan |
Safety / ASan Core Smoke (Ubuntu) | -Zsanitizer=address build-std smoke on the core. |
loom |
Safety / Loom Concurrency Race Model | --cfg loom permutation model-checking of the OCC seqlock and EBR. |
fuzz-smoke |
Safety / Fuzz Invariants Smoke | libFuzzer smoke (60 s/target) over every target registered in fuzz/Cargo.toml (discovered via cargo fuzz list, never hand-listed; a self-check step fails the job if fuzz/fuzz_targets/*.rs and the [[bin]] registrations differ) — currently 8: set_ops, set_algebra, map_ops, bytesmap_ops, strmap_ops, blobmap_image_corrupt, set32_ops, map32_ops. |
| Job | Name | Role |
|---|---|---|
differential-oracle |
Compat / Stock libjudy Oracle (Linux) | Black-box differential testing of libexpanse vs stock C libjudy via dlopen. |
php-judy-compat |
Compat / PHP-Judy Extension (Linux) | Builds php-judy (pinned SHA) against libexpanse.so, runs the upstream suite. |
php-judy-windows |
Compat / PHP-Judy Extension (Windows x64) | Same against expanse.dll under MSVC. |
| Job | Name | Role |
|---|---|---|
instruction-counts |
Perf / Callgrind Deterministic Instructions | Valgrind/Callgrind instruction counting + scripts/perf_report.py regression guard. |
callgrind-smoke |
Perf / Callgrind Fast Smoke (Ubuntu) | Fast scaled-down (<20s) Callgrind instruction regression smoke gate ( |
memory-budget |
Perf / Memory Budget Invariants | Runs examples/bytes_per_key.rs and examples/bytes_per_key_32.rs; fails if deterministic B/key exceeds architectural ceilings. |
bench-baremetal |
Perf / Remote Bare-Metal Benchmarks | Triggered via workflow_dispatch or /bench / /bench extended / /benchmark <suite> PR comments. The suite vocabulary is declared once in .github/bench-suites.json and tabulated in docs/BENCHMARKING.md §3; a separate hosted resolve job matches the argument as a whole token, refuses an unrecognised one by name without starting any run, and publishes the resolved suite as the job output that both the bench job and its concurrency group read. Dual-pass baseline drift reporting, Callgrind profiling, and multi-arch / population sweeps on the dedicated bare-metal reference host. Takes the host-wide bench lock (exit 75 if held), captures a system-load snapshot into the report, derives an anonymized host description from the runner, fails fast when a Callgrind suite lacks valgrind/iai-callgrind-runner, and only prints a Base Ref when the base pass actually produced comparable output. Comment identity is per suite (<!-- expanse-bench:<suite> -->), so two suites on one PR own two comments; the result step is if: always() and terminates its own comment with the actual cause (lock holder, missing Callgrind tooling, build/bench failure, cancellation) rather than leaving a ⏳ marker; a run with no numbers keeps the previous result for that suite in a collapsed block. A concurrency: group keyed on PR + suite supersedes an in-flight run of the same suite, and timeout-minutes: 180 bounds how long a wedged run can hold the shared host. |
| Job | Name | Role |
|---|---|---|
test-python |
Bindings / Python Fast Smoke (Py3.12) | PyO3 binding smoke. |
test-node |
Bindings / Node.js (matrix) | napi-rs addon across OS × Node versions. |
test-php |
Bindings / PHP (matrix) | FFI binding across OS × PHP versions. |
test-dotnet |
Bindings / .NET (matrix) | P/Invoke binding tests. |
pack-dotnet |
Bindings / .NET NuGet Package | Packs the Orieg.Expanse .nupkg. |
test-java |
Bindings / Java 22+ Panama (matrix) | Project Panama FFM binding tests. |
test-ruby |
Bindings / Ruby (matrix) | magnus / C ABI extension tests. |
test-wasm |
Bindings / WebAssembly (wasm32) | wasm32 binding build/test. |
test-wasm64 |
Bindings / WebAssembly (wasm64 Memory64 Experimental) | wasm64-unknown-unknown build-std check and Node.js Memory64 runtime smoke test. |
test-go |
Bindings / Go (matrix) | Go binding tests across CGO, purego (CGO_ENABLED=0), and explicit -tags expanse_purego on Linux, macOS, and Windows. |
| Job | Name | Role |
|---|---|---|
test-rocksdb-memtable |
Integrations / RocksDB MemTable (matrix) | Builds/tests ExpanseMemTableRep across sanitizer: [none, asan-ubsan, tsan] (TSan excluded on macOS); includes a differential test vs reference structures. |
| Job | Name | Role |
|---|---|---|
ci-gate |
CI Gate / All Checks Passed | Runs if: always(), needs: all 35 other jobs, treats cleanly-skipped jobs as passing, and runs the completeness self-check. The only required branch-protection context. |
Turnaround stays low for non-code and localized PRs without losing required-check coverage:
- A single lightweight
detect-changesjob (dorny/paths-filter@v4) computes thirteen per-subsystem booleans:rust-src,tooling,perf-tooling,python,node,dotnet,java,docs,ruby,php,wasm,go,integrations. Downstream jobs declareneeds: [detect-changes]andif: needs.detect-changes.outputs.<subsystem> == 'true' || github.event_name != 'pull_request'. rust-src— "does Rust/C++ behaviour need re-verifying":crates/**,include/**,tests/cpp/**,fuzz/**,Cargo.toml,Cargo.lock,rust-toolchain*,.github/actions/**, plusci.ymlitself. It is the only filter that schedules the expensive safety lane (miri,test-asan,fuzz-smoke,callgrind-smoke) and the cross-compile matrix. It does not includescripts/**: no Rust job compiles or links a Python script.tooling— the repo guards:scripts/**,.github/workflows/**,.github/bench-suites.json. Gateslint, which carriesbump_version.py,check_abi_parity.py,check_ci_gate.py,check_bench_suites.pyand the report-script self-tests. A PR that only edits the/benchsuite manifest orbench_baremetal.ymltherefore still runs the sync guard — without pulling in Miri, ASan, fuzz or Callgrind.perf-tooling— the one real tooling↔Rust coupling, scoped to it:scripts/perf_report.pyandscripts/bench_report.pyare executed byinstruction-counts,callgrind-smokeandtest-aarch64, so they gate those three jobs specifically rather than justifyingscripts/**→ everything.ci.ymlstays inrust-srcdeliberately. An edit to it rewrites the build commands, flags, toolchains andif:gates of the safety jobs themselves, and can silently remove one;mainis protected, so thepushevent fires only after the merge and PR time is the sole place a broken or disabled safety job is observable before it lands. Restricting it to the jobs a diff actually touches is not expressible as a path glob, so the conservative case is taken: everyci.ymledit runs the full matrix.nightly.ymlandbench_baremetal.ymlcarry no such risk forci.yml's jobs and sit intoolingonly.integrations/**is C++ outside the cargo workspace, built by exactly one job (test-rocksdb-memtable) and already covered by theintegrationsfilter, so it is not part ofrust-src.- A PR touching only
docs/**,*.mdorwebsite/**matches no Rust job and skips the Rust matrix entirely — but it does rundocs-lint, which consumes thedocsfilter output, so a docs-only PR is never a zero-check PR. - Known residual:
check_bench_suites.pyalso asserts the generated table indocs/BENCHMARKING.md, butlintis not gated ondocs/**, so a docs-only edit to that table is checked by the next PR that touchesrust-srcortoolingrather than by its own. - The
CI Gate / All Checks Passedrollup satisfies branch protection once its dependencies conclude (skipped jobs count as passing), so PRs never deadlock in "Pending" behind a filtered-out check.
Wall-clock timing on shared cloud runners exhibits large multi-tenancy / thermal / hyperthread noise, causing false-positive failures. The instruction-counts job instead counts instructions retired (and cache accesses) via Valgrind/Callgrind, which is deterministic on x86-64 Linux: the same commit yields the same instruction count on any runner, so changes as small as 0.1% reflect real codegen/algorithmic differences.
The same determinism was measured, not assumed, on the AArch64 lane before that lane was allowed to gate. Two consecutive ubuntu-24.04-arm runs whose commits touched no crates/, Cargo.toml or Cargo.lock content produced byte-identical counts across all 20 smoke_instructions arms — zero drift, not drift below a threshold (measured: ubuntu-24.04-arm Neoverse-N2, runs 33124978928 and 33128212931, both at tree 69ed1774). That is what makes a shared ARM runner an admissible instrument under AGENTS.md §8.4: the counts are exact integers with zero variance, so co-tenant load cannot perturb them.
scripts/perf_report.py compares PR-branch instructions against a base bench (--base) and, with --fail-on-regression, turns regressions into a job failure:
- Single-benchmark threshold: any benchmark above
--max-regression-pctfails CI. - Multi-benchmark threshold: more than one benchmark above the noise floor fails CI.
The job wires --fail-on-regression with a deliberately loose --max-regression-pct 5.0 (shared runners are noisy; the 0.1% figure above is Callgrind's measurement/display resolution and the review threshold — see docs/BENCHMARKING.md and AGENTS.md §6 — not the automated failure threshold). On pull requests the job captures the merge-base bench and supplies it via --base, arming the guard. callgrind-smoke (x86-64) and test-aarch64 (Neoverse N2) run the identical mechanism over the same smoke_instructions bench, so a codegen regression that only manifests on one ISA is caught on that ISA. The pipeline degrades loudly, never silently:
- Pipefail on bench steps — the
cargo bench … | teesteps run withshell: bash(impliesset -o pipefail), so a crashed benchmark fails its step instead of exiting0throughtee. - Empty/near-empty head parse is a hard failure in
--fail-on-regressionmode: a run that measured nothing (or lost most of the base's arms to a partial crash) can never render "🟢 0 Regressions". - Base-present/head-missing arms are reported explicitly in the report rather than silently dropped from the comparison.
- A base capture that fails stays non-fatal (
|| trueon the base pass — a broken base commit must not hard-block PRs), but the report then renders a prominent "⚠️ NO BASELINE — regression gate did not run" section, not a quiet chip. - A missing head artifact fails the step. The guard steps assert their inputs exist (
instruction-counts.txt/bytes-per-key.txt/vs-stock.txt,smoke-instruction-counts.txt, andaarch64-smoke-instructions.txt) and::error+ exit 1 when one is absent. Previously the invocation sat inside anif [ -f … ]wrapper, so a crashed benchmark skipped the guard and the step exited0.
Both report scripts ship unit-style checks for this behavior — python3 scripts/perf_report.py --self-test and python3 scripts/bench_report.py --self-test — and the lint job runs them on every PR.
For end-to-end runtime comparisons (e.g. the PHP runtime, JIT paths), never compare absolute wall-clock across runs. Measure two arms — Arm S (pristine baseline) and Arm C (candidate) — in alternating interleaved rounds on the same runner, and gate on the ratio Candidate / Baseline. Runner slowdown scales both arms equally, keeping the ratio noise-free.
An intentional trade-off (safety hardening, new feature, metadata tagging) is approved explicitly:
- Add a literal
allow-regression: <reason>line to the PR body — the directive must begin its own line, then the colon and a nonempty reason on that same line — plus a Performance Trade-off Disclosure section (regressed metric + load-bearing rationale + net win).perf_report.pyaccepts only this strict form: a bareallow-regressionsubstring,perf-override: approved, a quoted copy of the policy text, or any mid-line prose mention (including inside a markdown table cell or an inline code span) approves nothing. Writing about the override in a PR body must never arm it — the line anchor is what guarantees that, andperf_report.py --self-testpins the cases. CI records a genuine override in the step summary and allows the PR. - The reason must carry a resolvable citation — a CI run URL or a committed artifact path — and every number in it must appear in that source (AGENTS.md §6, §8.7). An override whose reason cites nothing is void:
perf_report.pyleaves the gate armed, exits non-zero, and reportsRegression override is void — no resolvable citationalong with the reason as given. A bare commit SHA does not qualify; it names a revision, not a measurement of it.perf_report.py --self-testpins both directions. - If the regressed arm is covered by a documented exemption (
docs/BENCHMARKING.mdrule 16 — the random arms ofmap_get,set_containsand their C-ABI twins), cite the rule instead of opening an override. The exemption's scope is narrow; a regression on any other arm is a regression.
memory-budget runs examples/bytes_per_key.rs and examples/bytes_per_key_32.rs: total heap bytes ÷ key count against strict per-distribution ceilings. These are deterministic allocator-accounting numbers, unaffected by machine load, so unlike timing tables they can hard-gate a build. Raise a ceiling only deliberately, updating the BENCHMARKING.md row in the same commit.
The instruction gates above are single-threaded and deterministic by design. They cannot catch a change that serializes concurrent readers or silently degrades the optimistic path to the mutex fallback: Callgrind serializes threads, and raw wall-clock ops/s is too noisy to threshold tightly.
The nightly bench-report job therefore runs benches/concurrency.rs — all Sync* types plus baselines, on a reduced sweep via EXPANSE_BENCH_THREADS / EXPANSE_BENCH_WORKLOADS. It gates on scaling ratios: total ops/s at max threads ÷ 1 thread, per (engine, workload). scripts/bench_concurrency_check.py compares those against the previous nightly's concurrency-baseline artifact, using the same upload/download round-trip as the bindings-baseline.
Ratios are robust to host-load drift, so a generous 30% relative-drop threshold flags real scaling collapses while tolerating scheduler noise. The gate is warn-only today (no --fail-on-regression); promotion to failing is gated on the baseline staying quiet across several consecutive unmodified nightlies (#360). /benchmark concurrency runs the same instrument report-only on the bare-metal host. Suite and knob details: docs/BENCHMARKING.md §2–3.
A green test suite only proves that what is present passes, not that what should be present is present. To prevent silent code/test shrinkage or accidental merge regressions:
- Deletion rationale gate (
scripts/check_deletion_rationale.py): any PR that deletes tracked files compared to the base branch (git diff --diff-filter=D) fails CI unless the PR body includes a line-anchoredremoves: <reason>ordeletes: <reason>directive. Ref/diff determination failures fail loud (EXIT=1). - C ABI symbol floor (
scripts/check_abi_parity.py): fails if total exported C ABI functions fall below the pinned floor (≥ 100 symbols) without an explicitallow-symbol-shrink: <reason>directive. The floor constant (MIN_C_SYMBOLS = 100) is verified against the base ref viagit show, preventing diffs from lowering the constant without an override. The zero margin (exactly 100 symbols vs floor of 100) is deliberate: the first legitimate deprecation trips the floor and requires an explicit rationale. Renaming the constant trips base resolution and intentionally requires the override. - Workspace test count floor (
scripts/check_test_floors.py): fails if workspace test counts drop below the pinned floor (≥ 300 tests) without an explicitallow-test-shrink: <reason>directive. The floor constant (MIN_WORKSPACE_TESTS = 300) is verified against the base ref viagit show, preventing diffs from lowering the constant without an override. The thin margin (305 tests vs floor of 300) is deliberate so substantial test deletions trip the gate immediately. Renaming the constant trips base resolution and intentionally requires the override.
- Tier 1 (per-PR,
ci.yml):mirirunscargo miri test -p expanse-trie --libover an explicit filter list —leaf,node,slot,alloc,bits,types, plus theblobmap/strmap/bytesmapdeferred-dispose round trips. Heavy op-sequence tests are left to proptest and fuzzing. The job skips on non-Rust diffs (§3). Catches Stacked/Tree Borrows and provenance violations before merge. - Tier 2 (merge gate):
ci-gaterequires Tier 1 Miri to pass before a PR is mergeable. - Tier 3 (nightly,
nightly.yml): the full un-skipped Miri suite across all crate targets, including long-running randomized model sweeps (proptest_model.rs). Failures open/update a deduplicated GitHub issue; recovery auto-closes it (see §8).
- Sanitizer matrix (ASan/UBSan/TSan):
test-asancovers the Rust core;test-rocksdb-memtablerunssanitizer: [none, asan-ubsan, tsan]over the C++ExpanseMemTableRep(TSan catches races in atomic sibling-leaf pointers and the optimistic reader path; TSan excluded on macOS); the nightly workflow runstest-tsan(-Zsanitizer=thread) across allSync*andSync32*Rust concurrent primitives with storage-engine suppressions (.github/tsan-suppressions.txt) to allow benign OCC optimistic reader loads, paired with an inverted-exit-code canary test (tests/tsan_canary.rs) ensuring TSan remains armed. Storage-level unbracketed races are caught byassert_bracketed()debug panics and Loom model checks rather than TSan. - Differential oracles:
differential-oracleruns identical operation sequences throughlibexpanseand stock Clibjudy; the RocksDB integration adds a differential memtable test asserting byte-for-byte state equality against reference structures. - Concurrency models:
loom(--cfg loom) model-checks the twooccprotocol primitives — seqlock version ordering and EBR pin/advance retirement (a retirement waits 2 epoch advances). It does not model a branch split; thesyncread path as a whole is not loom-checkable (TESTING.md, layer 6). A multi-threaded history recorder (tests/linearizability.rs) validates OCC linearizability.
Large matrices across many runner VMs can trip upstream rate limits / 504s. Standards used across the pipeline and sister repos:
-
Resilient curl (never bare
curl -f):curl --retry 5 --retry-delay 2 --retry-max-time 60 --retry-all-errors --retry-connrefused -fsSL "$URL" -o "$OUTPUT". -
Startup jitter for concurrent matrix jobs:
sleep $(( (RANDOM % 5) + 1 ))before the first network request. -
max-parallelthrottling for heavy release matrices hitting external CDNs/registries. -
Step-level retries (
nick-fields/retry@v3) wrapping flaky setup (setup-php,maturin-action,apt-get).
Nightly workflows run out of band with no human watching PR checks, so failures self-report to avoid silent rot. On failure() && github.event_name == 'schedule', an actions/github-script step opens or comments on a deduplicated issue (label nightly-failure) with commit, run link, failing target, and a local reproduction command; on success() it comments and auto-closes the open issue. See nightly.yml for the exact script.
--fail-on-regression— turnsperf_report.pyregressions into blocking errors.--pr-body-file <path>— supplies the PR description so approval markers are parsed.--max-regression-pct <float>— max allowed single-benchmark instruction regression.--noise-floor <float>— threshold above which an instruction delta is a regression.--self-test— runs the unit-style checks built intoperf_report.py/bench_report.py/check_abi_parity.py/check_deletion_rationale.py/check_test_floors.py(parse, gating, override matching, legend bands, and — forbench_report.py— that each ratio column's marker is graded by that column's declared direction and that an unmeasured arm renders as an absence rather than a zero) and exits.removes: <reason>/deletes: <reason>— PR body directive approving intentional file deletions.allow-symbol-shrink: <reason>— PR body directive approving intentional C ABI symbol shrinkage.allow-test-shrink: <reason>— PR body directive approving intentional test count reduction.RUSTFLAGS="--cfg loom"— swapsstd::sync::atomicfor Loom permutation-checked atomics.-C target-cpu=x86-64-v3— enables AVX2/BMI2/POPCNT for the comparative microarchitecture benches.
- Teardown contamination in benchmarks — in
benches/instructions.rs, data measured inside Callgrind must not runDrop/dealloc inside the timed block; usecore::mem::forget(data)when measuring lookups. - Zero-byte memory copies — inserting at the end of a linear leaf (
pos == pop) must not issue an unconditionalcopy_nonoverlappingof 0 bytes; guard withif pos < pop. - C-vs-Rust ABI fairness — compare
.soagainst.soviadlopen(the*_expanse_dlarms) so static LTO doesn't skew stock-libjudy comparisons. - Binary file searches — never
grep/rg/sedbinary artifacts (.so,.dll,.a,.tar.gz); usenm/objdump/python3. X | grep -qunder the runner shell — workflow steps runbash -e -o pipefail;grep -qexits at the first match and closes the pipe, so a producer still writing (llvm-readobjover an archive, for instance) dies with EPIPE and the pipeline fails on a match. It is timing-dependent and rarely reproduces locally. Write the producer's output to a file, then grep the file.- The docs-hygiene gate reads the PR body from the event payload captured when the run was triggered — editing the body afterwards does not re-run or fix it; push a new commit. Its retracted-figure ledger matches literal tokens (
1.11×is one), so a coincidentally identical ratio must be phrased differently (a percentage, or one more significant figure) rather than fought.
- Continuous flamegraph artifacts — publish differential SVG flamegraphs on regression.
- AArch64 Linux execution & Callgrind gating — delivered on the GitHub-hosted ARM64 Linux runner (
ubuntu-24.04-arm, Neoverse N2): native workspace tests, an asserted capability census, and a merge-base Callgrind regression gate. - Apple-Silicon Callgrind & the 128 B cache line — still open, and the half of the original AArch64 roadmap item that #397 did not deliver.
macos-latestruns workspace tests but no Callgrind (valgrind does not run there), so the platform wheredocs/HARDWARE.md§2.4 records a real performance risk — 128-byte lines againstalign(64)nodes — has no instruction-count coverage. - Automated corpus cache sync — promote high-coverage nightly fuzz corpora into PR smoke checks.
- Failing concurrency scaling gate — promote the nightly warn-only ratio guard (§4.6) to blocking once the baseline stays quiet (#360).
To ensure high supply-chain security, fast execution, and zero runner runtime deprecation warnings across the pipeline, Expanse enforces a strict Actions Update & Runtime Policy:
GitHub Actions runners periodically deprecate older Node.js action runtimes (e.g. Node 20 reached end-of-life and is deprecated on runners in favor of Node 24).
- Prohibited Workarounds: Setting insecure stopgap flags like
ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: "true"is strictly prohibited. Workarounds mask technical debt and create brittle pipelines before hard runner cutoffs. - Mandatory Upgrades: When runner runtimes evolve, all workflow actions MUST be promptly updated to their official major versions compiled against the active LTS runtime (Node 24+).
- Major Version Tags: Pin all official and trusted community actions to their current major version tag (e.g.,
actions/checkout@v7,dorny/paths-filter@v4,actions/setup-python@v7). - Strictly Prohibit
@latest: GitHub Actions does not support@latestsyntax; referencing@latestwill fail workflow execution. - Canonical Action Catalog & Baseline:
actions/checkout@v7(repository checkout with modern ESM & secure git configs)actions/github-script@v8(Node 24 runtime with dual CommonJS / ESM compatibility)actions/setup-node@v7(Node.js SDK installation)actions/setup-python@v7(Python environment setup)actions/setup-dotnet@v6(.NET SDK setup)actions/setup-java@v6(JDK / Panama setup)actions/setup-go@v7(Go toolchain for the cgo binding lane)actions/cache@v6(generic cache — nightly fuzz corpus restore)actions/upload-artifact@v7/actions/download-artifact@v8(v4+ artifact storage)actions/upload-pages-artifact@v5/actions/deploy-pages@v5(GitHub Pages CD)dorny/paths-filter@v4(monorepo subsystem change detection)peter-evans/find-comment@v4/peter-evans/create-or-update-comment@v5(PR bot comment updates)Swatinem/rust-cache@v2(smart cargo build artifact caching)
To audit whether upstream actions have newer releases or runtime migrations available:
for action in "actions/checkout" "actions/setup-node" "actions/setup-python" "actions/setup-dotnet" "actions/setup-java" "actions/setup-go" "actions/cache" "actions/upload-artifact" "actions/download-artifact" "dorny/paths-filter"; do
echo "$action: $(gh api /repos/$action/releases/latest --jq .tag_name 2>/dev/null || echo 'manual check needed')"
doneThese conventions apply across Expanse and its sister repositories (php-judy, judy-cache, judy-polyfill, yaml-workflows, gws-connectors). They exist so a new repo inherits the same zero-regression discipline.
Concurrency hygiene. PR runs cancel superseded runs; main pushes must not:
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}Merges to main use a unique key (github.run_id / github.sha) with cancel-in-progress: false so every merge keeps a complete audit trail.
New-project setup checklist:
- Define
concurrencywithcancel-in-progress: ${{ github.event_name == 'pull_request' }}. - Create a
detect-changesjob withdorny/paths-filter@v4. - Gate downstream jobs on
needs: [detect-changes]+if: needs.detect-changes.outputs.<subsystem> == 'true'. - Create a
ci-gaterollup evaluating${{ toJson(needs) }}, with a completeness self-check. - Set up deterministic regression gating (Callgrind instructions or interleaved dual-arm ratios).
- Set an explicit
timeout-minuteson every job. - Pin actions to modern Node 24+ major releases (never use
ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION). - Configure branch protection to require only the
ci-gatecontext. - Add automated nightly issue triage / self-healing to
nightly.yml.
Architecture choice — native Actions vs yaml-workflows: use native GitHub Actions for core PR CI (ci.yml) for zero setup overhead and direct diagnostic streaming; prefer the orieg/yaml-workflow action for DAG-based multi-artifact release packaging (release.yml), cross-repo nightly sweeps (nightly.yml), and multi-step docs portals (pages.yml).