name: CI on: push: branches: [main] pull_request: branches: [main] workflow_dispatch: # Cancel a still-running CI for the same ref when a new push supersedes it — # avoids burning runner-hours on stale commits during active PR iteration. # Never cancel on main: a run there should always finish (e.g. for the # scorecard/coverage artifacts it produces), even if another push lands. concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} permissions: contents: read env: CARGO_TERM_COLOR: always RUSTFLAGS: -Dwarnings # CI must be hermetic: never let tests download the embedding model # (~90 MB) in the background. Without this, the #551 auto-activation can # load the engine mid-suite and slow tarpaulin past its timeout. LEAN_CTX_EMBEDDINGS_AUTO_DOWNLOAD: "0" jobs: test: name: Test strategy: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} # --test-threads=1 (env-race legacy, v3.3.5) serializes ~70 binaries that # now include heavy property/fuzz/benchmark suites (#291, #493, #551) — # well beyond 90 min on hosted runners. Parallelization is tracked # separately; until then the gate gets the time it actually needs. # # Tried switching to cargo-nextest (rust/.config/nextest.toml already # exists and documents the same env-race for 3 known binaries via a # `serial-state` test-group). Reverted: nextest's default parallelism # (test-threads = -4) surfaced several more tests with hidden wall-clock # timing assumptions (e.g. core::cache::tests::hebbian_eviction_bonus_is_wired # depends on two calls landing in the same real-time 500ms window) that a # `max-threads=1` test-group does NOT fix — that only serializes tests # *within* the group against each other, it doesn't protect them from # being scheduling-delayed by the many *other* tests still running in # parallel elsewhere on the same runner. Revisit once those tests are # made robust to scheduling jitter (or the burst window is made # injectable for tests) rather than papering over it with test-groups. timeout-minutes: 180 env: # ~60 integration-test binaries each statically link the full lib # (tree-sitter ×18, rten, aws-lc, resvg, …). With default full debug # info that is 25-40 GB and the ubuntu runner dies with ENOSPC / the # linker with SIGBUS. Line tables keep file:line in panic backtraces # at a fraction of the size. CARGO_PROFILE_DEV_DEBUG: line-tables-only CARGO_PROFILE_TEST_DEBUG: line-tables-only # property_compression alone took 8012s serialized on ubuntu (87% of # the job) with proptest's default 256 cases — and pushed Windows past # the 180-min timeout. 64 cases keep the properties exercised on every # push at a quarter of the cost; local runs keep the full default. PROPTEST_CASES: 64 steps: - uses: actions/checkout@v4 # v4 with: persist-credentials: false - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - name: Ensure toolchain is active shell: bash run: | CARGO_HOME="${CARGO_HOME:-$HOME/.cargo}" export PATH="$CARGO_HOME/bin:$PATH" rustup default stable cargo --version echo "$CARGO_HOME/bin" >> $GITHUB_PATH - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: workspaces: rust -> target # ---- Windows: MSYS2 + GNU target + jemalloc ------------------------- # The default MSVC toolchain can't build jemalloc (autotools mismatch). # We switch to the GNU target with MSYS2/MinGW and pre-build jemalloc. # See .github/actions/windows-jemalloc for the gory details. - name: Setup MSYS2 and MinGW if: runner.os == 'Windows' uses: msys2/setup-msys2@e9898307ac31d1a803454791be09ab9973336e1c # v2 with: msystem: MINGW64 update: true install: base-devel mingw-w64-x86_64-toolchain path-type: inherit - name: Install Rust GNU target if: runner.os == 'Windows' uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable with: targets: x86_64-pc-windows-gnu - name: Build jemalloc + dummy liballoc.a (Windows only) if: runner.os == 'Windows' id: jemalloc uses: ./.github/actions/windows-jemalloc - name: Free disk space if: runner.os == 'Linux' shell: bash run: | sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \ /opt/hostedtoolcache/CodeQL /usr/local/.ghcup /usr/share/swift \ /usr/local/share/powershell /usr/local/share/chromium \ /usr/local/share/boost /usr/lib/jvm /usr/local/julia* || true sudo docker system prune -af --volumes >/dev/null 2>&1 || true df -h / - name: Run tests working-directory: rust shell: bash run: | if [[ "${{ runner.os }}" == "macOS" ]]; then CARGO="${CARGO_HOME}/bin/cargo" else CARGO="cargo" fi if [[ "${{ runner.os }}" == "Windows" ]]; then JEMALLOC_OVERRIDE="${{ steps.jemalloc.outputs.jemalloc-override }}" \ CARGO_TARGET_X86_64_PC_WINDOWS_GNU_RUSTFLAGS="-L ${{ steps.jemalloc.outputs.dummy-dir }}" \ "$CARGO" test --target x86_64-pc-windows-gnu --all-features -- --test-threads=1 else "$CARGO" test --all-features -- --test-threads=1 fi # #581: lock in the parallel BM25 (incremental) rebuild win against silent # regression. Timing is only meaningful on a quiet, single-threaded runner, # so the gate is env-opt-in (no-op in the matrix above) and runs once here. - name: BM25 build-time regression gate (#581) if: runner.os == 'Linux' working-directory: rust shell: bash env: LEAN_CTX_PERF_GATE: "1" run: cargo test --all-features --lib -- --test-threads=1 parallel_incremental_rebuild_perf_gate --nocapture clippy: name: Clippy runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 # v4 with: persist-credentials: false - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable with: components: clippy - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: workspaces: rust -> target - name: Run clippy working-directory: rust run: cargo clippy --all-features -- -D warnings # Maintainability gate (#660): no Rust file grows past 1500 LOC # (legacy files are frozen at 2000 via the script's allowlist). - name: LOC gate (file size budget) run: ./scripts/loc-gate.sh # Guards the no-ORT build (#586). The `embeddings`/`neural` features pull the # `ort` crate, whose `load-dynamic` dylib resolver has no fallback arm for # non-tier-1 targets (FreeBSD, etc.), so community ports build with those # features off. This job catches any code that references `ort`/embeddings # without a feature gate. Dead code in the reduced build is expected, so # warnings are not denied here (override the workflow-wide -Dwarnings). build-minimal: name: Build (no embeddings / no ORT) runs-on: ubuntu-latest env: RUSTFLAGS: "" steps: - uses: actions/checkout@v4 # v4 with: persist-credentials: false - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: workspaces: rust -> target - name: Check build without ORT-backed features working-directory: rust run: cargo check --no-default-features --features "tree-sitter,http-server,team-server,secure-update,jemalloc" # The no-tree-sitter combo is the slim-binary candidate (#663) and had # rotted silently because only the no-ORT combo was checked. - name: Check build without tree-sitter working-directory: rust run: cargo check --no-default-features --features "embeddings,http-server,team-server,secure-update,jemalloc" fmt: name: Format runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v4 # v4 with: persist-credentials: false - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable with: components: rustfmt - name: Check formatting working-directory: rust run: cargo fmt --check cookbook: name: Cookbook (Node) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 # v4 with: persist-credentials: false - uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" cache-dependency-path: cookbook/package-lock.json - name: Install working-directory: cookbook run: npm ci - name: Lint working-directory: cookbook run: npm run lint - name: Typecheck working-directory: cookbook run: npm run typecheck - name: Test working-directory: cookbook run: npm test - name: Build working-directory: cookbook run: npm run build pi-extension: name: Pi Extension (Node) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 # v4 with: persist-credentials: false # Fail fast if pi-lean-ctx / lean-ctx-bin drifted from the engine version, # so the Pi extension can never quietly fall behind (npm skips the publish # of a stale version). Runs on every push/PR — not just at release. - name: Check npm package ↔ engine version coupling run: python3 scripts/check-package-versions.py - uses: actions/setup-node@v4 with: node-version: "22" cache: "npm" cache-dependency-path: packages/pi-lean-ctx/package-lock.json - name: Install working-directory: packages/pi-lean-ctx run: npm ci - name: Typecheck working-directory: packages/pi-lean-ctx run: npm run typecheck - name: Test working-directory: packages/pi-lean-ctx run: npm test rust-sdk: name: Rust SDK (lean-ctx-client) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 # v4 with: persist-credentials: false - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable with: components: clippy, rustfmt - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: workspaces: clients/rust/lean-ctx-client -> target - name: Format working-directory: clients/rust/lean-ctx-client run: cargo fmt --check - name: Clippy working-directory: clients/rust/lean-ctx-client run: cargo clippy --all-targets -- -D warnings - name: Test working-directory: clients/rust/lean-ctx-client run: cargo test - name: Docs working-directory: clients/rust/lean-ctx-client env: RUSTDOCFLAGS: -D warnings run: cargo doc --no-deps embed-sdk: # Track A — the in-process embedding SDK (rust/crates/lean-ctx-sdk). It is a # workspace member but excluded from `default-members`, so the engine's own # test/clippy/doc jobs never see it; this job is its dedicated gate # (pedantic clippy, doctests, the engine-integration tests, example build). name: Embed SDK (lean-ctx-sdk) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 # v4 with: persist-credentials: false - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable with: components: clippy, rustfmt - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: workspaces: rust -> target - name: Format working-directory: rust run: cargo fmt -p lean-ctx-sdk --check - name: Clippy (pedantic, 0 warnings) working-directory: rust run: cargo clippy -p lean-ctx-sdk --all-targets -- -D warnings - name: Test (doctests + engine integration) working-directory: rust run: cargo test -p lean-ctx-sdk - name: Example builds working-directory: rust run: cargo build -p lean-ctx-sdk --example embed - name: Docs working-directory: rust env: RUSTDOCFLAGS: -D warnings run: cargo doc -p lean-ctx-sdk --no-deps python-sdk: name: Python SDK (leanctx) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 # v4 with: persist-credentials: false - uses: actions/setup-python@v5 # v5 with: python-version: "3.11" - name: Install working-directory: clients/python run: pip install -e ".[dev]" - name: Test working-directory: clients/python run: python -m pytest -q hermes-plugin: # The Hermes context-engine plugin (integrations/hermes-lean-ctx) replaces # Hermes' built-in ContextCompressor with lean-ctx. Gate: pytest (ABC # conformance + tool_call/tool_result invariant + daemon-down graceful) and # a benchmark smoke that proves the harness runs offline (local fallback). name: Hermes Plugin (Python) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 # v4 with: persist-credentials: false - uses: actions/setup-python@v5 # v5 with: python-version: "3.11" - name: Install test deps # Runtime dep (leanctx SDK) is wired onto sys.path by the plugin # conftest from clients/python — no install needed. tiktoken makes the # token count match the host model exactly; without it the plugin uses # its char-based fallback (also tested). run: pip install pytest tiktoken - name: Run plugin tests working-directory: integrations/hermes-lean-ctx run: python -m pytest tests/ -q - name: Benchmark smoke (offline, local fallback) working-directory: integrations/hermes-lean-ctx # Discard port → daemon unreachable → exercises deterministic local # compaction end-to-end. Competitors are auto-skipped when absent. run: python benchmarks/run.py --turns 40 --needles 6 --context-length 2000 --base-url http://127.0.0.1:9 --no-write sdk-conformance: # GL #395: every first-party SDK must prove the full /v1 contract against # a real engine build. Red when an SDK misses a route, mistypes a shape, # or the engine drifts from the frozen surface. name: SDK Conformance Matrix runs-on: ubuntu-latest timeout-minutes: 45 steps: - uses: actions/checkout@v4 # v4 with: persist-credentials: false - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: workspaces: | rust -> target clients/rust/lean-ctx-client -> target - uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" cache-dependency-path: cookbook/package-lock.json - uses: actions/setup-python@v5 # v5 with: python-version: "3.11" - name: Install Python SDK working-directory: clients/python # langchain-core gives the adapter smoke a real framework round trip; # LlamaIndex/CrewAI skip cleanly (heavy dep trees, covered by the # framework-agnostic OpenAI adapter path). run: pip install -e ".[dev]" langchain-core - name: Run conformance kit (3 SDKs vs real server) run: ./scripts/sdk-conformance.sh - name: Upload conformance matrix if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: sdk-conformance-matrix path: docs/reference/sdk-conformance-matrix.md if-no-files-found: ignore coverage: name: Coverage runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 # v4 with: persist-credentials: false - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable with: # tarpaulin's LLVM engine (below) needs llvm-profdata/llvm-cov from # the rustup component — without it the engine silently falls back. components: llvm-tools-preview - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: workspaces: rust -> target - name: Free disk space run: | sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL || true df -h / - uses: taiki-e/install-action@74e87cbfa15a59692b158178d8905a61bf6fca95 # v2 with: tool: cargo-tarpaulin - name: Generate coverage working-directory: rust env: CARGO_BUILD_JOBS: "2" # --engine llvm uses LLVM source-based coverage (instrument-coverage + # llvm-profdata) instead of tarpaulin's default ptrace engine. ptrace # is non-deterministically fragile on hosted runners: it corrupts the # tracee's registers under load, surfacing as a bogus # "memory allocation of 1407…bytes failed" abort followed by # "ECHILD: No child processes" — a flake unrelated to the code (the # Test matrix on the same SHA stays green). The LLVM engine never # ptraces, so the whole class of flake disappears. run: | cargo tarpaulin --engine llvm --out xml --skip-clean --all-features --lib --timeout 180 \ --exclude-files 'src/cli/*' --exclude-files 'src/main.rs' \ -- --test-threads=1 pkill -f 'target.*lean-ctx' 2>/dev/null || true - name: Upload to Codecov uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4 with: fail_ci_if_error: false deny: name: cargo-deny runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 # v4 with: persist-credentials: false - uses: EmbarkStudios/cargo-deny-action@5bb39ff5d5a0e94dc9e2dc94eced0c6129743a57 # v2 with: manifest-path: rust/Cargo.toml adversarial: name: Adversarial Safety runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 # v4 with: persist-credentials: false - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: workspaces: rust -> target - name: Run adversarial compression tests working-directory: rust run: cargo test --test adversarial_compression -- --nocapture bench: name: Benchmarks runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: persist-credentials: false - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: workspaces: rust -> target - name: Compile benchmarks working-directory: rust run: cargo bench --no-run - name: Generate reproducible scorecard working-directory: rust run: cargo run --quiet -- benchmark scorecard --json --output ../scorecard.json - name: Upload scorecard artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: scorecard path: scorecard.json quality-gate: name: Output-Quality Gate (eval A/B) runs-on: ubuntu-latest timeout-minutes: 30 steps: - uses: actions/checkout@v4 # v4 with: persist-credentials: false - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: workspaces: rust -> target - name: Build lean-ctx working-directory: rust run: cargo build --quiet # Self-footprint gate (#578/#964): the fixed per-session cost lean-ctx # itself injects (advertised tool schemas + MCP instructions + wakeup) # must stay under budget. Runs in a scratch HOME + empty cwd so only OUR # footprint is measured — no repo rules files, no developer state. # Baseline after the diet: ~2147 tok (schemas 1685, instructions 462). - name: Self-footprint gate (doctor overhead) shell: bash env: LEAN_CTX_CONTEXT_BUDGET_TOKENS: "2600" run: | set -euo pipefail BIN="$GITHUB_WORKSPACE/rust/target/debug/lean-ctx" SCRATCH=$(mktemp -d) mkdir -p "$SCRATCH/home" "$SCRATCH/data" "$SCRATCH/work" cd "$SCRATCH/work" HOME="$SCRATCH/home" LEAN_CTX_DATA_DIR="$SCRATCH/data" "$BIN" doctor overhead --gate # Deterministic with/without output-quality proof. Three modes, in priority order: # 1. A committed recording (rust/eval/recording.json) → byte-stable replay, no secrets, # and the gate BLOCKS on a regression. # 2. Otherwise, if LEAN_CTX_EVAL_* secrets are set → capture from the live model + gate. # 3. Otherwise → skipped (the harness itself is still covered by the test job). # Capture a recording once with: lean-ctx eval ab --suite … --record rust/eval/recording.json - name: Run deterministic A/B output-quality gate working-directory: rust shell: bash env: LEAN_CTX_EVAL_MODEL_URL: ${{ secrets.LEAN_CTX_EVAL_MODEL_URL }} LEAN_CTX_EVAL_MODEL: ${{ secrets.LEAN_CTX_EVAL_MODEL }} LEAN_CTX_EVAL_MODEL_KEY: ${{ secrets.LEAN_CTX_EVAL_MODEL_KEY }} run: | set -euo pipefail BIN=./target/debug/lean-ctx REC=eval/recording.json "$BIN" eval init eval-suite if [ -f "$REC" ]; then echo "Replaying committed recording (deterministic, no secrets)…" "$BIN" eval ab --suite eval-suite/suite.ndjson --replay "$REC" --out ab-report.json --gate elif [ -n "${LEAN_CTX_EVAL_MODEL_URL:-}" ]; then echo "No committed recording; capturing from the configured live model…" mkdir -p eval "$BIN" eval ab --suite eval-suite/suite.ndjson --record "$REC" --out ab-report.json --gate else echo "::notice::Output-quality gate skipped — commit rust/eval/recording.json or set LEAN_CTX_EVAL_* secrets." fi - name: Verify artifact (signature + determinism digest) working-directory: rust shell: bash run: | if [ -f ab-report.json ]; then ./target/debug/lean-ctx eval verify ab-report.json; fi - name: Upload signed A/B report if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: eval-ab-report path: rust/ab-report.json if-no-files-found: ignore # Off-vs-on answer-quality testbench across pinned repos (#611). The committed # local-fixture subset replays with a recorded model → byte-stable, no secrets, # and BLOCKS on any per-repo regression. Refresh with: # lean-ctx eval testbench --lock eval/testbench/testbench.lock.json --record eval/testbench/recording.json - name: Run deterministic testbench gate (off vs on) working-directory: rust shell: bash run: | set -euo pipefail BIN=./target/debug/lean-ctx LOCK=eval/testbench/testbench.lock.json REC=eval/testbench/recording.json if [ -f "$REC" ]; then echo "Replaying committed testbench subset (deterministic, no secrets)…" "$BIN" eval testbench --lock "$LOCK" --replay "$REC" --out testbench-out --gate else echo "::notice::Testbench gate skipped — commit rust/eval/testbench/recording.json." fi - name: Upload testbench FINDINGS if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: testbench-findings path: | rust/testbench-out/FINDINGS.md rust/testbench-out/regressions.json if-no-files-found: ignore doc: name: Documentation runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 # v4 with: persist-credentials: false - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: workspaces: rust -> target - name: Check docs compile working-directory: rust env: RUSTDOCFLAGS: -D warnings run: cargo doc --no-deps --all-features - name: Check generated reference docs are current working-directory: rust run: cargo run --example gen_docs --features dev-tools -- --check - name: Check bundled registry snapshots are canonical working-directory: rust run: cargo run --example gen_registry --features dev-tools -- --check - name: Check committed testbench recording is current working-directory: rust run: cargo run --example gen_testbench_recording --features dev-tools -- --check # Single aggregating gate. Point branch protection at THIS one job ("CI Green") # instead of every matrix leg — it stays correct when jobs are added/removed and # cannot be bypassed by a silently-missing required check. Runs always() so it can # inspect results even when a dependency failed; fails only on failure/cancelled # (a legitimately skipped job — e.g. the optional quality-gate — does not block). ci-green: name: CI Green if: always() needs: - test - clippy - build-minimal - fmt - cookbook - pi-extension - rust-sdk - embed-sdk - python-sdk - hermes-plugin - sdk-conformance - coverage - deny - adversarial - bench - quality-gate - doc runs-on: ubuntu-latest steps: - name: Verify no required job failed shell: bash env: NEEDS_JSON: ${{ toJSON(needs) }} run: | echo "$NEEDS_JSON" python3 - <<'PY' import json, os, sys needs = json.loads(os.environ["NEEDS_JSON"]) blocked = {k: v["result"] for k, v in needs.items() if v["result"] in ("failure", "cancelled")} if blocked: print("Required CI jobs did not pass:", blocked) sys.exit(1) print("All required CI jobs passed (success or skipped).") PY # Security audit runs in security-check.yml workflow