feat(salvage): total recovery for SSTs and blob files with in-place ECC autoheal #3461
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Hardening policy: every `uses:` ref is pinned to a full commit | |
| # SHA with an inline `# vX.Y.Z` comment; every `actions/checkout` | |
| # step sets `persist-credentials: false` unless a later step in | |
| # the same job legitimately runs `git push`. Disabled/reference | |
| # workflows live in `.github/disabled/`, not here, so audit | |
| # scanners stay clean. | |
| name: CoordiNode CI | |
| on: | |
| push: | |
| branches: | |
| - main | |
| pull_request: | |
| branches: | |
| - main | |
| # Manual re-run from any branch. Two use cases: | |
| # (a) re-trigger a flaky cross-compile target after qemu | |
| # hiccup — needs `full_cross_matrix: true` because the | |
| # flaky ones (powerpc64, riscv64) live ONLY in the full | |
| # matrix, not the PR/dispatch default; | |
| # (b) maintainer wants a full CI cycle on a draft branch | |
| # without opening a PR — also `full_cross_matrix: true` | |
| # to mirror main-branch coverage. | |
| # Without the input set (default), manual dispatch runs the | |
| # same reduced 2-target cross matrix that PRs do (aarch64-gnu | |
| # + i686-gnu) — useful for quick sanity-check re-runs. | |
| # Triggering workflow_dispatch from the UI / API requires | |
| # repository write access (not the workflow-token `actions:write` | |
| # scope, which is a different thing). Fork PR authors don't | |
| # have write on the upstream repo, so they can't fire this — | |
| # full_cross_matrix has no fork-side attack surface. | |
| workflow_dispatch: | |
| inputs: | |
| full_cross_matrix: | |
| description: "Run the full 5-target cross-compile matrix (aarch64-gnu/musl, i686-gnu, powerpc64-gnu, riscv64gc-gnu) instead of the reduced 2-target subset" | |
| type: boolean | |
| default: false | |
| required: false | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| env: | |
| CARGO_TERM_COLOR: always | |
| jobs: | |
| # Detect whether the change set touches code we want to validate. | |
| # Used to gate the inner steps of `lint` and `test (stable, | |
| # ubuntu-latest)` — both of which are REQUIRED checks in the | |
| # branch ruleset — so their job names always surface to GitHub | |
| # (satisfying the ruleset) while heavy work is skipped on | |
| # docs-only / repo-meta-only PRs. Non-required downstream jobs | |
| # (`no-std-check`, `cross-matrix`, `cross`, | |
| # `codecov`) gate at the job level via `if:` and skip outright | |
| # when no code changes — they aren't required so a "skipped" | |
| # status is fine for them, but a required check that reports | |
| # "skipped" is treated as "not yet satisfied" by the ruleset | |
| # engine, hence the in-job conditional pattern below for the | |
| # two required jobs. | |
| changes: | |
| runs-on: ubuntu-latest | |
| # Bumped from the original 2 min — generous timeout so a slow | |
| # `/pulls/{N}/files` page on a large PR can't run us into a | |
| # hard wall. The job is also designed to NEVER fail (every | |
| # path below resolves to a `code` output, even on API errors) | |
| # so a missed deadline is the only remaining failure mode and | |
| # 10 min gives plenty of margin. | |
| timeout-minutes: 10 | |
| # Read-only token: the `pull_request` branch of the script | |
| # below hits `/pulls/{N}/files`; non-PR events | |
| # (`push` to main, `workflow_dispatch`) bypass the API | |
| # entirely and just emit `code=true`. Either way, we never | |
| # push and never write. Explicit `permissions:` block scopes | |
| # the auto-provided GITHUB_TOKEN to exactly what this job | |
| # needs, regardless of repo-default permission settings. | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| outputs: | |
| code: ${{ steps.filter.outputs.code }} | |
| steps: | |
| # No `actions/checkout` step on purpose: the change predicate | |
| # uses GitHub's REST API to list affected files via | |
| # `/pulls/{N}/files` for the `pull_request` event. That | |
| # avoids the `actions/checkout@v6 + fetch-depth: 0 + | |
| # persist-credentials: false` 403 we hit on the first | |
| # attempt, and skips the clone cost entirely for a job that | |
| # only needs path names. Non-PR events (`push` to main, | |
| # `workflow_dispatch`) bypass the path filter entirely and | |
| # always emit `code=true` — see the inline rationale below. | |
| - name: Detect code-touching changes | |
| id: filter | |
| # Override the runner's default `-eo pipefail` flags. | |
| # GitHub Actions invokes the default `bash` step with | |
| # `bash --noprofile --norc -eo pipefail {0}`, so a script | |
| # that just adds `set -u` STILL inherits `-e` + pipefail | |
| # from the runner. This job is a hard dependency of the | |
| # required `lint` + `test (stable, ubuntu-latest)` checks | |
| # (they `needs: changes`); any non-zero exit cascades to | |
| # "required check never reports" and blocks merges, which | |
| # is exactly the failure mode we built the safe-default | |
| # output for. Keep `--noprofile --norc` so we still match | |
| # the runner's clean-env defaults (no inherited dotfiles | |
| # from a self-hosted runner with profile.d / bashrc) — | |
| # we only drop the `-eo pipefail` half deliberately. | |
| shell: bash --noprofile --norc {0} | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| EVENT_NAME: ${{ github.event_name }} | |
| REPO: ${{ github.repository }} | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| run: | | |
| set -u | |
| # Helper: emit a `code` output, log a reason, and exit 0 | |
| # so dependent jobs always have a valid value to consume. | |
| emit_and_exit() { | |
| local code_value="$1" | |
| local reason="$2" | |
| echo "::notice::changes job: code=$code_value ($reason)" | |
| echo "code=$code_value" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| } | |
| if [ "$EVENT_NAME" = "pull_request" ]; then | |
| # GitHub's own PR file list is the merge-base diff — | |
| # exactly what we want, without resolving merge-base | |
| # ourselves. Paginated to handle PRs touching >100 files | |
| # (the API caps each page at 100, more pages auto-fetch). | |
| # stdout / stderr are captured into SEPARATE variables: | |
| # stdout (filename list) goes into CHANGED, stderr (any | |
| # warnings or rate-limit messages) is preserved in | |
| # API_ERR for the error notice. Mixing the two streams | |
| # would let stderr noise leak into the CODE_RE predicate | |
| # below and possibly mis-classify the run. | |
| echo "Fetching PR #$PR_NUMBER file list via REST API..." | |
| API_ERR_FILE=$(mktemp) | |
| if ! CHANGED=$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" --jq '.[].filename' 2>"$API_ERR_FILE"); then | |
| # API/auth/rate-limit failure: don't cascade. Default | |
| # to `code=true` so the real lint/test runs anyway. | |
| # Surface the underlying error message in the notice | |
| # so an operator debugging a flake sees what GitHub | |
| # actually returned. | |
| api_err=$(cat "$API_ERR_FILE" | tr '\n' ' ' | head -c 200) | |
| rm -f "$API_ERR_FILE" | |
| emit_and_exit "true" "REST /pulls/$PR_NUMBER/files failed (${api_err:-no stderr}), defaulting to full CI" | |
| fi | |
| rm -f "$API_ERR_FILE" | |
| else | |
| # Any non-`pull_request` event (push to main per the | |
| # workflow trigger, plus `workflow_dispatch` for manual | |
| # re-runs) bypasses the path filter and runs full CI. | |
| # | |
| # Rationale: the path-filter optimisation exists for | |
| # developer iteration on PR branches (typical PR is | |
| # N small commits, fast feedback matters). Merges to | |
| # main are integration events validating what ships; | |
| # manual workflow dispatches are operator-initiated and | |
| # are by definition non-routine. Neither has an | |
| # iteration loop to optimise. Skipping CI on a | |
| # "docs-only" push to main would ALSO be structurally | |
| # unsafe given the compare API's known 300-entry | |
| # `.files` truncation cap — a large code-changing merge | |
| # could be misclassified as `code=false` and silently | |
| # skip validation. | |
| emit_and_exit "true" "$EVENT_NAME event — always run full CI (no path filter outside PRs)" | |
| fi | |
| echo "Changed files:" | |
| printf '%s\n' "$CHANGED" | sed 's/^/ /' | |
| # Predicate: anything that affects compile / lint / test | |
| # results counts as code. The workflow file is included so | |
| # changes to CI logic itself always trigger the full matrix | |
| # (avoids self-skipping a CI refactor PR). The lint/test | |
| # config files (`.config/nextest.toml`, `.rustfmt.toml`, | |
| # `clippy.toml`) and the in-repo test fixtures | |
| # (`test_fixture/`) are included because changes to any of | |
| # them can flip the lint/test outcome on otherwise-identical | |
| # source code — skipping the real validation on a PR that | |
| # touches only those files would defeat the purpose of the | |
| # required-check gate. | |
| CODE_RE='^(src/|tests/|benches/|examples/|test_fixture/|build\.rs$|Cargo\.toml$|Cargo\.lock$|tools/|\.github/workflows/coordinode-ci\.yml$|rust-toolchain(\.toml)?$|\.cargo/|\.config/nextest\.toml$|\.rustfmt\.toml$|clippy\.toml$)' | |
| if printf '%s\n' "$CHANGED" | grep -qE "$CODE_RE"; then | |
| emit_and_exit "true" "code paths affected — running real CI" | |
| else | |
| emit_and_exit "false" "no code paths affected — fast success" | |
| fi | |
| lint: | |
| needs: changes | |
| # `always() && !cancelled()` so the required `lint` check | |
| # still runs (and emits its name to the ruleset) even if | |
| # `changes` itself fails / is skipped — `!cancelled()` alone | |
| # is NOT enough because GitHub's default needs-gating skips | |
| # this job when a dependency fails. `always()` overrides that | |
| # gating; the `!cancelled()` half lets a user-cancelled | |
| # workflow run still report cancellation honestly. Empty | |
| # `needs.changes.outputs.code` (changes job failed before | |
| # emitting output) is caught below by the `!= 'false'` step | |
| # guards, which default to running the real lint work (safe | |
| # fallback). | |
| if: ${{ always() && !cancelled() }} | |
| timeout-minutes: 10 | |
| runs-on: ubuntu-latest | |
| steps: | |
| # Surfaces a notice in the run log + summary so docs-only PRs | |
| # leave an obvious trail explaining why the check passed | |
| # without doing real work. Only fires on the explicit | |
| # `code=false` signal — empty/unset (changes job failed) | |
| # falls through to the real lint work below. | |
| - name: Path-conditional skip notice | |
| if: needs.changes.outputs.code == 'false' | |
| run: | | |
| echo "::notice::No code paths changed — skipping format + clippy. Required-check name still emitted as success." | |
| - uses: actions/checkout@v7 | |
| if: needs.changes.outputs.code != 'false' | |
| with: | |
| persist-credentials: false | |
| - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | |
| if: needs.changes.outputs.code != 'false' | |
| with: | |
| toolchain: stable | |
| components: rustfmt, clippy | |
| - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 | |
| if: needs.changes.outputs.code != 'false' | |
| - name: Format | |
| if: needs.changes.outputs.code != 'false' | |
| run: cargo fmt --all -- --check | |
| - name: Clippy (strict) | |
| if: needs.changes.outputs.code != 'false' | |
| # `--all-targets` lints tests + benches too, not just the lib; without | |
| # it, test-harness code under `--all-features` is never clippy-checked. | |
| run: cargo clippy --all-features --all-targets -- -D warnings | |
| - name: Clippy (default features) | |
| if: needs.changes.outputs.code != 'false' | |
| # The feature set a consumer gets by simply depending on the crate. | |
| # `--all-features` does NOT cover it: code reachable only when a | |
| # feature is OFF is never compiled there, so this configuration rotted | |
| # unnoticed until its tests stopped compiling altogether. Lint both, or | |
| # the one nobody builds is the one that breaks. | |
| run: cargo clippy --all-targets -- -D warnings | |
| test: | |
| needs: [changes, lint] | |
| # `always() && !cancelled()` so the matrix entries still | |
| # emit their names to the ruleset even if `changes` or | |
| # `lint` fails / is skipped. `!cancelled()` alone is NOT | |
| # enough because GitHub's default needs-gating skips this | |
| # job when a dependency fails; `always()` overrides that | |
| # gating, the `!cancelled()` half lets a user-cancelled | |
| # workflow run still report cancellation honestly. Empty | |
| # `needs.changes.outputs.code` is treated as "run real | |
| # tests" via the `!= 'false'` step guards. | |
| # | |
| # Matrix-aware skip (run only the required entry on docs- | |
| # only PRs) was attempted via a job-level | |
| # `matrix.rust == 'stable' && matrix.os == 'ubuntu-latest'` | |
| # branch in this `if:`, but the `matrix` context is NOT | |
| # available at job-level expression evaluation — only at | |
| # step-level and in job names. All 6 matrix entries run on | |
| # docs-only PRs and emit the skip notice from their own | |
| # step-level guards; if optimising runner-minute waste is | |
| # later required, the right shape is a dynamic matrix via | |
| # `fromJSON(needs.changes.outputs.matrix)` (with `changes` | |
| # emitting two pre-baked JSON values), not job-level `if:`. | |
| if: ${{ always() && !cancelled() }} | |
| timeout-minutes: 20 | |
| strategy: | |
| fail-fast: true | |
| matrix: | |
| rust: [stable, "1.92.0"] | |
| os: [ubuntu-latest, windows-latest, macos-latest] | |
| runs-on: ${{ matrix.os }} | |
| steps: | |
| # Same path-conditional skip pattern as `lint`. ALL matrix | |
| # entries emit their job name regardless of whether the inner | |
| # steps ran, so `test (stable, ubuntu-latest)` — the entry | |
| # listed in the branch ruleset as a required check — is | |
| # always satisfied on docs-only PRs / lint-failed PRs | |
| # without paying the full test cost. | |
| # | |
| # Three step-level cases for the inner work: | |
| # - code == 'false' → skip notice (docs-only PR) | |
| # - lint.result != 'success' → skip notice (lint broken; running tests adds noise on an already-failed PR) | |
| # - code != 'false' AND lint.result == 'success' → real work | |
| # The job-level `if: always() && !cancelled()` keeps the | |
| # required check name surfacing in all three cases. | |
| - name: Path-conditional skip notice | |
| if: needs.changes.outputs.code == 'false' | |
| run: | | |
| echo "::notice::No code paths changed — skipping nextest + doc tests + tools/. Required-check name still emitted as success." | |
| - name: Lint-failed skip notice | |
| if: needs.changes.outputs.code != 'false' && needs.lint.result != 'success' | |
| run: | | |
| echo "::notice::Lint failed (result=${{ needs.lint.result }}) — skipping test work to avoid duplicate noise on an already-failed PR. Required-check name still emitted; fix the lint failure to unblock real test runs." | |
| - uses: actions/checkout@v7 | |
| if: needs.changes.outputs.code != 'false' && needs.lint.result == 'success' | |
| with: | |
| persist-credentials: false | |
| - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | |
| if: needs.changes.outputs.code != 'false' && needs.lint.result == 'success' | |
| with: | |
| toolchain: ${{ matrix.rust }} | |
| - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 | |
| if: needs.changes.outputs.code != 'false' && needs.lint.result == 'success' | |
| with: | |
| prefix-key: ${{ runner.os }}-cargo | |
| - uses: taiki-e/install-action@1ef5c5f58e85d25baaaa1704478fdd6c2921f2b5 # nextest | |
| if: needs.changes.outputs.code != 'false' && needs.lint.result == 'success' | |
| - name: Run tests | |
| if: needs.changes.outputs.code != 'false' && needs.lint.result == 'success' | |
| # tools/db_bench is a standalone crate, not in workspace — built separately | |
| env: | |
| # 32 cases is plenty for CI; PROPTEST_MAX_SHRINK trims the | |
| # shrink budget from 1000 → 100 because at this case count | |
| # shrinking rarely exceeds 20–50 iterations and the extra | |
| # budget is wasted CI time. See tests/common/mod.rs. | |
| PROPTEST_CASES: "32" | |
| PROPTEST_MAX_SHRINK: "100" | |
| run: cargo nextest run --profile ci --all-features | |
| - name: Run doc tests | |
| if: needs.changes.outputs.code != 'false' && needs.lint.result == 'success' | |
| run: cargo test --doc --features lz4 | |
| - name: Check db_bench crate | |
| if: needs.changes.outputs.code != 'false' && needs.lint.result == 'success' | |
| working-directory: tools/db_bench | |
| run: cargo check --all-features | |
| - name: Build + test sst-dump crate | |
| if: needs.changes.outputs.code != 'false' && needs.lint.result == 'success' | |
| # Standalone crate (not in workspace), exercise both the bin | |
| # build and the integration smoke tests so any drift in the | |
| # public verify::verify_sst_file API or the SST writer layout | |
| # surfaces here instead of in production. No `--profile ci`: | |
| # sst-dump has no .config/nextest.toml of its own and the | |
| # parent crate's profile config is not visible from a | |
| # non-workspace child. | |
| working-directory: tools/sst-dump | |
| run: cargo nextest run | |
| fuzz-heal: | |
| # Reproducible single-byte-bitrot fuzzer over the SST read / heal path: | |
| # flips one bit in a corpus of SSTs (varied block size, per-KV checksum, | |
| # columnar, compression, encryption, Page-ECC) for a fixed ~45s budget and | |
| # asserts the read path never panics and never returns a wrong value (a | |
| # flipped block heals via ECC or fails its checksum, never silent corruption). | |
| # `#[ignore]`d, so it is excluded from the normal `test` job and run here with | |
| # `--run-ignored=only`. Ubuntu-only (the invariant is platform-independent); | |
| # not a required check. | |
| needs: [changes, lint] | |
| if: ${{ needs.changes.outputs.code != 'false' && needs.lint.result == 'success' }} | |
| timeout-minutes: 15 | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| persist-credentials: false | |
| - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | |
| with: | |
| toolchain: stable | |
| - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 | |
| with: | |
| prefix-key: ubuntu-cargo | |
| - uses: taiki-e/install-action@1ef5c5f58e85d25baaaa1704478fdd6c2921f2b5 # nextest | |
| - name: Run bitrot heal fuzzer | |
| # Pin the reproducer directory explicitly so the test's dump location does | |
| # not depend on the process working directory (robust if the crate ever | |
| # moves into a workspace subdirectory). The dump step reads the same path. | |
| env: | |
| FUZZ_HEAL_REPRO_DIR: ${{ github.workspace }} | |
| # `--retries 0` overrides the ci profile's retries: the fuzzer dumps the | |
| # EXACT failing SST, and a retry (on a non-deterministic corpus) would | |
| # either overwrite that dump or mask a real failure that does not replay. | |
| # `ignored-only` is the documented selector value; the shorter `only` | |
| # is a deprecated alias current nextest still accepts (this job ran | |
| # fine on it) but newer releases may drop. | |
| run: cargo nextest run --profile ci --all-features --run-ignored=ignored-only --retries 0 -E 'test(fuzz_heal_bitrot)' | |
| - name: Dump reproducer on failure | |
| if: failure() | |
| # The corpus is not byte-deterministic (encrypted / timestamped SSTs), so | |
| # the seed alone cannot replay a failure in those. The test writes the | |
| # EXACT failing SST to `fuzz_heal_repro.sst`; surface it (base64) in the | |
| # log so the case reproduces directly, no artifact upload needed. | |
| env: | |
| FUZZ_HEAL_REPRO_DIR: ${{ github.workspace }} | |
| run: | | |
| echo "=== fuzz_heal_repro.txt ===" | |
| cat "$FUZZ_HEAL_REPRO_DIR/fuzz_heal_repro.txt" 2>/dev/null || echo "(no repro txt)" | |
| echo "=== fuzz_heal_repro.sst (base64) ===" | |
| base64 "$FUZZ_HEAL_REPRO_DIR/fuzz_heal_repro.sst" 2>/dev/null || echo "(no repro sst)" | |
| no-std-check: | |
| # Gates the `#![no_std]` + alloc engine path. The engine modules compile | |
| # unconditionally; only the std default trait implementations (the system | |
| # filesystem, the io_uring backend, the system clock) stay behind | |
| # `#[cfg(feature = "std")]`. This job runs `cargo check --no-default-features | |
| # --features alloc` on a true no-std-only target (the `thumbv7em-none-eabihf` | |
| # cross-compile target has no std at all, so any leaked `std::*` import | |
| # surfaces here even if the host toolchain has std available). | |
| # | |
| # This is a HARD GATE: the alloc build must stay clean. New code added to | |
| # this crate MUST be no-std-friendly (prefer `core::*` / `alloc::*`, gate | |
| # std-only behind `#[cfg(feature = "std")]`). A failure here means a | |
| # `std::*` leak crept onto an engine path: fix the leak, do not relax the | |
| # gate. | |
| needs: [changes, lint] | |
| if: ${{ always() && !cancelled() && needs.lint.result == 'success' && needs.changes.outputs.code != 'false' }} | |
| timeout-minutes: 10 | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| persist-credentials: false | |
| - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | |
| with: | |
| toolchain: "1.92.0" | |
| - name: Install no-std target | |
| # Some versions of `dtolnay/rust-toolchain@stable` silently skip the | |
| # `targets:` input when `toolchain:` is pinned to a specific version | |
| # rather than a channel name. Install the cross target explicitly so | |
| # the no-std check can never break for a target-not-installed reason | |
| # (which would mask real migration progress). | |
| run: rustup target add thumbv7em-none-eabihf | |
| - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 | |
| with: | |
| prefix-key: ubuntu-cargo-no-std | |
| - name: Check (no_std + alloc) | |
| run: cargo check --target thumbv7em-none-eabihf --no-default-features --features alloc | |
| # Generates the cross-compile target matrix. The full 5-target | |
| # sweep runs in either of two cases: | |
| # - push to main (regression coverage on every merged change) | |
| # - workflow_dispatch with `full_cross_matrix: true` (manual | |
| # opt-in for re-testing flaky targets or for full coverage | |
| # on a draft branch) | |
| # Everything else — PR builds + default manual dispatches — | |
| # uses the reduced 2-target subset (aarch64-gnu + i686-gnu) | |
| # that covers the two canonical pointer-width / ABI mistakes: | |
| # 64-bit aarch64 ABI quirks and 32-bit x86 pointer-width. | |
| # Both targets are little-endian — endianness coverage (the | |
| # big-endian powerpc64 target) is deferred to the full sweep | |
| # on main, alongside musl libc differences and the qemu- | |
| # emulated riscv64 path. The reduced run is ~30% of the | |
| # full-sweep cost and keeps PR feedback under 5 min. | |
| cross-matrix: | |
| # No `needs: lint` — this job only inspects github.event_name | |
| # / github.ref / inputs to emit a JSON target list. It doesn't | |
| # touch source, doesn't compile, doesn't run lint-sensitive | |
| # tooling. Running it in parallel with lint shaves ~10s off | |
| # the PR critical path: the matrix is ready when lint | |
| # finishes, and `cross` (which `needs: [changes, lint, | |
| # cross-matrix]`) starts immediately rather than waiting for | |
| # serial chain. | |
| needs: changes | |
| # cross-matrix doesn't depend on `lint` (it's a pure | |
| # github.event_name / inputs lookup that emits a JSON target | |
| # list), so the `needs.lint.result` gate the other downstream | |
| # jobs carry doesn't apply here — the matrix-generation work | |
| # is cheap (~2 s) and independent of lint passing. The | |
| # `always() && !cancelled()` half still applies so a `changes` | |
| # infra-failure doesn't silently skip the matrix generator | |
| # (which would cascade into `cross` skipping for the wrong | |
| # reason). | |
| if: ${{ always() && !cancelled() && needs.changes.outputs.code != 'false' }} | |
| runs-on: ubuntu-latest | |
| outputs: | |
| targets: ${{ steps.set.outputs.targets }} | |
| steps: | |
| - id: set | |
| env: | |
| IS_FULL: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'workflow_dispatch' && inputs.full_cross_matrix) }} | |
| run: | | |
| if [ "$IS_FULL" = "true" ]; then | |
| echo 'targets=["aarch64-unknown-linux-gnu","aarch64-unknown-linux-musl","i686-unknown-linux-gnu","powerpc64-unknown-linux-gnu","riscv64gc-unknown-linux-gnu"]' >> "$GITHUB_OUTPUT" | |
| else | |
| echo 'targets=["aarch64-unknown-linux-gnu","i686-unknown-linux-gnu"]' >> "$GITHUB_OUTPUT" | |
| fi | |
| cross: | |
| needs: [changes, lint, cross-matrix] | |
| if: ${{ always() && !cancelled() && needs.lint.result == 'success' && needs.changes.outputs.code != 'false' }} | |
| timeout-minutes: 15 | |
| strategy: | |
| fail-fast: true | |
| matrix: | |
| # Bracket notation REQUIRED on the needs context key here: | |
| # the job id `cross-matrix` contains a hyphen, so dot | |
| # notation (`needs.cross-matrix.outputs.targets`) is parsed | |
| # by the GitHub Actions expression engine as `needs.cross` | |
| # minus `matrix.outputs.targets`, breaking the lookup. | |
| target: ${{ fromJSON(needs['cross-matrix'].outputs.targets) }} | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| persist-credentials: false | |
| - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | |
| with: | |
| toolchain: stable | |
| - name: Install cross | |
| run: cargo install cross | |
| - name: Cross test | |
| # proptest cases: 32 hardcoded in ProptestConfig | |
| run: cross test -r --features lz4 --target ${{ matrix.target }} | |
| codecov: | |
| needs: [changes, lint] | |
| if: ${{ always() && !cancelled() && needs.lint.result == 'success' && needs.changes.outputs.code != 'false' }} | |
| timeout-minutes: 20 | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| persist-credentials: false | |
| - uses: dtolnay/rust-toolchain@5b842231ba77f5c045dba54ac5560fed2db780e2 # nightly | |
| with: | |
| components: llvm-tools-preview | |
| - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 | |
| - uses: taiki-e/install-action@6bd9352acd589686fbc4cff533c083c3366dd444 # cargo-llvm-cov | |
| - uses: taiki-e/install-action@1ef5c5f58e85d25baaaa1704478fdd6c2921f2b5 # nextest | |
| - name: Run tests (all-features) with coverage | |
| env: | |
| # Same proptest budget as the regular test job — without | |
| # these, the coverage job would default to proptest's | |
| # built-in 256 cases and dominate CI time. See | |
| # tests/common/mod.rs. | |
| PROPTEST_CASES: "32" | |
| PROPTEST_MAX_SHRINK: "100" | |
| run: cargo +nightly llvm-cov --no-report nextest --all-features | |
| # zstd feature: run with a narrower feature set (zstd + lz4, no defaults) | |
| # to validate the zstd backend in isolation. --all-features already enables | |
| # zstd (zstd-pure is an alias), so this step covers the non-default path. | |
| - name: Run tests (zstd backend) with coverage | |
| env: | |
| PROPTEST_CASES: "32" | |
| PROPTEST_MAX_SHRINK: "100" | |
| run: cargo +nightly llvm-cov --no-report nextest --no-default-features --features zstd,lz4 | |
| - run: cargo +nightly llvm-cov --no-report --doc --features lz4 | |
| - run: cargo +nightly llvm-cov report --doctests --lcov --output-path lcov.info --ignore-filename-regex='registry' | |
| - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 | |
| with: | |
| files: lcov.info | |
| env: | |
| CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} |