headroom/.github/workflows/rust.yml
Tejas Chopra a30305bc4c
ci: require ONNX Runtime >= 1.24 and fail fast when it is missing or too old (#2591)
CI installed 'onnxruntime>=1.16.0' for the Rust ort runtime. That floor is 8
minor versions too low, and the failure mode below it is a silent hang.

Why 1.24: ort-sys computes ORT_API_VERSION = 17 + one per enabled api-N feature,
and Cargo features are additive across the graph. fastembed 5.17.3 enables
api-24, so the constant resolves to 24 and ort rejects any lower runtime.

Why it hangs instead of failing: on rejection ort calls Error::new() from inside
load_dylib_from_path, which already runs inside the Once that setup_api() is
initialising. Building the error re-enters that Once, and std::sync::Once blocks
forever on re-entry. Reproduced in isolation with a bare Session::builder() and
onnxruntime 1.21.1 - killed after >1h at 0% CPU, no output; ORT_DYLIB_PATH makes
no difference. With 1.24.4 the same call returns in 1.2s and the kompress parity
fixtures pass 21/21.

Per @RubenAAA this is not limited to old runtimes: a box that resolves no
libonnxruntime at all hangs identically (0.0% CPU, threads in futex_wait_queue,
nothing onnx-shaped in /proc/<pid>/maps). Any failure inside
load_dylib_from_path re-enters the Once, so a pin alone cannot close it.

So this adds a pre-flight to the dylib step asserting, before any test runs,
that onnxruntime imports, that its minor is >= 24, and that a libonnxruntime
object exists under capi/. Each failure exits 1 with an ::error:: annotation
naming the cause, instead of burning the 30-minute timeout with an empty log.

Verified all three branches locally (absent -> rc=1, 1.21.1 -> rc=1,
1.24.4 -> rc=0) and in CI, where it resolved onnxruntime 1.28.0 and exported
the .so path.

pyproject.toml is deliberately untouched: bumping the floor there makes
headroom-ai[all] unsatisfiable via a pillow chain (onnxruntime>=1.24 forces
pillow>=10.3.0,<12.0 while [all] requires pillow>=12.3.0). The user-facing
hazard via headroom/_ort.py remains open and needs its own change.
2026-07-27 08:04:53 -07:00

259 lines
10 KiB
YAML

name: rust
# Path gating lives in the `rust-changes` job below, NOT in a workflow-level
# `paths:` filter. The distinction matters for branch protection: a workflow
# skipped by `paths:` never creates its check runs at all, so a required status
# check from it sits pending forever on any PR that misses those paths, and the
# PR can never merge. A job skipped by `if:` still creates a check run, reports
# `skipped`, and GitHub counts skipped as success for a required check.
#
# Same coverage as the old filter — the path list moved verbatim into
# `rust-changes` — but `parity` is now safe to mark required on `main`.
on:
push:
branches: [ main, rust-rewrite ]
pull_request:
schedule:
# Nightly parity run at 07:17 UTC (weekdays only). Redundant with the
# per-PR gate below, but catches drift from toolchain/dependency updates
# that land without touching any filtered path.
- cron: '17 7 * * 1-5'
concurrency:
group: rust-${{ github.ref }}
cancel-in-progress: true
# Default permissions: read-only. Individual jobs override only what they need.
# Mitigates CodeQL/CWE-275 (missing-workflow-permissions): the GITHUB_TOKEN
# defaults to whatever the repo policy is, which can be read-write. Pinning
# this here means even if the repo default changes, this workflow stays safe.
permissions:
contents: read
jobs:
# Carries the path list the workflow-level `paths:` filter used to hold. Named
# `rust-changes` rather than `changes` so it does not collide with ci.yml's
# `changes` check.
rust-changes:
name: rust-changes
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
rust: ${{ steps.decide.outputs.rust }}
steps:
- uses: actions/checkout@v7
- uses: dorny/paths-filter@v4
id: filter
if: github.event_name == 'pull_request' || github.event_name == 'push'
with:
filters: |
rust:
- 'crates/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'tests/parity/**'
- 'Makefile'
- '.github/workflows/rust.yml'
- id: decide
# `schedule` and `workflow_dispatch` have no diff to filter against, so
# they run the full suite — that is the point of the nightly job.
run: |
case "${{ github.event_name }}" in
pull_request|push) echo "rust=${{ steps.filter.outputs.rust }}" >> "$GITHUB_OUTPUT" ;;
*) echo "rust=true" >> "$GITHUB_OUTPUT" ;;
esac
test:
name: test (ubuntu)
needs: rust-changes
if: needs.rust-changes.outputs.rust == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- name: Install stable toolchain
# Pin action code to @stable (latest fixes), toolchain version
# via input. The @1.95.0 ref shipped action code that errors on
# ubuntu-latest with `detected conflict: 'bin/cargo-clippy'`
# because the pre-installed runner Rust collides with the
# clippy-preview component install.
uses: dtolnay/rust-toolchain@stable
with:
toolchain: 1.95.0
components: rustfmt, clippy
- name: Cache cargo registry + build
uses: Swatinem/rust-cache@v2
- uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Provide ONNX Runtime dylib
# headroom-core is built with `ort-load-dynamic` (see its
# Cargo.toml): the ONNX Runtime shared library is dlopen'd at
# runtime instead of statically linked, so the magika/detection
# tests need a real libonnxruntime.so and ORT_DYLIB_PATH pointing
# at it — same contract `headroom/_ort.py` fulfills for Python
# users via the pip `onnxruntime` package.
run: |
# >= 1.24, not 1.16: `ort`'s ORT_API_VERSION resolves to 24 because
# `fastembed` enables its `api-24` feature.
pip install 'onnxruntime>=1.24'
# Pre-flight, not just a pin. `ort` deadlocks rather than errors on
# ANY failure inside `load_dylib_from_path` — a version mismatch and
# a library that cannot be resolved at all both re-enter the `Once`
# that `setup_api()` is initialising, and `std::sync::Once` blocks
# forever on re-entry. Either way the job burns its full 30-minute
# timeout at 0% CPU with nothing in the log. Assert both conditions
# here so a bad runner fails in seconds with a readable message.
python - <<'PY' >> "$GITHUB_ENV"
import pathlib, sys
def die(msg: str) -> None:
print(f"::error::{msg}", file=sys.stderr)
raise SystemExit(1)
try:
import onnxruntime
except Exception as exc: # noqa: BLE001 - any import failure is fatal here
die(f"onnxruntime is not importable: {exc}")
version = onnxruntime.__version__
try:
major, minor = (int(part) for part in version.split(".")[:2])
except ValueError:
die(f"cannot parse onnxruntime version {version!r}")
if (major, minor) < (1, 24):
die(
f"onnxruntime {version} is too old: ort requires >= 1.24 "
"(ORT_API_VERSION=24, set by fastembed's api-24 feature). "
"ort DEADLOCKS instead of erroring below this, so the tests "
"would hang rather than fail."
)
capi = pathlib.Path(onnxruntime.__file__).parent / "capi"
libs = sorted(capi.glob("libonnxruntime.so*")) or sorted(capi.glob("libonnxruntime*.dylib"))
if not libs:
die(f"no libonnxruntime shared library under {capi}")
print(f"ORT_DYLIB_PATH={libs[0]}")
print(f"onnxruntime {version} -> {libs[0]}", file=sys.stderr)
PY
- name: cargo fmt --check
run: cargo fmt --all -- --check
- name: cargo clippy
run: cargo clippy --workspace -- -D warnings
- name: cargo test
run: cargo test --workspace
simulator-e2e:
name: simulator e2e (${{ matrix.os }})
needs: rust-changes
if: needs.rust-changes.outputs.rust == 'true'
runs-on: ${{ matrix.os }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v7
- name: Install stable toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: 1.95.0
- name: Cache cargo registry + build
uses: Swatinem/rust-cache@v2
- name: cargo test simulator-backed proxy e2e
run: cargo test -p headroom-proxy --test e2e_simulators
wheels:
name: wheels (${{ matrix.target }})
needs: rust-changes
if: needs.rust-changes.outputs.rust == 'true'
runs-on: ${{ matrix.os }}
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
maturin-target: x86_64
- os: macos-14
target: aarch64-apple-darwin
maturin-target: aarch64-apple-darwin
- os: macos-15-intel
target: x86_64-apple-darwin
maturin-target: x86_64-apple-darwin
# Intel macOS uses `ort-load-dynamic` (no prebuilt ORT from ort-sys);
# Apple Silicon bundles ORT via `ort-download-binaries-rustls-tls`.
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: "Build wheel (single-wheel architecture builds headroom-ai)"
uses: PyO3/maturin-action@v1
# Maturin reads `[tool.maturin]` from the root `pyproject.toml`
# which points at `crates/headroom-py/Cargo.toml` for the cdylib.
# Output is `headroom_ai-<ver>-<py>-<py>-<platform>.whl` containing
# both Python source and the compiled `headroom/_core.so`.
with:
command: build
args: --release --out dist
target: ${{ matrix.maturin-target }}
- name: Upload wheel artifact
uses: actions/upload-artifact@v7
with:
name: wheels-${{ matrix.target }}
path: dist/*.whl
audit:
name: audit
needs: rust-changes
if: needs.rust-changes.outputs.rust == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: 1.95.0
- uses: Swatinem/rust-cache@v2
- name: Install cargo-audit + cargo-deny
uses: taiki-e/install-action@v2
with:
tool: cargo-audit,cargo-deny
- name: cargo audit (soft-fail)
continue-on-error: true
run: cargo audit
- name: cargo deny check licenses
continue-on-error: true
run: cargo deny check licenses
# Blocking on every PR that touches Rust. Safe to harden now because the
# harness fails only on a Diff — `parity-run` sets `any_diffs` inside the
# diffed loop alone, so the 65 fixtures still served by `stub_comparator!`
# report as Skipped and cannot turn this red. Measured on main today:
# 111 matched / 65 skipped / 0 diffed.
#
# What it protects: the recorded fixtures are frozen Python output, so this
# gate catches the Rust side drifting away from that snapshot — exactly the
# failure mode the ongoing port produces. It cannot detect the Python side
# drifting away from the fixtures; that needs re-recording, not this job.
parity:
name: parity
needs: rust-changes
if: needs.rust-changes.outputs.rust == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: 1.95.0
- uses: Swatinem/rust-cache@v2
# No Python toolchain: headroom-parity links headroom-core directly and
# never crosses into the interpreter, so the venv + maturin + `pip
# install -e .` setup this job used to do was pure overhead.
- name: Run parity harness
run: make test-parity