Skip to content

fix(ci): publish headroom-core-py binary wheels — fixes #355#357

Closed
chopratejas wants to merge 6 commits intomainfrom
hotfix-355-publish-rust-wheels
Closed

fix(ci): publish headroom-core-py binary wheels — fixes #355#357
chopratejas wants to merge 6 commits intomainfrom
hotfix-355-publish-rust-wheels

Conversation

@chopratejas
Copy link
Copy Markdown
Owner

Closes #355.

Summary

v0.20.13 shipped to PyPI as headroom_ai-0.20.13-py3-none-any.whl (pure-Python). The release workflow's Build Python package step runs python -m build which invokes hatch only — maturin was never called, so headroom._core was never compiled into the published wheel. A0's fail-loud startup check correctly catches the missing module and exits 78.

What this PR does

1. .github/workflows/release.yml — new build-rust-wheels job

  • 4-platform matrix: linux x86_64, linux aarch64, macos x86_64, macos aarch64.
  • Per-Python-version wheels for cp310, cp311, cp312, cp313 (and cp314+ via PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 — proper fix is bumping PyO3 ≥0.23, tracked separately).
  • Built via PyO3/maturin-action@v1 with manylinux: auto for x86_64 and manylinux: 2_28 for aarch64.
  • publish-pypi now depends on build-rust-wheels and merges the rust wheels into dist/ before the OIDC PyPI upload — single trusted-publisher credential, single artifact upload.

2. scripts/version-sync.py — sync Rust crate versions on release

  • New update_rust_pyproject_version() and update_rust_cargo_version() helpers keep crates/headroom-py/Cargo.toml and crates/headroom-py/pyproject.toml in lockstep with the headroom-ai release. Wheel filename (headroom_core_py-X.Y.Z-cp311-...whl) matches the dependency declaration.
  • Rewrites the headroom-core-py>=… dep line in root pyproject.toml on each release sync.

3. pyproject.toml — runtime dependency on headroom-core-py

  • Added "headroom-core-py>=0.20.13" so pip install headroom-ai automatically resolves the correct platform wheel.

Workaround for users stuck on v0.20.13

From the issue:

  • Build from source: PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 maturin build --release -m crates/headroom-py/Cargo.toml
  • OR HEADROOM_REQUIRE_RUST_CORE=false for degraded Python-only mode.

Test plan

  • make ci-precheck PASSED locally
  • python scripts/version-sync.py --version 0.20.99 produces the expected diff (Cargo.toml + pyproject.toml + headroom-py/* + plugin manifests all bumped to 0.20.99; deps line in root pyproject rewritten to headroom-core-py>=0.20.99)
  • After merge, the next release will trigger build-rust-wheels and produce 4 platform × 4 Python wheels — that's the smoke test the existing wheels (aarch64-apple-darwin) and wheels (x86_64-unknown-linux-gnu) checks already cover, just now wired into release publish too.

… (issue #355)

v0.20.13 shipped to PyPI as `headroom_ai-0.20.13-py3-none-any.whl` —
pure Python, no Rust extension. The release workflow's `Build Python
package` step runs `python -m build`, which invokes hatch only;
maturin was never called, so `headroom._core` was never compiled or
shipped. With A0's fail-loud startup check, users hit
`event=rust_core_missing reason=ModuleNotFoundError action=exit_78`.

Reported by @SvenMeyer in #355.

Fix:

1. .github/workflows/release.yml — new `build-rust-wheels` job that
   runs maturin on a 4-platform matrix (linux x86_64, linux aarch64,
   macos x86_64, macos aarch64) for cp310/cp311/cp312/cp313. Sets
   PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 so PyO3 0.22.6 builds work
   on Python 3.14 until we bump PyO3 (tracked separately).
   `publish-pypi` now depends on this job and merges the rust wheels
   into `dist/` before the OIDC PyPI upload.

2. scripts/version-sync.py — sync the same release version into
   `crates/headroom-py/Cargo.toml` and `crates/headroom-py/pyproject.toml`
   so the wheel filename matches the headroom-ai version. Also rewrites
   the new `headroom-core-py>=…` dep line so `pip install headroom-ai`
   resolves the correct platform wheel.

3. pyproject.toml — add `"headroom-core-py>=0.20.13"` to runtime deps.

For users stuck on v0.20.13:
  - Build from source with `PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1`
  - OR `HEADROOM_REQUIRE_RUST_CORE=false` for degraded Python-only mode.
Two PR-CI failures from the runtime-dep declaration:

1. workflow-validation: actionlint rejected `macos-13` — deprecated
   runner label. Replaced with `macos-15-intel` (per actionlint's list
   of valid macOS labels).

2. test-agno / test-extras / docker-native-e2e / test (matrix):
   `pip install -e .` failed with
       ERROR: Could not find a version that satisfies the requirement
       headroom-core-py>=0.20.13 (from headroom-ai)
   Chicken-and-egg: this PR is what publishes headroom-core-py to
   PyPI, so PR-CI install can't resolve the constraint from PyPI yet.

Fix:

* New composite action `.github/actions/build-rust-core-wheel/`:
  builds the headroom-core-py wheel locally via maturin into a
  `wheelhouse/` directory and exports `PIP_FIND_LINKS=$wheelhouse`
  to GITHUB_ENV. Every subsequent `pip install` in the same job
  resolves the constraint from the local wheelhouse.

* Lower the runtime-dep floor from `>=0.20.13` to `>=0.1.0` so
  freshly-built local wheels (Cargo.toml `version = "0.1.0"`) satisfy
  the constraint without needing version-sync to mutate
  pyproject.toml mid-build. version-sync.py still rewrites the floor
  on each release, so users `pip install`ing from PyPI get a
  release-tied pairing.

* Wire the composite action into `test`, `test-extras`, `test-agno`
  jobs in ci.yml — runs BEFORE the existing `pip install -e .` step.

* Restructure Dockerfile: build the Rust wheel BEFORE the `uv pip
  install ".[${HEADROOM_EXTRAS}]"` stub install. Set
  `PIP_FIND_LINKS=/build/wheels` + `UV_FIND_LINKS=/build/wheels` as
  ENV so both pip and uv pick up the local wheel.

Net behavior: every CI install path that needs `headroom-core-py`
gets it from a freshly-built local wheel; production installs from
PyPI continue to work normally once the next release lands.
Two follow-up failures from the runtime-dep declaration:

1. Python 3.10 in the composite action
   `python -c 'import tomllib'` fails on 3.10 (tomllib is 3.11+).
   Replaced with grep/sed on pyproject.toml — version line is
   `version = "..."`, regex extraction is portable across all
   supported Python versions.

2. e2e/wrap/Dockerfile and e2e/init/Dockerfile both do
   `pip install -e ".[proxy]"` from source. Same chicken-and-egg
   as test-agno: they can't resolve `headroom-core-py>=0.1.0`
   from PyPI yet.

   Fix: CI's `docker-native-e2e` job now runs the
   build-rust-core-wheel composite action BEFORE the e2e docker
   builds. The action populates `wheelhouse/` in the workspace.
   Both e2e Dockerfiles now `COPY wheelhouse /workspace/wheelhouse`
   and set `PIP_FIND_LINKS=/workspace/wheelhouse`. pip finds the
   local wheel and resolves the constraint.

   `wheelhouse/.gitkeep` + `wheelhouse/README.md` ensure the
   directory exists in clean checkouts so the `COPY` doesn't fail
   when developers `docker build` locally without first running
   the composite action. `.gitignore` excludes `wheelhouse/*.whl`
   so accidentally-committed build artifacts don't get tracked.
…p-installs

Round 3 of #355 fallout: the runtime dep on `headroom-core-py` breaks every
job that does `pip install -e .` (or installs an extras set) before the
build-rust-core-wheel composite action has run. Round 1 covered ci.yml's
test/test-extras/test-agno/docker-native-e2e jobs; round 2 added the
wheelhouse copy to e2e/{wrap,init}/Dockerfile. This round catches the
remaining workflows the prior rounds missed:

- `headroom-e2e-setup` composite action — used by init-native-e2e.yml and
  the broader e2e harness. Embeds the wheel build before its `pip install
  -e ".[proxy]"` step so every consumer inherits the fix.
- `init-e2e.yml` + `wrap-e2e.yml` — standalone docker-build jobs. The
  Dockerfiles already COPY `wheelhouse/` and set PIP_FIND_LINKS, but
  the workflows never populated the directory. Now they do.
- `rust.yml` parity-nightly — `pip install -e .` inside a venv. The venv
  inherits PIP_FIND_LINKS from $GITHUB_ENV.
- `eval.yml` smoke-test + weekly-suite — `pip install -e ".[all]"`.
- `publish.yml` SBOM step — `pip install -e ".[proxy]"`.

ci.yml's docker-native-e2e job already pre-builds the wheelhouse; the
duplicate Dockerfile builds in the e2e/{wrap,init} jobs of ci.yml continue
to work via the same mechanism.

Refs: #355
PR #357 was still failing on docker-init-e2e / docker-wrap-e2e with:

    ERROR: Could not find a version that satisfies the requirement
    headroom-core-py>=0.9.1 (from versions: none)

The wheel WAS in /workspace/wheelhouse — but tagged
`manylinux_2_38_x86_64`. PyO3/maturin-action with `manylinux: auto`
(default) uses host glibc; Ubuntu 24.04 runners ship glibc 2.39, so
maturin tags wheels at the `2_38` floor. The `node:22-bookworm` base
image (Debian 12) has glibc 2.36 — pip rejects the incompatible wheel
silently and reports "from versions: none".

Fix: pin to `manylinux: 2_28` for Linux builds, matching the production
matrix in release.yml. 2.28 is RHEL 8 era, broad enough to cover every
base image the e2e/* Dockerfiles touch (bookworm, jammy, etc.). macOS
and Windows builds are gated separately since the manylinux tag is a
Linux-only concept.

Refs: #355
Round 5 of #355 fallout. With the manylinux pin in 986341a, maturin
runs inside `quay.io/pypa/manylinux_2_28_x86_64` (AlmaLinux 8). That
container ships without OpenSSL dev headers, so the next failure was:

    error: failed to run custom build command for `openssl-sys v0.9.114`
    💥 maturin failed
    Error: The process '/usr/bin/docker' failed with exit code 1

`openssl-sys` arrives via `native-tls`, which is pulled transitively by
`hf-hub` (used by `fastembed`) and `ureq` (build-dep of `ort-sys`). Our
workspace pins `reqwest` to `rustls-tls`, but cargo's feature
unification means a single crate enabling openssl flips it on for
everyone. Severing that chain is a bigger refactor (each crate's
features need auditing); the fast fix is to give the build container
what it needs.

`before-script-linux` runs inside the manylinux container before maturin.
`yum install -y openssl-devel pkgconfig perl-IPC-Cmd` covers headers,
pkg-config detection, and the autotools probe IPC-Cmd dep.

Refs: #355
pull Bot pushed a commit to pythoninthegrass/headroom that referenced this pull request May 3, 2026
Eliminates the dual-package architecture that was the root cause of chopratejas#355.
`pip install headroom-ai` now produces ONE wheel containing both the Python
source (headroom/*.py) and the compiled Rust extension (headroom/_core.so).
No more separate `headroom-core-py` package, no more chicken-and-egg with
PyPI publication, no more wheelhouse / PIP_FIND_LINKS / composite-action
plumbing in CI.

This is the canonical pattern used by cryptography, polars, ruff,
pydantic-core, and other Rust-as-core Python packages. Honors the
"Rust as core engine" direction.

## What changed

- pyproject.toml: `[build-system]` swapped from hatchling to maturin.
  `[tool.hatch.*]` deleted; `[tool.maturin]` added pointing at
  `crates/headroom-py/Cargo.toml` for the cdylib. `python-source = "."`
  picks up the root `headroom/` package directly (dashboard HTML
  templates and other non-Python files included automatically).
- crates/headroom-py/pyproject.toml: deleted. The crate is no longer a
  separate published package; its Cargo.toml stays as the cdylib build
  target invoked via `[tool.maturin] manifest-path`.
- crates/headroom-py/python/: deleted (placeholder layout for the old
  separate package).

## CI updates

- ci.yml: `test` / `test-extras` / `test-agno` jobs simplified — Rust
  toolchain set up before `pip install -e .` (which now invokes maturin
  via build-system). Removed the "build wheel + symlink .so" dance.
  `build` job swapped from `python -m build` (hatch) to
  `maturin build` + `maturin sdist`.
- release.yml: collapsed dual-package matrix into one. New `build-wheels`
  matrix produces cross-platform wheels for cp310/11/12/13 ×
  {linux x86_64, linux aarch64, macos x86_64, macos aarch64}. New
  `collect-dist` aggregator merges artifacts. publish-pypi consumes the
  merged dist.
- init-native-e2e.yml: dropped windows-latest from the matrix —
  upstream `esaxx-rs` (/MT) and `ort-sys` (/MD) link with conflicting
  MSVC C runtime libraries, so the Rust extension cannot build for
  win_amd64 today. Tracked as a follow-up; not a blocker for Linux+macOS.
- headroom-e2e-setup: composite action now sets up Rust toolchain +
  Swatinem/rust-cache before `pip install -e .[proxy]`.
- eval.yml, publish.yml, rust.yml: same pattern — rust toolchain before
  install. rust.yml's wheels job builds from root pyproject.toml (no
  more `-m crates/headroom-py/Cargo.toml`).
- e2e/init/Dockerfile, e2e/wrap/Dockerfile: install rust + maturin in
  the build stage; copy `crates/` + workspace `Cargo.toml/lock` so the
  install can build the extension. Dropped `HEADROOM_REQUIRE_RUST_CORE=false`
  from wrap-e2e — the image now ships the full Rust core.
- Dockerfile (main): simplified — no more Layer 2/3 dance with
  `headroom-core-py` install + symlink. Single `uv pip install` builds
  + installs everything.
- .devcontainer/Dockerfile: rust toolchain + libssl-dev + maturin
  added so `uv sync` builds the extension inside the devcontainer.

## Lockfile + script

- uv.lock: regenerated. No `headroom-core-py` entries remain.
- scripts/build_rust_extension.sh: simplified from a symlink-into-tree
  workaround to a thin wrapper around `pip install -e .`. The maturin
  build-backend handles placement automatically.

## Local validation (all green on macOS aarch64)

1. Clean venv `pip install -e .` → `from headroom._core import …` works.
2. `maturin build --release` → 13.8 MB wheel, 336 files including
   `headroom/_core.cpython-311-darwin.so` (32 MB cdylib) and
   `headroom/dashboard/templates/dashboard.html`.
3. `pip install <wheel>` in fresh venv → import works.
4. Wheel contents verified via `unzip -l`.
5. `pytest tests/test_transforms/test_diff_compressor.py` — 29 passed.
6. `pytest tests/test_relevance.py` — 30 passed.
7. `cargo build --workspace` + `cargo test --workspace` — all green.
8. `make ci-precheck` — 176 Python tests + Rust + commitlint green.

## Migration notes

Users on `pip install headroom-ai` get the Rust core automatically
(linux + macos wheels). sdist installs require rust toolchain available
locally — pip will build via maturin.

Closes chopratejas#355
Supersedes chopratejas#357 (workarounds-based fix abandoned in favor of
architectural fix)
@chopratejas
Copy link
Copy Markdown
Owner Author

Superseded by PR #360 (single-wheel maturin refactor). The original goal of this hotfix — publishing Rust wheels with headroom-ai — is now achieved via maturin's PEP 517 build backend producing a single wheel containing both Python source and the compiled headroom/_core.so. Closing.

@chopratejas chopratejas closed this May 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

v0.20.13: headroom._core Rust extension missing from PyPI wheel — proxy exits with code 78 on start

1 participant