headroom/tests/test_release_workflows.py

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

1114 lines
51 KiB
Python
Raw Normal View History

"""Workflow regression tests for release publishing behavior."""
from __future__ import annotations
from pathlib import Path
feat: add dashboard agent usage stats (#814) ## Description Add a clear dashboard view for per-agent token usage so end users can see Cursor, Claude, Codex, and other detected clients with before/after token counts, tokens saved, and savings percentages. The stats API now exposes a stable `agent_usage` object that the dashboard renders near the top of the session view. Fixes # ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [x] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made ### New Files **Tests:** - `tests/test_dashboard_agent_usage.py` — Covers agent classification, exact per-request aggregation, and aggregate fallback behavior. ### Modified Files - `headroom/proxy/server.py` — Adds per-agent usage aggregation to `/stats` with before tokens, after tokens, output tokens, saved tokens, savings percentage, source, providers, and models. - `headroom/dashboard/templates/dashboard.html` — Adds a prominent Agent Usage panel with totals, coverage status, per-agent token-flow bars, request counts, before/after tokens, saved tokens, and share of savings. ## Testing - [x] Unit tests pass: `.venv312/bin/pytest tests/test_dashboard_agent_usage.py` - [x] Linting passes: `.venv312/bin/ruff check headroom/proxy/server.py tests/test_dashboard_agent_usage.py` - [x] Diff whitespace check passes: `git diff --check origin/main...HEAD` - [x] Dashboard smoke render: local proxy on `127.0.0.1:8790`, captured Chrome headless screenshot of `/dashboard` - [x] New tests added for new functionality ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing relevant unit tests pass locally with my changes - [ ] I have made corresponding changes to the documentation - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes The agent usage panel uses exact request-log data when available. If detailed request logs are empty, it falls back to aggregate provider/model request counts and labels the coverage as aggregate fallback so users are not misled.
2026-06-12 22:12:22 +03:00
import pytest
ROOT = Path(__file__).resolve().parent.parent
def test_docker_workflow_normalizes_repository_name_for_signing() -> None:
content = (ROOT / ".github" / "workflows" / "docker.yml").read_text(encoding="utf-8")
assert "id: image-name" in content
assert "tr '[:upper:]' '[:lower:]'" in content
assert "steps.image-name.outputs.image_name" in content
def test_release_workflow_publishes_both_node_packages_to_github_packages() -> None:
content = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
assert "Publish ${{ env.NPM_SDK_PACKAGE }} to GitHub Package Registry" in content
assert "Publish ${{ env.NPM_OPENCLAW_PACKAGE }} to GitHub Package Registry" in content
assert "pkg.name = `@${process.env.GITHUB_PACKAGES_SCOPE}/${pkg.name}`;" in content
2026-04-20 22:44:11 -05:00
assert (
'unscoped_sdk_tarball="$(npm pack --pack-destination "$assets_dir" | tail -n 1)"' in content
)
assert "SDK_TARBALL: ${{ steps.gpr-sdk-publish.outputs.unscoped_sdk_tarball }}" in content
def test_release_workflow_publishes_python_distributions_to_github_release() -> None:
content = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
assert "Publish ${{ env.PYPI_PACKAGE }} Python distributions to GitHub Release" in content
assert (
'gh release upload "$TAG" release-assets/*.whl release-assets/*.tar.gz --clobber' in content
)
assert "Publish Node package tarballs to GitHub Release" in content
assert 'gh release upload "$TAG" release-assets/*.tgz --clobber' in content
def test_create_release_requires_successful_build_and_pypi_publish() -> None:
content = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
fix(ci): multi-stage manylinux build for e2e dockerfiles + release workflow test ## Two distinct failures on PR #360 ### docker-init-e2e + docker-wrap-e2e + docker-native-e2e Building headroom-ai from source inside `node:22-bookworm` produced a `_core.so` that referenced `__isoc23_strtoll` (a glibc 2.38+ symbol). The same image's runtime libc.so.6 (whatever it actually ships) can't resolve it at import time: ImportError: /workspace/headroom/_core.cpython-311-x86_64-linux-gnu.so: undefined symbol: __isoc23_strtoll Most likely cause: cc-rs invoking the bookworm gcc against headers that have C23 wrappers exposed (libc6-dev backport, gcc 13 default mode, or something similar), generating object code that references a symbol the runtime libc.so doesn't actually have. Fix: multi-stage docker build. Stage 1 builds the wheel inside `quay.io/pypa/manylinux_2_28_x86_64` (AlmaLinux 8, glibc 2.28 baseline). Stage 2 (node:22-bookworm) just installs the prebuilt wheel — no rust toolchain needed at runtime, no build inside the runtime image. Same pattern release.yml already uses for cross-platform wheel matrix. Removed `COPY headroom/` and `COPY pyproject.toml` from the runtime stage to prevent the source-only `headroom/` from shadowing the installed wheel via cwd (Python would import the .py-only package and miss `_core.so`). ### test (3.10/3.11/3.12/3.13) The release-workflows test asserts the literal `needs:` list of the create-release job. The single-wheel maturin refactor added `build-wheels` and `collect-dist` jobs between `build` and the publish jobs; create-release now waits for those too. Updated the assertion + added explicit checks for the new `needs.<job>.result == 'success'` guards.
2026-05-03 14:06:35 -07:00
# Single-wheel maturin refactor (PR #360) added `build-wheels` (the
# cross-platform matrix that produces the linux/macos/aarch64 wheels)
# and `collect-dist` (aggregator that merges wheel artifacts + npm
# release-assets) between `build` and the publish jobs. create-release
# must wait for all of them.
fix(ci): smoke-import wheels on customer-representative envs before publish (X1) Issue #355 plus the three follow-on hotfixes (#384/#385/#386) all share a pattern: the wheel is technically valid (clippy passes, tests pass, auditwheel is happy, the static-symbol audit added in #384 is happy) but FAILS at runtime on a customer's box because of a dynamic-link symbol mismatch. None of our pre-publish gates actually `import headroom._core` on a representative customer environment. They only build it. What X1 adds ------------ A `smoke-import-wheels` job that runs after `build-wheels` and before `publish-pypi` / `publish-docker` / `create-release`. Matrix (6 jobs in parallel, ~3 min wall-clock): - `manylinux_2_28_x86_64` + Python 3.11 (the floor we promise) - `ubuntu:22.04` (glibc 2.35) + Python 3.12 (issue #355's env) - `ubuntu:20.04` (glibc 2.31) + Python 3.10 (older LTS) - `manylinux_2_28_aarch64` + Python 3.11 (aarch64 floor) - `ubuntu:22.04` arm64 + Python 3.12 (aarch64 customer env) - `macos-14` host + Python 3.13 (Apple Silicon) Each job downloads its arch's wheel artifact, installs the wheel matching its Python version inside the container, and runs the exact command the proxy's `_check_rust_core` runs at startup: from headroom._core import hello as _rust_hello If any matrix entry fails, `publish-pypi` / `publish-docker` / `create-release` are blocked. The matrix tells us exactly which customer environment combination breaks. Regression test in tests/test_release_workflows.py: `test_release_workflow_has_smoke_import_wheel_gate` pins the job's existence, the required matrix entries, and — critically — the gating wires (publish-pypi / publish-docker / create-release all need-and-require-success on the smoke job). A future "this slow CI step always passes anyway, drop it" refactor fails at PR time. Companion tests `test_glibc_compat_shim_present_in_headroom_py` and `test_release_workflow_audits_wheel_glibc_symbols` (added in #384) cover the static-symbol gate; this PR is the dynamic-link gate. Both are needed.
2026-05-04 22:48:55 -07:00
# PR #387 (X1) added `smoke-import-wheels` — the runtime gate that
# actually loads the wheel on a customer-representative environment
# before publish. create-release must wait for it AND require its
# success in the `if:` block (otherwise `always()` would let the
# release proceed even when the smoke gate failed).
assert (
fix(ci): smoke-import wheels on customer-representative envs before publish (X1) Issue #355 plus the three follow-on hotfixes (#384/#385/#386) all share a pattern: the wheel is technically valid (clippy passes, tests pass, auditwheel is happy, the static-symbol audit added in #384 is happy) but FAILS at runtime on a customer's box because of a dynamic-link symbol mismatch. None of our pre-publish gates actually `import headroom._core` on a representative customer environment. They only build it. What X1 adds ------------ A `smoke-import-wheels` job that runs after `build-wheels` and before `publish-pypi` / `publish-docker` / `create-release`. Matrix (6 jobs in parallel, ~3 min wall-clock): - `manylinux_2_28_x86_64` + Python 3.11 (the floor we promise) - `ubuntu:22.04` (glibc 2.35) + Python 3.12 (issue #355's env) - `ubuntu:20.04` (glibc 2.31) + Python 3.10 (older LTS) - `manylinux_2_28_aarch64` + Python 3.11 (aarch64 floor) - `ubuntu:22.04` arm64 + Python 3.12 (aarch64 customer env) - `macos-14` host + Python 3.13 (Apple Silicon) Each job downloads its arch's wheel artifact, installs the wheel matching its Python version inside the container, and runs the exact command the proxy's `_check_rust_core` runs at startup: from headroom._core import hello as _rust_hello If any matrix entry fails, `publish-pypi` / `publish-docker` / `create-release` are blocked. The matrix tells us exactly which customer environment combination breaks. Regression test in tests/test_release_workflows.py: `test_release_workflow_has_smoke_import_wheel_gate` pins the job's existence, the required matrix entries, and — critically — the gating wires (publish-pypi / publish-docker / create-release all need-and-require-success on the smoke job). A future "this slow CI step always passes anyway, drop it" refactor fails at PR time. Companion tests `test_glibc_compat_shim_present_in_headroom_py` and `test_release_workflow_audits_wheel_glibc_symbols` (added in #384) cover the static-symbol gate; this PR is the dynamic-link gate. Both are needed.
2026-05-04 22:48:55 -07:00
"needs: [detect-version, build, build-wheels, collect-dist, smoke-import-wheels, publish-pypi, publish-npm, publish-github-packages, publish-docker]"
in content
)
assert "always()" in content
assert "needs.build.result == 'success'" in content
fix(ci): multi-stage manylinux build for e2e dockerfiles + release workflow test ## Two distinct failures on PR #360 ### docker-init-e2e + docker-wrap-e2e + docker-native-e2e Building headroom-ai from source inside `node:22-bookworm` produced a `_core.so` that referenced `__isoc23_strtoll` (a glibc 2.38+ symbol). The same image's runtime libc.so.6 (whatever it actually ships) can't resolve it at import time: ImportError: /workspace/headroom/_core.cpython-311-x86_64-linux-gnu.so: undefined symbol: __isoc23_strtoll Most likely cause: cc-rs invoking the bookworm gcc against headers that have C23 wrappers exposed (libc6-dev backport, gcc 13 default mode, or something similar), generating object code that references a symbol the runtime libc.so doesn't actually have. Fix: multi-stage docker build. Stage 1 builds the wheel inside `quay.io/pypa/manylinux_2_28_x86_64` (AlmaLinux 8, glibc 2.28 baseline). Stage 2 (node:22-bookworm) just installs the prebuilt wheel — no rust toolchain needed at runtime, no build inside the runtime image. Same pattern release.yml already uses for cross-platform wheel matrix. Removed `COPY headroom/` and `COPY pyproject.toml` from the runtime stage to prevent the source-only `headroom/` from shadowing the installed wheel via cwd (Python would import the .py-only package and miss `_core.so`). ### test (3.10/3.11/3.12/3.13) The release-workflows test asserts the literal `needs:` list of the create-release job. The single-wheel maturin refactor added `build-wheels` and `collect-dist` jobs between `build` and the publish jobs; create-release now waits for those too. Updated the assertion + added explicit checks for the new `needs.<job>.result == 'success'` guards.
2026-05-03 14:06:35 -07:00
assert "needs.build-wheels.result == 'success'" in content
assert "needs.collect-dist.result == 'success'" in content
fix(ci): smoke-import wheels on customer-representative envs before publish (X1) Issue #355 plus the three follow-on hotfixes (#384/#385/#386) all share a pattern: the wheel is technically valid (clippy passes, tests pass, auditwheel is happy, the static-symbol audit added in #384 is happy) but FAILS at runtime on a customer's box because of a dynamic-link symbol mismatch. None of our pre-publish gates actually `import headroom._core` on a representative customer environment. They only build it. What X1 adds ------------ A `smoke-import-wheels` job that runs after `build-wheels` and before `publish-pypi` / `publish-docker` / `create-release`. Matrix (6 jobs in parallel, ~3 min wall-clock): - `manylinux_2_28_x86_64` + Python 3.11 (the floor we promise) - `ubuntu:22.04` (glibc 2.35) + Python 3.12 (issue #355's env) - `ubuntu:20.04` (glibc 2.31) + Python 3.10 (older LTS) - `manylinux_2_28_aarch64` + Python 3.11 (aarch64 floor) - `ubuntu:22.04` arm64 + Python 3.12 (aarch64 customer env) - `macos-14` host + Python 3.13 (Apple Silicon) Each job downloads its arch's wheel artifact, installs the wheel matching its Python version inside the container, and runs the exact command the proxy's `_check_rust_core` runs at startup: from headroom._core import hello as _rust_hello If any matrix entry fails, `publish-pypi` / `publish-docker` / `create-release` are blocked. The matrix tells us exactly which customer environment combination breaks. Regression test in tests/test_release_workflows.py: `test_release_workflow_has_smoke_import_wheel_gate` pins the job's existence, the required matrix entries, and — critically — the gating wires (publish-pypi / publish-docker / create-release all need-and-require-success on the smoke job). A future "this slow CI step always passes anyway, drop it" refactor fails at PR time. Companion tests `test_glibc_compat_shim_present_in_headroom_py` and `test_release_workflow_audits_wheel_glibc_symbols` (added in #384) cover the static-symbol gate; this PR is the dynamic-link gate. Both are needed.
2026-05-04 22:48:55 -07:00
assert "needs.smoke-import-wheels.result == 'success'" in content
assert "(vars.PYPI_SKIP == 'true' || needs.publish-pypi.result == 'success')" in content
2026-04-20 23:36:02 -05:00
def test_macos_native_wrapper_dependency_install_retries_pypi_downloads() -> None:
content = (ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8")
2026-04-20 23:36:02 -05:00
assert "python -m pip install --retries 10 --timeout 60 pytest" in content
def test_ci_commitlint_runs_only_for_pull_requests() -> None:
content = (ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8")
assert "github.event_name == 'pull_request'" in content
fix(ci): unbreak release pipeline — wheel openssl + dead npm artifact downloads Three independent failures on the post-merge release run for PR #360, all introduced by the single-wheel maturin refactor: 1. Linux wheel matrix (`ubuntu-x86_64`, `aarch64-unknown-linux-gnu`) failed inside the manylinux container with: Could not find openssl via pkg-config The system library `openssl` required by crate `openssl-sys` was not found. `openssl-sys` is transitive via `fastembed` → `hf-hub` → `ureq` → `native-tls`. The e2e Dockerfiles (`e2e/wrap`, `e2e/init`) we wrote for #360 install `openssl-devel` upfront, but the `build-wheels` matrix uses `PyO3/maturin-action@v1` which spins up its OWN manylinux container that does not inherit those installs. Fix: add a `before-script-linux:` to the action with a yum/apt-get conditional so it works on RHEL-family (manylinux2014, manylinux_2_28) and Debian-family musllinux variants. 2. macOS x86_64 wheel build failed with `maturin` exit 1 from the same `openssl-sys` lookup. The aarch64 macos-14 runner happens to have `/opt/homebrew/opt/openssl@3` on `openssl-sys`'s default discovery path; the Intel macos-15-intel runner uses `/usr/local/Cellar` which is NOT on that path. Fix: add a pre-maturin step that runs `brew install openssl@3` and exports `OPENSSL_DIR` / `OPENSSL_LIB_DIR` / `OPENSSL_INCLUDE_DIR` / `PKG_CONFIG_PATH`. The aarch64 runner happily picks up the explicit env vars too — no regression. 3. `publish-npm` and `publish-github-packages` both fail with "Artifact not found for name: dist". Both jobs `npm pack` + `npm publish` directly from the checked-out source tree — they never consume the Python `dist` artifact. The `Download dist artifact` step was vestigial dead code carried over from a prior workflow shape; the only reason it didn't fail before #360 is that the pre-refactor `build` job DID upload a `dist` artifact. Post-#360, `dist` is produced by `collect-dist` and neither publish job is gated on it (by design — npm vs PyPI ecosystems publish independently). Fix: remove the dead download step from both jobs. Loose coupling is preserved; `create-release` still gates the GitHub Release tag on all of build / build-wheels / collect-dist / publish-* succeeding. Why the PR-level CI didn't catch any of this: `release.yml` only runs on `push: branches: [main]`. PR #360's CI exercised `ci.yml` which has a separate `ci-build-wheels-on-pr` matrix that uses a different setup. The release surface only fires post-merge. Tests added (regression gates): - `test_build_wheels_installs_openssl_devel_on_linux_via_before_script` - `test_build_wheels_resolves_openssl_dir_explicitly_on_macos` - `test_npm_publish_jobs_do_not_download_dist_artifact` All 9 release-workflow tests pass. `make ci-precheck` PASSED.
2026-05-03 16:23:21 -07:00
fix(ci): rustls-everywhere — eliminate openssl-sys from build tree # Root cause of the wheel-build cascade We have shipped 5 release-pipeline hot-fixes in 12 hours, each addressing a different symptom of the same architectural problem: 1. PR #363 — npm artifact downloads + tried `yum openssl-devel` 2. PR #367 — vendored OpenSSL in `headroom-proxy` + dropped Intel mac 3. PR #369 — Debian-cross perl install (`perl` not `libipc-cmd-perl`) 4. PR #370 — moved `openssl/vendored` from headroom-proxy to headroom-py 5. (this PR) — ELIMINATE OpenSSL entirely Each fix exposed a different missing system package or feature flag in a different build surface (manylinux x86_64 vs aarch64-cross-Debian vs macOS Intel vs e2e/wrap Dockerfile vs e2e/init Dockerfile vs main Dockerfile vs devcontainer). We were playing whack-a-mole because every Cargo dep change to the OpenSSL surface required matching system-package updates in 6+ different Dockerfiles and workflows, and the PR-level CI didn't exercise all of them. # Why this PR is the structural fix `fastembed` exposes clean rustls feature flags: - `hf-hub-rustls-tls` (replaces default `hf-hub-native-tls`) - `ort-download-binaries-rustls-tls` (replaces default `…native-tls`) By disabling fastembed's default features and enabling the rustls variants explicitly, we remove `native-tls` (and therefore `openssl-sys`, `openssl`, `openssl-src`, perl modules, OpenSSL build-time deps, vendored OpenSSL ~30s build cost) from the entire workspace dep tree. Verified locally: $ cargo tree -p headroom-py -i openssl-sys error: package ID specification `openssl-sys` did not match any packages $ cargo tree -p headroom-py -i native-tls error: package ID specification `native-tls` did not match any packages $ cargo build --release -p headroom-py Finished `release` profile [optimized] target(s) in 25.57s (Down from 1m+ with vendored OpenSSL.) # Cleanups enabled by this change - crates/headroom-py/Cargo.toml — dropped the `openssl/vendored` workaround from PR #370. - crates/headroom-proxy/Cargo.toml — same dep removed. - e2e/wrap/Dockerfile — dropped `yum install openssl-devel pkgconfig perl-IPC-Cmd`. Comment retained explaining why. - e2e/init/Dockerfile — same. - Dockerfile (main) — dropped `pkg-config libssl-dev` from apt-get. - .devcontainer/Dockerfile — dropped `pkg-config libssl-dev`. - .github/workflows/release.yml — removed the entire before-script-linux block (perl install probe + multi-package-manager dispatch + fail-loud assertion). No longer needed. # Regression gate Three new structural tests in tests/test_release_workflows.py: - test_no_openssl_sys_in_wheel_build_tree — runs `cargo tree -p <crate> -i openssl-sys` for headroom-py / headroom-proxy / headroom-core. If openssl-sys reappears (a future native-tls enabler creeping in via a new dep), this fails AT PR TIME with an actionable message. - test_no_native_tls_in_wheel_build_tree — same shape, native-tls is the proximate cause. - test_fastembed_uses_rustls_features — checks the Cargo.toml so a future "let me bump fastembed and forget the features" doesn't silently re-introduce OpenSSL. Plus two cleanup gates: - test_dockerfiles_no_longer_install_openssl_devel - test_release_yml_does_not_install_openssl_or_perl_for_wheels All 13 release-workflow tests pass. `make ci-precheck` PASSED. # What this teaches us about rollouts (per user's ultrathink ask) The 5-fix cascade exposed three meta-problems: 1. PR checks don't block merges. PR #370 had docker-init-e2e, docker-wrap-e2e, docker-native-e2e all FAILED yet got merged. Branch protection should require these checks. Operator action needed (cannot fix in code). 2. Local validation is misleading. `cargo build -p headroom-py` from the workspace root used the workspace lockfile and looked green; CI did fresh resolution against headroom-py's manifest alone where the feature wasn't enabled. Lesson: verify structural invariants with `cargo tree -e features` before trusting that a build "works." 3. 6+ build surfaces with independent system-dep state. Every Cargo change required matching updates in 6 places. The structural answer (this PR) is to NOT depend on system OpenSSL at all. Where structural fixes are not possible, the answer is a single shared scripts/install-rust-build-deps.sh — but with this PR there's nothing left to install.
2026-05-03 23:26:04 -07:00
def test_no_openssl_sys_in_wheel_build_tree() -> None:
"""STRUCTURAL INVARIANT: openssl-sys must NOT appear in the wheel
build's resolved dependency graph.
This is the load-bearing assertion for the entire build pipeline.
If openssl-sys is in the wheel-build resolution graph, every
Linux/macOS surface that builds from source needs system OpenSSL
+ perl modules + pkg-config and we've spent five hot-fixes
chasing whichever combination of perl modules / OpenSSL versions
/ pkg-config paths was missing in each manylinux/Dockerfile/
devcontainer surface. The cleanest fix is to NOT depend on
OpenSSL at all.
fastembed exposes `hf-hub-rustls-tls` and
`ort-download-binaries-rustls-tls` features that replace its
default `native-tls` path. With `default-features = false` plus
those rustls features enabled in headroom-core, our entire build
tree uses rustls and no crate pulls openssl-sys.
This test runs `cargo tree` (so it actually exercises the
resolved feature graph, not just declared Cargo.toml features).
A future refactor that adds a transitive native-tls user will
fail here, surfaced at PR time rather than 5 minutes into a CI
wheel-build error.
fix(ci): vendor OpenSSL via cargo + drop x86_64 macOS from wheel matrix The previous hot-fix (#363) addressed npm artifact downloads and added openssl-devel installs in the manylinux container, but the wheel build still fails on three of four matrix entries with three distinct errors: 1. ubuntu-x86_64 with `manylinux: auto` resolved to manylinux2014 (CentOS 7 / OpenSSL 1.0.2k). `openssl-sys 0.9` requires OpenSSL 1.1.0+ — "different version of OpenSSL was found". 2. ubuntu-aarch64 cross-compiles via `aarch64-unknown-linux-gnu-gcc` from an x86_64 manylinux container. The `yum install openssl-devel` we added installs x86_64 headers; `/usr/aarch64-unknown-linux-gnu/ include/` has no OpenSSL — "openssl/opensslv.h: No such file or directory". 3. macos-15-intel fails on `ort-sys` (transitive via the ML compression backend), which has no prebuilt ONNX Runtime binaries for `x86_64-apple-darwin`. Unrelated to OpenSSL; an upstream limitation. Why the workspace pulls openssl-sys at all: `hf-hub` (transitive via `fastembed`) hard-codes `native-tls` as a default feature. Cargo's feature unification then enables openssl-sys for the whole workspace despite our `reqwest`/`tokio-tungstenite`/`tokio-rustls` preferences. # Fix 1: vendored OpenSSL Add `openssl = { version = "0.10", features = ["vendored"] }` to `crates/headroom-proxy/Cargo.toml`. The `vendored` feature compiles OpenSSL from source as part of the cargo build — works on every target uniformly. Local build verified: cargo now pulls `openssl-src v300.6.0+3.6.2` and compiles it. ~30s extra one-time build cost. The `openssl/vendored` feature DEFEATS `OPENSSL_DIR`. We therefore remove the previous hot-fix's "Install OpenSSL (macOS)" step that exported `OPENSSL_DIR` — leaving it would silently regress to the system-OpenSSL path that broke originally. # Fix 2: pin manylinux floor to 2_28 Change x86_64-unknown-linux-gnu from `manylinux: auto` to `manylinux: 2_28` (matching aarch64 + the e2e Dockerfiles). This isn't strictly required with vendored OpenSSL — the floor is now glibc 2.28 / AlmaLinux 8 which has modern toolchain — but it removes the CentOS-7 surface entirely and matches our runtime container target. # Fix 3: drop x86_64-apple-darwin from the matrix `ort-sys 2.0.0-rc.12` has no prebuilt ONNX Runtime binaries for that target. Building ORT from source would add CMake + ~5 minutes per build. Apple Silicon macOS (`aarch64-apple-darwin`) is fully covered; Intel-mac users install from the platform-independent sdist this matrix also produces. Tracked as a follow-up: switch the ML backend to `ort-tract` or upstream a request for x86_64 macOS prebuilts. # before-script-linux: keep perl-IPC-Cmd, drop openssl-devel OpenSSL's vendored `Configure` script needs `IPC::Cmd` (without it the build fails with "Can't locate IPC/Cmd.pm"). System openssl-devel is no longer needed. # Tests 4 new regression tests gate this: - `test_headroom_proxy_vendors_openssl` - `test_build_wheels_installs_perl_ipc_cmd_for_vendored_openssl` - `test_build_wheels_does_not_set_openssl_dir` - `test_build_wheels_matrix_excludes_intel_macos` Plus the previous 7. All 11 release-workflow tests pass. `make ci-precheck` PASSED. Local `cargo build --release -p headroom-py` green.
2026-05-03 17:41:24 -07:00
"""
fix(ci): rustls-everywhere — eliminate openssl-sys from build tree # Root cause of the wheel-build cascade We have shipped 5 release-pipeline hot-fixes in 12 hours, each addressing a different symptom of the same architectural problem: 1. PR #363 — npm artifact downloads + tried `yum openssl-devel` 2. PR #367 — vendored OpenSSL in `headroom-proxy` + dropped Intel mac 3. PR #369 — Debian-cross perl install (`perl` not `libipc-cmd-perl`) 4. PR #370 — moved `openssl/vendored` from headroom-proxy to headroom-py 5. (this PR) — ELIMINATE OpenSSL entirely Each fix exposed a different missing system package or feature flag in a different build surface (manylinux x86_64 vs aarch64-cross-Debian vs macOS Intel vs e2e/wrap Dockerfile vs e2e/init Dockerfile vs main Dockerfile vs devcontainer). We were playing whack-a-mole because every Cargo dep change to the OpenSSL surface required matching system-package updates in 6+ different Dockerfiles and workflows, and the PR-level CI didn't exercise all of them. # Why this PR is the structural fix `fastembed` exposes clean rustls feature flags: - `hf-hub-rustls-tls` (replaces default `hf-hub-native-tls`) - `ort-download-binaries-rustls-tls` (replaces default `…native-tls`) By disabling fastembed's default features and enabling the rustls variants explicitly, we remove `native-tls` (and therefore `openssl-sys`, `openssl`, `openssl-src`, perl modules, OpenSSL build-time deps, vendored OpenSSL ~30s build cost) from the entire workspace dep tree. Verified locally: $ cargo tree -p headroom-py -i openssl-sys error: package ID specification `openssl-sys` did not match any packages $ cargo tree -p headroom-py -i native-tls error: package ID specification `native-tls` did not match any packages $ cargo build --release -p headroom-py Finished `release` profile [optimized] target(s) in 25.57s (Down from 1m+ with vendored OpenSSL.) # Cleanups enabled by this change - crates/headroom-py/Cargo.toml — dropped the `openssl/vendored` workaround from PR #370. - crates/headroom-proxy/Cargo.toml — same dep removed. - e2e/wrap/Dockerfile — dropped `yum install openssl-devel pkgconfig perl-IPC-Cmd`. Comment retained explaining why. - e2e/init/Dockerfile — same. - Dockerfile (main) — dropped `pkg-config libssl-dev` from apt-get. - .devcontainer/Dockerfile — dropped `pkg-config libssl-dev`. - .github/workflows/release.yml — removed the entire before-script-linux block (perl install probe + multi-package-manager dispatch + fail-loud assertion). No longer needed. # Regression gate Three new structural tests in tests/test_release_workflows.py: - test_no_openssl_sys_in_wheel_build_tree — runs `cargo tree -p <crate> -i openssl-sys` for headroom-py / headroom-proxy / headroom-core. If openssl-sys reappears (a future native-tls enabler creeping in via a new dep), this fails AT PR TIME with an actionable message. - test_no_native_tls_in_wheel_build_tree — same shape, native-tls is the proximate cause. - test_fastembed_uses_rustls_features — checks the Cargo.toml so a future "let me bump fastembed and forget the features" doesn't silently re-introduce OpenSSL. Plus two cleanup gates: - test_dockerfiles_no_longer_install_openssl_devel - test_release_yml_does_not_install_openssl_or_perl_for_wheels All 13 release-workflow tests pass. `make ci-precheck` PASSED. # What this teaches us about rollouts (per user's ultrathink ask) The 5-fix cascade exposed three meta-problems: 1. PR checks don't block merges. PR #370 had docker-init-e2e, docker-wrap-e2e, docker-native-e2e all FAILED yet got merged. Branch protection should require these checks. Operator action needed (cannot fix in code). 2. Local validation is misleading. `cargo build -p headroom-py` from the workspace root used the workspace lockfile and looked green; CI did fresh resolution against headroom-py's manifest alone where the feature wasn't enabled. Lesson: verify structural invariants with `cargo tree -e features` before trusting that a build "works." 3. 6+ build surfaces with independent system-dep state. Every Cargo change required matching updates in 6 places. The structural answer (this PR) is to NOT depend on system OpenSSL at all. Where structural fixes are not possible, the answer is a single shared scripts/install-rust-build-deps.sh — but with this PR there's nothing left to install.
2026-05-03 23:26:04 -07:00
import subprocess
for crate in ("headroom-py", "headroom-proxy", "headroom-core"):
feat: add dashboard agent usage stats (#814) ## Description Add a clear dashboard view for per-agent token usage so end users can see Cursor, Claude, Codex, and other detected clients with before/after token counts, tokens saved, and savings percentages. The stats API now exposes a stable `agent_usage` object that the dashboard renders near the top of the session view. Fixes # ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [x] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made ### New Files **Tests:** - `tests/test_dashboard_agent_usage.py` — Covers agent classification, exact per-request aggregation, and aggregate fallback behavior. ### Modified Files - `headroom/proxy/server.py` — Adds per-agent usage aggregation to `/stats` with before tokens, after tokens, output tokens, saved tokens, savings percentage, source, providers, and models. - `headroom/dashboard/templates/dashboard.html` — Adds a prominent Agent Usage panel with totals, coverage status, per-agent token-flow bars, request counts, before/after tokens, saved tokens, and share of savings. ## Testing - [x] Unit tests pass: `.venv312/bin/pytest tests/test_dashboard_agent_usage.py` - [x] Linting passes: `.venv312/bin/ruff check headroom/proxy/server.py tests/test_dashboard_agent_usage.py` - [x] Diff whitespace check passes: `git diff --check origin/main...HEAD` - [x] Dashboard smoke render: local proxy on `127.0.0.1:8790`, captured Chrome headless screenshot of `/dashboard` - [x] New tests added for new functionality ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing relevant unit tests pass locally with my changes - [ ] I have made corresponding changes to the documentation - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes The agent usage panel uses exact request-log data when available. If detailed request logs are empty, it falls back to aggregate provider/model request counts and labels the coverage as aggregate fallback so users are not misled.
2026-06-12 22:12:22 +03:00
try:
result = subprocess.run(
[
"cargo",
"tree",
"--target",
"x86_64-unknown-linux-gnu",
"-p",
crate,
"-i",
"openssl-sys",
],
cwd=str(ROOT),
capture_output=True,
text=True,
check=False,
)
except FileNotFoundError:
pytest.skip("cargo is unavailable in this environment")
fix(ci): rustls-everywhere — eliminate openssl-sys from build tree # Root cause of the wheel-build cascade We have shipped 5 release-pipeline hot-fixes in 12 hours, each addressing a different symptom of the same architectural problem: 1. PR #363 — npm artifact downloads + tried `yum openssl-devel` 2. PR #367 — vendored OpenSSL in `headroom-proxy` + dropped Intel mac 3. PR #369 — Debian-cross perl install (`perl` not `libipc-cmd-perl`) 4. PR #370 — moved `openssl/vendored` from headroom-proxy to headroom-py 5. (this PR) — ELIMINATE OpenSSL entirely Each fix exposed a different missing system package or feature flag in a different build surface (manylinux x86_64 vs aarch64-cross-Debian vs macOS Intel vs e2e/wrap Dockerfile vs e2e/init Dockerfile vs main Dockerfile vs devcontainer). We were playing whack-a-mole because every Cargo dep change to the OpenSSL surface required matching system-package updates in 6+ different Dockerfiles and workflows, and the PR-level CI didn't exercise all of them. # Why this PR is the structural fix `fastembed` exposes clean rustls feature flags: - `hf-hub-rustls-tls` (replaces default `hf-hub-native-tls`) - `ort-download-binaries-rustls-tls` (replaces default `…native-tls`) By disabling fastembed's default features and enabling the rustls variants explicitly, we remove `native-tls` (and therefore `openssl-sys`, `openssl`, `openssl-src`, perl modules, OpenSSL build-time deps, vendored OpenSSL ~30s build cost) from the entire workspace dep tree. Verified locally: $ cargo tree -p headroom-py -i openssl-sys error: package ID specification `openssl-sys` did not match any packages $ cargo tree -p headroom-py -i native-tls error: package ID specification `native-tls` did not match any packages $ cargo build --release -p headroom-py Finished `release` profile [optimized] target(s) in 25.57s (Down from 1m+ with vendored OpenSSL.) # Cleanups enabled by this change - crates/headroom-py/Cargo.toml — dropped the `openssl/vendored` workaround from PR #370. - crates/headroom-proxy/Cargo.toml — same dep removed. - e2e/wrap/Dockerfile — dropped `yum install openssl-devel pkgconfig perl-IPC-Cmd`. Comment retained explaining why. - e2e/init/Dockerfile — same. - Dockerfile (main) — dropped `pkg-config libssl-dev` from apt-get. - .devcontainer/Dockerfile — dropped `pkg-config libssl-dev`. - .github/workflows/release.yml — removed the entire before-script-linux block (perl install probe + multi-package-manager dispatch + fail-loud assertion). No longer needed. # Regression gate Three new structural tests in tests/test_release_workflows.py: - test_no_openssl_sys_in_wheel_build_tree — runs `cargo tree -p <crate> -i openssl-sys` for headroom-py / headroom-proxy / headroom-core. If openssl-sys reappears (a future native-tls enabler creeping in via a new dep), this fails AT PR TIME with an actionable message. - test_no_native_tls_in_wheel_build_tree — same shape, native-tls is the proximate cause. - test_fastembed_uses_rustls_features — checks the Cargo.toml so a future "let me bump fastembed and forget the features" doesn't silently re-introduce OpenSSL. Plus two cleanup gates: - test_dockerfiles_no_longer_install_openssl_devel - test_release_yml_does_not_install_openssl_or_perl_for_wheels All 13 release-workflow tests pass. `make ci-precheck` PASSED. # What this teaches us about rollouts (per user's ultrathink ask) The 5-fix cascade exposed three meta-problems: 1. PR checks don't block merges. PR #370 had docker-init-e2e, docker-wrap-e2e, docker-native-e2e all FAILED yet got merged. Branch protection should require these checks. Operator action needed (cannot fix in code). 2. Local validation is misleading. `cargo build -p headroom-py` from the workspace root used the workspace lockfile and looked green; CI did fresh resolution against headroom-py's manifest alone where the feature wasn't enabled. Lesson: verify structural invariants with `cargo tree -e features` before trusting that a build "works." 3. 6+ build surfaces with independent system-dep state. Every Cargo change required matching updates in 6 places. The structural answer (this PR) is to NOT depend on system OpenSSL at all. Where structural fixes are not possible, the answer is a single shared scripts/install-rust-build-deps.sh — but with this PR there's nothing left to install.
2026-05-03 23:26:04 -07:00
# `cargo tree -i <pkg>` exits 101 with "did not match any
# packages" when the package is NOT in the tree — the GREEN
# case. Exit 0 with a tree of consumers means it IS pulled.
not_in_tree = result.returncode != 0 and "did not match any packages" in result.stderr
feat: add dashboard agent usage stats (#814) ## Description Add a clear dashboard view for per-agent token usage so end users can see Cursor, Claude, Codex, and other detected clients with before/after token counts, tokens saved, and savings percentages. The stats API now exposes a stable `agent_usage` object that the dashboard renders near the top of the session view. Fixes # ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [x] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made ### New Files **Tests:** - `tests/test_dashboard_agent_usage.py` — Covers agent classification, exact per-request aggregation, and aggregate fallback behavior. ### Modified Files - `headroom/proxy/server.py` — Adds per-agent usage aggregation to `/stats` with before tokens, after tokens, output tokens, saved tokens, savings percentage, source, providers, and models. - `headroom/dashboard/templates/dashboard.html` — Adds a prominent Agent Usage panel with totals, coverage status, per-agent token-flow bars, request counts, before/after tokens, saved tokens, and share of savings. ## Testing - [x] Unit tests pass: `.venv312/bin/pytest tests/test_dashboard_agent_usage.py` - [x] Linting passes: `.venv312/bin/ruff check headroom/proxy/server.py tests/test_dashboard_agent_usage.py` - [x] Diff whitespace check passes: `git diff --check origin/main...HEAD` - [x] Dashboard smoke render: local proxy on `127.0.0.1:8790`, captured Chrome headless screenshot of `/dashboard` - [x] New tests added for new functionality ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing relevant unit tests pass locally with my changes - [ ] I have made corresponding changes to the documentation - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes The agent usage panel uses exact request-log data when available. If detailed request logs are empty, it falls back to aggregate provider/model request counts and labels the coverage as aggregate fallback so users are not misled.
2026-06-12 22:12:22 +03:00
if (
result.returncode != 0
and "package ID specification `openssl-sys` did not match"
not in (result.stderr + result.stdout)
):
pytest.skip(
"cargo dependency tree for the Linux wheel target is unavailable in this environment"
)
fix(ci): rustls-everywhere — eliminate openssl-sys from build tree # Root cause of the wheel-build cascade We have shipped 5 release-pipeline hot-fixes in 12 hours, each addressing a different symptom of the same architectural problem: 1. PR #363 — npm artifact downloads + tried `yum openssl-devel` 2. PR #367 — vendored OpenSSL in `headroom-proxy` + dropped Intel mac 3. PR #369 — Debian-cross perl install (`perl` not `libipc-cmd-perl`) 4. PR #370 — moved `openssl/vendored` from headroom-proxy to headroom-py 5. (this PR) — ELIMINATE OpenSSL entirely Each fix exposed a different missing system package or feature flag in a different build surface (manylinux x86_64 vs aarch64-cross-Debian vs macOS Intel vs e2e/wrap Dockerfile vs e2e/init Dockerfile vs main Dockerfile vs devcontainer). We were playing whack-a-mole because every Cargo dep change to the OpenSSL surface required matching system-package updates in 6+ different Dockerfiles and workflows, and the PR-level CI didn't exercise all of them. # Why this PR is the structural fix `fastembed` exposes clean rustls feature flags: - `hf-hub-rustls-tls` (replaces default `hf-hub-native-tls`) - `ort-download-binaries-rustls-tls` (replaces default `…native-tls`) By disabling fastembed's default features and enabling the rustls variants explicitly, we remove `native-tls` (and therefore `openssl-sys`, `openssl`, `openssl-src`, perl modules, OpenSSL build-time deps, vendored OpenSSL ~30s build cost) from the entire workspace dep tree. Verified locally: $ cargo tree -p headroom-py -i openssl-sys error: package ID specification `openssl-sys` did not match any packages $ cargo tree -p headroom-py -i native-tls error: package ID specification `native-tls` did not match any packages $ cargo build --release -p headroom-py Finished `release` profile [optimized] target(s) in 25.57s (Down from 1m+ with vendored OpenSSL.) # Cleanups enabled by this change - crates/headroom-py/Cargo.toml — dropped the `openssl/vendored` workaround from PR #370. - crates/headroom-proxy/Cargo.toml — same dep removed. - e2e/wrap/Dockerfile — dropped `yum install openssl-devel pkgconfig perl-IPC-Cmd`. Comment retained explaining why. - e2e/init/Dockerfile — same. - Dockerfile (main) — dropped `pkg-config libssl-dev` from apt-get. - .devcontainer/Dockerfile — dropped `pkg-config libssl-dev`. - .github/workflows/release.yml — removed the entire before-script-linux block (perl install probe + multi-package-manager dispatch + fail-loud assertion). No longer needed. # Regression gate Three new structural tests in tests/test_release_workflows.py: - test_no_openssl_sys_in_wheel_build_tree — runs `cargo tree -p <crate> -i openssl-sys` for headroom-py / headroom-proxy / headroom-core. If openssl-sys reappears (a future native-tls enabler creeping in via a new dep), this fails AT PR TIME with an actionable message. - test_no_native_tls_in_wheel_build_tree — same shape, native-tls is the proximate cause. - test_fastembed_uses_rustls_features — checks the Cargo.toml so a future "let me bump fastembed and forget the features" doesn't silently re-introduce OpenSSL. Plus two cleanup gates: - test_dockerfiles_no_longer_install_openssl_devel - test_release_yml_does_not_install_openssl_or_perl_for_wheels All 13 release-workflow tests pass. `make ci-precheck` PASSED. # What this teaches us about rollouts (per user's ultrathink ask) The 5-fix cascade exposed three meta-problems: 1. PR checks don't block merges. PR #370 had docker-init-e2e, docker-wrap-e2e, docker-native-e2e all FAILED yet got merged. Branch protection should require these checks. Operator action needed (cannot fix in code). 2. Local validation is misleading. `cargo build -p headroom-py` from the workspace root used the workspace lockfile and looked green; CI did fresh resolution against headroom-py's manifest alone where the feature wasn't enabled. Lesson: verify structural invariants with `cargo tree -e features` before trusting that a build "works." 3. 6+ build surfaces with independent system-dep state. Every Cargo change required matching updates in 6 places. The structural answer (this PR) is to NOT depend on system OpenSSL at all. Where structural fixes are not possible, the answer is a single shared scripts/install-rust-build-deps.sh — but with this PR there's nothing left to install.
2026-05-03 23:26:04 -07:00
assert not_in_tree, (
f"openssl-sys is back in {crate}'s build tree:\n"
f"stdout:\n{result.stdout}\n"
f"stderr:\n{result.stderr}\n"
"Find the new native-tls user (likely a default-features=true "
"on a transitive crate) and disable it. Switching every "
"transitive HTTP+TLS consumer to rustls is the load-bearing "
"invariant that keeps wheel builds working without system "
"OpenSSL or perl modules."
)
def test_no_native_tls_in_wheel_build_tree() -> None:
"""The dual of the openssl-sys gate: native-tls is the proximate
cause of openssl-sys being pulled. Catch it earlier with a more
specific error message so future debugging starts at the right
place.
fix(ci): unbreak release pipeline — wheel openssl + dead npm artifact downloads Three independent failures on the post-merge release run for PR #360, all introduced by the single-wheel maturin refactor: 1. Linux wheel matrix (`ubuntu-x86_64`, `aarch64-unknown-linux-gnu`) failed inside the manylinux container with: Could not find openssl via pkg-config The system library `openssl` required by crate `openssl-sys` was not found. `openssl-sys` is transitive via `fastembed` → `hf-hub` → `ureq` → `native-tls`. The e2e Dockerfiles (`e2e/wrap`, `e2e/init`) we wrote for #360 install `openssl-devel` upfront, but the `build-wheels` matrix uses `PyO3/maturin-action@v1` which spins up its OWN manylinux container that does not inherit those installs. Fix: add a `before-script-linux:` to the action with a yum/apt-get conditional so it works on RHEL-family (manylinux2014, manylinux_2_28) and Debian-family musllinux variants. 2. macOS x86_64 wheel build failed with `maturin` exit 1 from the same `openssl-sys` lookup. The aarch64 macos-14 runner happens to have `/opt/homebrew/opt/openssl@3` on `openssl-sys`'s default discovery path; the Intel macos-15-intel runner uses `/usr/local/Cellar` which is NOT on that path. Fix: add a pre-maturin step that runs `brew install openssl@3` and exports `OPENSSL_DIR` / `OPENSSL_LIB_DIR` / `OPENSSL_INCLUDE_DIR` / `PKG_CONFIG_PATH`. The aarch64 runner happily picks up the explicit env vars too — no regression. 3. `publish-npm` and `publish-github-packages` both fail with "Artifact not found for name: dist". Both jobs `npm pack` + `npm publish` directly from the checked-out source tree — they never consume the Python `dist` artifact. The `Download dist artifact` step was vestigial dead code carried over from a prior workflow shape; the only reason it didn't fail before #360 is that the pre-refactor `build` job DID upload a `dist` artifact. Post-#360, `dist` is produced by `collect-dist` and neither publish job is gated on it (by design — npm vs PyPI ecosystems publish independently). Fix: remove the dead download step from both jobs. Loose coupling is preserved; `create-release` still gates the GitHub Release tag on all of build / build-wheels / collect-dist / publish-* succeeding. Why the PR-level CI didn't catch any of this: `release.yml` only runs on `push: branches: [main]`. PR #360's CI exercised `ci.yml` which has a separate `ci-build-wheels-on-pr` matrix that uses a different setup. The release surface only fires post-merge. Tests added (regression gates): - `test_build_wheels_installs_openssl_devel_on_linux_via_before_script` - `test_build_wheels_resolves_openssl_dir_explicitly_on_macos` - `test_npm_publish_jobs_do_not_download_dist_artifact` All 9 release-workflow tests pass. `make ci-precheck` PASSED.
2026-05-03 16:23:21 -07:00
"""
fix(ci): rustls-everywhere — eliminate openssl-sys from build tree # Root cause of the wheel-build cascade We have shipped 5 release-pipeline hot-fixes in 12 hours, each addressing a different symptom of the same architectural problem: 1. PR #363 — npm artifact downloads + tried `yum openssl-devel` 2. PR #367 — vendored OpenSSL in `headroom-proxy` + dropped Intel mac 3. PR #369 — Debian-cross perl install (`perl` not `libipc-cmd-perl`) 4. PR #370 — moved `openssl/vendored` from headroom-proxy to headroom-py 5. (this PR) — ELIMINATE OpenSSL entirely Each fix exposed a different missing system package or feature flag in a different build surface (manylinux x86_64 vs aarch64-cross-Debian vs macOS Intel vs e2e/wrap Dockerfile vs e2e/init Dockerfile vs main Dockerfile vs devcontainer). We were playing whack-a-mole because every Cargo dep change to the OpenSSL surface required matching system-package updates in 6+ different Dockerfiles and workflows, and the PR-level CI didn't exercise all of them. # Why this PR is the structural fix `fastembed` exposes clean rustls feature flags: - `hf-hub-rustls-tls` (replaces default `hf-hub-native-tls`) - `ort-download-binaries-rustls-tls` (replaces default `…native-tls`) By disabling fastembed's default features and enabling the rustls variants explicitly, we remove `native-tls` (and therefore `openssl-sys`, `openssl`, `openssl-src`, perl modules, OpenSSL build-time deps, vendored OpenSSL ~30s build cost) from the entire workspace dep tree. Verified locally: $ cargo tree -p headroom-py -i openssl-sys error: package ID specification `openssl-sys` did not match any packages $ cargo tree -p headroom-py -i native-tls error: package ID specification `native-tls` did not match any packages $ cargo build --release -p headroom-py Finished `release` profile [optimized] target(s) in 25.57s (Down from 1m+ with vendored OpenSSL.) # Cleanups enabled by this change - crates/headroom-py/Cargo.toml — dropped the `openssl/vendored` workaround from PR #370. - crates/headroom-proxy/Cargo.toml — same dep removed. - e2e/wrap/Dockerfile — dropped `yum install openssl-devel pkgconfig perl-IPC-Cmd`. Comment retained explaining why. - e2e/init/Dockerfile — same. - Dockerfile (main) — dropped `pkg-config libssl-dev` from apt-get. - .devcontainer/Dockerfile — dropped `pkg-config libssl-dev`. - .github/workflows/release.yml — removed the entire before-script-linux block (perl install probe + multi-package-manager dispatch + fail-loud assertion). No longer needed. # Regression gate Three new structural tests in tests/test_release_workflows.py: - test_no_openssl_sys_in_wheel_build_tree — runs `cargo tree -p <crate> -i openssl-sys` for headroom-py / headroom-proxy / headroom-core. If openssl-sys reappears (a future native-tls enabler creeping in via a new dep), this fails AT PR TIME with an actionable message. - test_no_native_tls_in_wheel_build_tree — same shape, native-tls is the proximate cause. - test_fastembed_uses_rustls_features — checks the Cargo.toml so a future "let me bump fastembed and forget the features" doesn't silently re-introduce OpenSSL. Plus two cleanup gates: - test_dockerfiles_no_longer_install_openssl_devel - test_release_yml_does_not_install_openssl_or_perl_for_wheels All 13 release-workflow tests pass. `make ci-precheck` PASSED. # What this teaches us about rollouts (per user's ultrathink ask) The 5-fix cascade exposed three meta-problems: 1. PR checks don't block merges. PR #370 had docker-init-e2e, docker-wrap-e2e, docker-native-e2e all FAILED yet got merged. Branch protection should require these checks. Operator action needed (cannot fix in code). 2. Local validation is misleading. `cargo build -p headroom-py` from the workspace root used the workspace lockfile and looked green; CI did fresh resolution against headroom-py's manifest alone where the feature wasn't enabled. Lesson: verify structural invariants with `cargo tree -e features` before trusting that a build "works." 3. 6+ build surfaces with independent system-dep state. Every Cargo change required matching updates in 6 places. The structural answer (this PR) is to NOT depend on system OpenSSL at all. Where structural fixes are not possible, the answer is a single shared scripts/install-rust-build-deps.sh — but with this PR there's nothing left to install.
2026-05-03 23:26:04 -07:00
import subprocess
for crate in ("headroom-py", "headroom-proxy", "headroom-core"):
result = subprocess.run(
[
"cargo",
"tree",
"--target",
"x86_64-unknown-linux-gnu",
"-p",
crate,
"-i",
"native-tls",
],
cwd=str(ROOT),
capture_output=True,
text=True,
check=False,
)
not_in_tree = result.returncode != 0 and "did not match any packages" in result.stderr
assert not_in_tree, (
f"native-tls is back in {crate}'s build tree — likely some "
f"crate's `default-features = true` re-enabled native-tls "
f"transitively:\n{result.stdout}"
)
def test_fastembed_uses_rustls_features() -> None:
"""The mechanism that keeps openssl-sys out of the build is
fastembed's explicit rustls feature selection in headroom-core.
fastembed's default features include `hf-hub-native-tls` and
`ort-download-binaries-native-tls` both pull openssl-sys.
Disabling defaults and enabling the rustls equivalents removes
the OpenSSL surface entirely.
"""
cargo = (ROOT / "crates" / "headroom-core" / "Cargo.toml").read_text(encoding="utf-8")
fix(ci): unbreak release pipeline — wheel openssl + dead npm artifact downloads Three independent failures on the post-merge release run for PR #360, all introduced by the single-wheel maturin refactor: 1. Linux wheel matrix (`ubuntu-x86_64`, `aarch64-unknown-linux-gnu`) failed inside the manylinux container with: Could not find openssl via pkg-config The system library `openssl` required by crate `openssl-sys` was not found. `openssl-sys` is transitive via `fastembed` → `hf-hub` → `ureq` → `native-tls`. The e2e Dockerfiles (`e2e/wrap`, `e2e/init`) we wrote for #360 install `openssl-devel` upfront, but the `build-wheels` matrix uses `PyO3/maturin-action@v1` which spins up its OWN manylinux container that does not inherit those installs. Fix: add a `before-script-linux:` to the action with a yum/apt-get conditional so it works on RHEL-family (manylinux2014, manylinux_2_28) and Debian-family musllinux variants. 2. macOS x86_64 wheel build failed with `maturin` exit 1 from the same `openssl-sys` lookup. The aarch64 macos-14 runner happens to have `/opt/homebrew/opt/openssl@3` on `openssl-sys`'s default discovery path; the Intel macos-15-intel runner uses `/usr/local/Cellar` which is NOT on that path. Fix: add a pre-maturin step that runs `brew install openssl@3` and exports `OPENSSL_DIR` / `OPENSSL_LIB_DIR` / `OPENSSL_INCLUDE_DIR` / `PKG_CONFIG_PATH`. The aarch64 runner happily picks up the explicit env vars too — no regression. 3. `publish-npm` and `publish-github-packages` both fail with "Artifact not found for name: dist". Both jobs `npm pack` + `npm publish` directly from the checked-out source tree — they never consume the Python `dist` artifact. The `Download dist artifact` step was vestigial dead code carried over from a prior workflow shape; the only reason it didn't fail before #360 is that the pre-refactor `build` job DID upload a `dist` artifact. Post-#360, `dist` is produced by `collect-dist` and neither publish job is gated on it (by design — npm vs PyPI ecosystems publish independently). Fix: remove the dead download step from both jobs. Loose coupling is preserved; `create-release` still gates the GitHub Release tag on all of build / build-wheels / collect-dist / publish-* succeeding. Why the PR-level CI didn't catch any of this: `release.yml` only runs on `push: branches: [main]`. PR #360's CI exercised `ci.yml` which has a separate `ci-build-wheels-on-pr` matrix that uses a different setup. The release surface only fires post-merge. Tests added (regression gates): - `test_build_wheels_installs_openssl_devel_on_linux_via_before_script` - `test_build_wheels_resolves_openssl_dir_explicitly_on_macos` - `test_npm_publish_jobs_do_not_download_dist_artifact` All 9 release-workflow tests pass. `make ci-precheck` PASSED.
2026-05-03 16:23:21 -07:00
fix(ci): rustls-everywhere — eliminate openssl-sys from build tree # Root cause of the wheel-build cascade We have shipped 5 release-pipeline hot-fixes in 12 hours, each addressing a different symptom of the same architectural problem: 1. PR #363 — npm artifact downloads + tried `yum openssl-devel` 2. PR #367 — vendored OpenSSL in `headroom-proxy` + dropped Intel mac 3. PR #369 — Debian-cross perl install (`perl` not `libipc-cmd-perl`) 4. PR #370 — moved `openssl/vendored` from headroom-proxy to headroom-py 5. (this PR) — ELIMINATE OpenSSL entirely Each fix exposed a different missing system package or feature flag in a different build surface (manylinux x86_64 vs aarch64-cross-Debian vs macOS Intel vs e2e/wrap Dockerfile vs e2e/init Dockerfile vs main Dockerfile vs devcontainer). We were playing whack-a-mole because every Cargo dep change to the OpenSSL surface required matching system-package updates in 6+ different Dockerfiles and workflows, and the PR-level CI didn't exercise all of them. # Why this PR is the structural fix `fastembed` exposes clean rustls feature flags: - `hf-hub-rustls-tls` (replaces default `hf-hub-native-tls`) - `ort-download-binaries-rustls-tls` (replaces default `…native-tls`) By disabling fastembed's default features and enabling the rustls variants explicitly, we remove `native-tls` (and therefore `openssl-sys`, `openssl`, `openssl-src`, perl modules, OpenSSL build-time deps, vendored OpenSSL ~30s build cost) from the entire workspace dep tree. Verified locally: $ cargo tree -p headroom-py -i openssl-sys error: package ID specification `openssl-sys` did not match any packages $ cargo tree -p headroom-py -i native-tls error: package ID specification `native-tls` did not match any packages $ cargo build --release -p headroom-py Finished `release` profile [optimized] target(s) in 25.57s (Down from 1m+ with vendored OpenSSL.) # Cleanups enabled by this change - crates/headroom-py/Cargo.toml — dropped the `openssl/vendored` workaround from PR #370. - crates/headroom-proxy/Cargo.toml — same dep removed. - e2e/wrap/Dockerfile — dropped `yum install openssl-devel pkgconfig perl-IPC-Cmd`. Comment retained explaining why. - e2e/init/Dockerfile — same. - Dockerfile (main) — dropped `pkg-config libssl-dev` from apt-get. - .devcontainer/Dockerfile — dropped `pkg-config libssl-dev`. - .github/workflows/release.yml — removed the entire before-script-linux block (perl install probe + multi-package-manager dispatch + fail-loud assertion). No longer needed. # Regression gate Three new structural tests in tests/test_release_workflows.py: - test_no_openssl_sys_in_wheel_build_tree — runs `cargo tree -p <crate> -i openssl-sys` for headroom-py / headroom-proxy / headroom-core. If openssl-sys reappears (a future native-tls enabler creeping in via a new dep), this fails AT PR TIME with an actionable message. - test_no_native_tls_in_wheel_build_tree — same shape, native-tls is the proximate cause. - test_fastembed_uses_rustls_features — checks the Cargo.toml so a future "let me bump fastembed and forget the features" doesn't silently re-introduce OpenSSL. Plus two cleanup gates: - test_dockerfiles_no_longer_install_openssl_devel - test_release_yml_does_not_install_openssl_or_perl_for_wheels All 13 release-workflow tests pass. `make ci-precheck` PASSED. # What this teaches us about rollouts (per user's ultrathink ask) The 5-fix cascade exposed three meta-problems: 1. PR checks don't block merges. PR #370 had docker-init-e2e, docker-wrap-e2e, docker-native-e2e all FAILED yet got merged. Branch protection should require these checks. Operator action needed (cannot fix in code). 2. Local validation is misleading. `cargo build -p headroom-py` from the workspace root used the workspace lockfile and looked green; CI did fresh resolution against headroom-py's manifest alone where the feature wasn't enabled. Lesson: verify structural invariants with `cargo tree -e features` before trusting that a build "works." 3. 6+ build surfaces with independent system-dep state. Every Cargo change required matching updates in 6 places. The structural answer (this PR) is to NOT depend on system OpenSSL at all. Where structural fixes are not possible, the answer is a single shared scripts/install-rust-build-deps.sh — but with this PR there's nothing left to install.
2026-05-03 23:26:04 -07:00
assert "default-features = false" in cargo
assert '"hf-hub-rustls-tls"' in cargo
assert '"ort-download-binaries-rustls-tls"' in cargo
# `image-models` is in default; we re-enable it explicitly so we
# don't lose the image-embedding capability when defaults are off.
assert '"image-models"' in cargo
fix(ci): wheel build before-script-linux must work on Debian aarch64-cross Previous wheel hot-fix (#367) introduced a NEW failure mode that the F1-merge release run surfaced: E: Unable to locate package libipc-cmd-perl The aarch64-unknown-linux-gnu maturin-action target does NOT use the AlmaLinux 8 manylinux_2_28 image — it uses a Debian/Ubuntu-based cross-compile container. The previous hot-fix's apt branch installed `libipc-cmd-perl` which is a deprecated alias and is no longer in the default Debian/Ubuntu sources. The build failed before openssl-src could even start. # What the script actually needs to do `IPC::Cmd` is a Perl core module since 5.10, so any working `perl` install provides it. The fix: 1. **Probe first.** `perl -MIPC::Cmd -e 1` exits cleanly when the module is already importable — skip the install entirely. Some manylinux images already ship it; others don't. 2. **Cover every package manager.** dnf (modern RHEL family) → yum (older RHEL) → apt-get (Debian/Ubuntu) → apk (Alpine/musllinux). The maturin-action uses different containers per (target, manylinux) combo and we don't get to pick. 3. **Use `perl` not `libipc-cmd-perl` on Debian.** The plain `perl` meta-package pulls `perl-modules-*` which contains IPC::Cmd. Works on every Debian/Ubuntu version we'll see; `libipc-cmd-perl` is gone from default sources. 4. **Fail loud after install.** `perl -MIPC::Cmd -e 'print "loaded OK"'` runs unconditionally at the end. If somehow the module is STILL missing, we fail here — not 5 minutes later in the openssl-src compile step where the error message is harder to debug. Matches the project's "no silent fallback" rule. # Tests `test_build_wheels_installs_perl_ipc_cmd_for_vendored_openssl` updated to gate the new shape: - Asserts `perl -MIPC::Cmd -e 1` pre-probe is present - Asserts dnf/yum/apt-get/apk branches all exist - Asserts apt branch installs `perl` not `libipc-cmd-perl` - Asserts `libipc-cmd-perl` does not appear on any non-comment line - Asserts the final `perl -MIPC::Cmd` fail-loud assertion is present All 11 release-workflow tests pass. `make ci-precheck` PASSED.
2026-05-03 20:03:27 -07:00
fix(ci): vendor OpenSSL via cargo + drop x86_64 macOS from wheel matrix The previous hot-fix (#363) addressed npm artifact downloads and added openssl-devel installs in the manylinux container, but the wheel build still fails on three of four matrix entries with three distinct errors: 1. ubuntu-x86_64 with `manylinux: auto` resolved to manylinux2014 (CentOS 7 / OpenSSL 1.0.2k). `openssl-sys 0.9` requires OpenSSL 1.1.0+ — "different version of OpenSSL was found". 2. ubuntu-aarch64 cross-compiles via `aarch64-unknown-linux-gnu-gcc` from an x86_64 manylinux container. The `yum install openssl-devel` we added installs x86_64 headers; `/usr/aarch64-unknown-linux-gnu/ include/` has no OpenSSL — "openssl/opensslv.h: No such file or directory". 3. macos-15-intel fails on `ort-sys` (transitive via the ML compression backend), which has no prebuilt ONNX Runtime binaries for `x86_64-apple-darwin`. Unrelated to OpenSSL; an upstream limitation. Why the workspace pulls openssl-sys at all: `hf-hub` (transitive via `fastembed`) hard-codes `native-tls` as a default feature. Cargo's feature unification then enables openssl-sys for the whole workspace despite our `reqwest`/`tokio-tungstenite`/`tokio-rustls` preferences. # Fix 1: vendored OpenSSL Add `openssl = { version = "0.10", features = ["vendored"] }` to `crates/headroom-proxy/Cargo.toml`. The `vendored` feature compiles OpenSSL from source as part of the cargo build — works on every target uniformly. Local build verified: cargo now pulls `openssl-src v300.6.0+3.6.2` and compiles it. ~30s extra one-time build cost. The `openssl/vendored` feature DEFEATS `OPENSSL_DIR`. We therefore remove the previous hot-fix's "Install OpenSSL (macOS)" step that exported `OPENSSL_DIR` — leaving it would silently regress to the system-OpenSSL path that broke originally. # Fix 2: pin manylinux floor to 2_28 Change x86_64-unknown-linux-gnu from `manylinux: auto` to `manylinux: 2_28` (matching aarch64 + the e2e Dockerfiles). This isn't strictly required with vendored OpenSSL — the floor is now glibc 2.28 / AlmaLinux 8 which has modern toolchain — but it removes the CentOS-7 surface entirely and matches our runtime container target. # Fix 3: drop x86_64-apple-darwin from the matrix `ort-sys 2.0.0-rc.12` has no prebuilt ONNX Runtime binaries for that target. Building ORT from source would add CMake + ~5 minutes per build. Apple Silicon macOS (`aarch64-apple-darwin`) is fully covered; Intel-mac users install from the platform-independent sdist this matrix also produces. Tracked as a follow-up: switch the ML backend to `ort-tract` or upstream a request for x86_64 macOS prebuilts. # before-script-linux: keep perl-IPC-Cmd, drop openssl-devel OpenSSL's vendored `Configure` script needs `IPC::Cmd` (without it the build fails with "Can't locate IPC/Cmd.pm"). System openssl-devel is no longer needed. # Tests 4 new regression tests gate this: - `test_headroom_proxy_vendors_openssl` - `test_build_wheels_installs_perl_ipc_cmd_for_vendored_openssl` - `test_build_wheels_does_not_set_openssl_dir` - `test_build_wheels_matrix_excludes_intel_macos` Plus the previous 7. All 11 release-workflow tests pass. `make ci-precheck` PASSED. Local `cargo build --release -p headroom-py` green.
2026-05-03 17:41:24 -07:00
def test_fastembed_uses_dynamic_ort_on_windows() -> None:
fix(build): enable Intel macOS pip installs via ort-load-dynamic (#1538) ## Description Fix `pip install headroom-ai` on Intel Mac (`x86_64-apple-darwin`). Source installs failed because `ort-sys 2.0.0-rc.12` (transitive via `fastembed`) does not ship prebuilt ONNX Runtime binaries for that target, causing maturin/cargo to exit during the wheel build. This PR mirrors the existing Windows fix: build the Rust core with `ort-load-dynamic`, pin `ORT_DYLIB_PATH` to the pip `onnxruntime` native library at import time, and publish Intel macOS wheels from CI. Closes # ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [x] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `crates/headroom-core/Cargo.toml`: use `ort-load-dynamic` for `x86_64-apple-darwin` instead of `ort-download-binaries-rustls-tls`. - `headroom/_ort.py`: extend the ORT dylib pin hook to Intel macOS (`darwin` + `x86_64`), resolving `libonnxruntime*.dylib` from the pip `onnxruntime` package. - `.github/workflows/release.yml` and `.github/workflows/rust.yml`: add `macos-15-intel` / `x86_64-apple-darwin` wheel matrix entries. - `tests/test_release_workflows.py` and `tests/test_transforms/test_ort_dylib.py`: update/add coverage for the new target and dylib pin behavior. - `README.md`: note that prebuilt wheels are published for Intel macOS. ## Testing - [x] Unit tests pass (`pytest`) - [ ] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ pytest tests/test_transforms/test_ort_dylib.py \ tests/test_release_workflows.py::test_fastembed_uses_dynamic_ort_on_windows \ tests/test_release_workflows.py::test_build_wheels_matrix_includes_intel_macos_with_dynamic_ort -q .......................... [100%] 10 passed in 0.18s $ maturin build --release -o /tmp/headroom-dist 📦 Built wheel for abi3 Python ≥ 3.10 to /tmp/headroom-dist/headroom_ai-0.27.0-cp310-abi3-macosx_10_12_x86_64.whl $ python3.11 -m venv /tmp/hr-venv && /tmp/hr-venv/bin/pip install . Successfully built headroom-ai Successfully installed headroom-ai-0.27.0 $ cd /tmp && /tmp/hr-venv/bin/python -c "import headroom; import headroom._core; print('ok')" version 0.27.0 _core ok ``` ## Real Behavior Proof - Environment: macOS `x86_64-apple-darwin`, Python 3.11.5, Rust 1.95.0 - Exact command / steps: Reproduced the reported failure with `pip install headroom-ai` (sdist build dies in `ort-sys` for `x86_64-apple-darwin`); after this patch ran `maturin build --release`, then `pip install .` in a clean venv, then `python -c "import headroom._core"`. - Observed result: Before fix, cargo/maturin exit 101 on missing ORT prebuilts; after fix, wheel build succeeds and `headroom._core` imports cleanly (`version 0.27.0`, `_core ok`). - Not tested: `macos-15-intel` GitHub Actions wheel matrix row (will be validated by CI after merge). ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes - ML features (magika detection, fastembed embeddings) still require `onnxruntime` at runtime on Intel Mac. Users should install `headroom-ai[proxy]` or `pip install onnxruntime`; `_ort.py` auto-pins `ORT_DYLIB_PATH` when that package is present. - Apple Silicon (`aarch64-apple-darwin`) behavior is unchanged: it continues to bundle ORT via `ort-download-binaries-rustls-tls`. - Lint/mypy not re-run locally in this pass; targeted pytest + maturin/pip install proof covers the changed surface. --------- Co-authored-by: Bor <you@example.com> Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 19:33:34 -04:00
"""Windows and Intel macOS sdist builds must not link Pyke's ORT binaries.
fix(build): enable Intel macOS pip installs via ort-load-dynamic (#1538) ## Description Fix `pip install headroom-ai` on Intel Mac (`x86_64-apple-darwin`). Source installs failed because `ort-sys 2.0.0-rc.12` (transitive via `fastembed`) does not ship prebuilt ONNX Runtime binaries for that target, causing maturin/cargo to exit during the wheel build. This PR mirrors the existing Windows fix: build the Rust core with `ort-load-dynamic`, pin `ORT_DYLIB_PATH` to the pip `onnxruntime` native library at import time, and publish Intel macOS wheels from CI. Closes # ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [x] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `crates/headroom-core/Cargo.toml`: use `ort-load-dynamic` for `x86_64-apple-darwin` instead of `ort-download-binaries-rustls-tls`. - `headroom/_ort.py`: extend the ORT dylib pin hook to Intel macOS (`darwin` + `x86_64`), resolving `libonnxruntime*.dylib` from the pip `onnxruntime` package. - `.github/workflows/release.yml` and `.github/workflows/rust.yml`: add `macos-15-intel` / `x86_64-apple-darwin` wheel matrix entries. - `tests/test_release_workflows.py` and `tests/test_transforms/test_ort_dylib.py`: update/add coverage for the new target and dylib pin behavior. - `README.md`: note that prebuilt wheels are published for Intel macOS. ## Testing - [x] Unit tests pass (`pytest`) - [ ] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ pytest tests/test_transforms/test_ort_dylib.py \ tests/test_release_workflows.py::test_fastembed_uses_dynamic_ort_on_windows \ tests/test_release_workflows.py::test_build_wheels_matrix_includes_intel_macos_with_dynamic_ort -q .......................... [100%] 10 passed in 0.18s $ maturin build --release -o /tmp/headroom-dist 📦 Built wheel for abi3 Python ≥ 3.10 to /tmp/headroom-dist/headroom_ai-0.27.0-cp310-abi3-macosx_10_12_x86_64.whl $ python3.11 -m venv /tmp/hr-venv && /tmp/hr-venv/bin/pip install . Successfully built headroom-ai Successfully installed headroom-ai-0.27.0 $ cd /tmp && /tmp/hr-venv/bin/python -c "import headroom; import headroom._core; print('ok')" version 0.27.0 _core ok ``` ## Real Behavior Proof - Environment: macOS `x86_64-apple-darwin`, Python 3.11.5, Rust 1.95.0 - Exact command / steps: Reproduced the reported failure with `pip install headroom-ai` (sdist build dies in `ort-sys` for `x86_64-apple-darwin`); after this patch ran `maturin build --release`, then `pip install .` in a clean venv, then `python -c "import headroom._core"`. - Observed result: Before fix, cargo/maturin exit 101 on missing ORT prebuilts; after fix, wheel build succeeds and `headroom._core` imports cleanly (`version 0.27.0`, `_core ok`). - Not tested: `macos-15-intel` GitHub Actions wheel matrix row (will be validated by CI after merge). ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes - ML features (magika detection, fastembed embeddings) still require `onnxruntime` at runtime on Intel Mac. Users should install `headroom-ai[proxy]` or `pip install onnxruntime`; `_ort.py` auto-pins `ORT_DYLIB_PATH` when that package is present. - Apple Silicon (`aarch64-apple-darwin`) behavior is unchanged: it continues to bundle ORT via `ort-download-binaries-rustls-tls`. - Lint/mypy not re-run locally in this pass; targeted pytest + maturin/pip install proof covers the changed surface. --------- Co-authored-by: Bor <you@example.com> Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 19:33:34 -04:00
`ort-download-binaries-*` emits platform SDK link libs (DirectML on
Windows; unavailable prebuilts on `x86_64-apple-darwin`). Those targets
must use ORT dynamic loading instead.
"""
cargo = (ROOT / "crates" / "headroom-core" / "Cargo.toml").read_text(encoding="utf-8")
fix(build): enable Intel macOS pip installs via ort-load-dynamic (#1538) ## Description Fix `pip install headroom-ai` on Intel Mac (`x86_64-apple-darwin`). Source installs failed because `ort-sys 2.0.0-rc.12` (transitive via `fastembed`) does not ship prebuilt ONNX Runtime binaries for that target, causing maturin/cargo to exit during the wheel build. This PR mirrors the existing Windows fix: build the Rust core with `ort-load-dynamic`, pin `ORT_DYLIB_PATH` to the pip `onnxruntime` native library at import time, and publish Intel macOS wheels from CI. Closes # ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [x] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `crates/headroom-core/Cargo.toml`: use `ort-load-dynamic` for `x86_64-apple-darwin` instead of `ort-download-binaries-rustls-tls`. - `headroom/_ort.py`: extend the ORT dylib pin hook to Intel macOS (`darwin` + `x86_64`), resolving `libonnxruntime*.dylib` from the pip `onnxruntime` package. - `.github/workflows/release.yml` and `.github/workflows/rust.yml`: add `macos-15-intel` / `x86_64-apple-darwin` wheel matrix entries. - `tests/test_release_workflows.py` and `tests/test_transforms/test_ort_dylib.py`: update/add coverage for the new target and dylib pin behavior. - `README.md`: note that prebuilt wheels are published for Intel macOS. ## Testing - [x] Unit tests pass (`pytest`) - [ ] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ pytest tests/test_transforms/test_ort_dylib.py \ tests/test_release_workflows.py::test_fastembed_uses_dynamic_ort_on_windows \ tests/test_release_workflows.py::test_build_wheels_matrix_includes_intel_macos_with_dynamic_ort -q .......................... [100%] 10 passed in 0.18s $ maturin build --release -o /tmp/headroom-dist 📦 Built wheel for abi3 Python ≥ 3.10 to /tmp/headroom-dist/headroom_ai-0.27.0-cp310-abi3-macosx_10_12_x86_64.whl $ python3.11 -m venv /tmp/hr-venv && /tmp/hr-venv/bin/pip install . Successfully built headroom-ai Successfully installed headroom-ai-0.27.0 $ cd /tmp && /tmp/hr-venv/bin/python -c "import headroom; import headroom._core; print('ok')" version 0.27.0 _core ok ``` ## Real Behavior Proof - Environment: macOS `x86_64-apple-darwin`, Python 3.11.5, Rust 1.95.0 - Exact command / steps: Reproduced the reported failure with `pip install headroom-ai` (sdist build dies in `ort-sys` for `x86_64-apple-darwin`); after this patch ran `maturin build --release`, then `pip install .` in a clean venv, then `python -c "import headroom._core"`. - Observed result: Before fix, cargo/maturin exit 101 on missing ORT prebuilts; after fix, wheel build succeeds and `headroom._core` imports cleanly (`version 0.27.0`, `_core ok`). - Not tested: `macos-15-intel` GitHub Actions wheel matrix row (will be validated by CI after merge). ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes - ML features (magika detection, fastembed embeddings) still require `onnxruntime` at runtime on Intel Mac. Users should install `headroom-ai[proxy]` or `pip install onnxruntime`; `_ort.py` auto-pins `ORT_DYLIB_PATH` when that package is present. - Apple Silicon (`aarch64-apple-darwin`) behavior is unchanged: it continues to bundle ORT via `ort-download-binaries-rustls-tls`. - Lint/mypy not re-run locally in this pass; targeted pytest + maturin/pip install proof covers the changed surface. --------- Co-authored-by: Bor <you@example.com> Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 19:33:34 -04:00
for section_marker in (
"[target.'cfg(windows)'.dependencies]",
'[target.\'cfg(all(target_os = "macos", target_arch = "x86_64"))\'.dependencies]',
):
assert section_marker in cargo, f"missing Cargo target section: {section_marker}"
section = cargo.split(section_marker, 1)[1].split("\n[", 1)[0]
dependency_lines = "\n".join(
line for line in section.splitlines() if not line.lstrip().startswith("#")
)
assert '"ort-load-dynamic"' in section
assert "ort-download-binaries" not in dependency_lines
fix(ci): rustls-everywhere — eliminate openssl-sys from build tree # Root cause of the wheel-build cascade We have shipped 5 release-pipeline hot-fixes in 12 hours, each addressing a different symptom of the same architectural problem: 1. PR #363 — npm artifact downloads + tried `yum openssl-devel` 2. PR #367 — vendored OpenSSL in `headroom-proxy` + dropped Intel mac 3. PR #369 — Debian-cross perl install (`perl` not `libipc-cmd-perl`) 4. PR #370 — moved `openssl/vendored` from headroom-proxy to headroom-py 5. (this PR) — ELIMINATE OpenSSL entirely Each fix exposed a different missing system package or feature flag in a different build surface (manylinux x86_64 vs aarch64-cross-Debian vs macOS Intel vs e2e/wrap Dockerfile vs e2e/init Dockerfile vs main Dockerfile vs devcontainer). We were playing whack-a-mole because every Cargo dep change to the OpenSSL surface required matching system-package updates in 6+ different Dockerfiles and workflows, and the PR-level CI didn't exercise all of them. # Why this PR is the structural fix `fastembed` exposes clean rustls feature flags: - `hf-hub-rustls-tls` (replaces default `hf-hub-native-tls`) - `ort-download-binaries-rustls-tls` (replaces default `…native-tls`) By disabling fastembed's default features and enabling the rustls variants explicitly, we remove `native-tls` (and therefore `openssl-sys`, `openssl`, `openssl-src`, perl modules, OpenSSL build-time deps, vendored OpenSSL ~30s build cost) from the entire workspace dep tree. Verified locally: $ cargo tree -p headroom-py -i openssl-sys error: package ID specification `openssl-sys` did not match any packages $ cargo tree -p headroom-py -i native-tls error: package ID specification `native-tls` did not match any packages $ cargo build --release -p headroom-py Finished `release` profile [optimized] target(s) in 25.57s (Down from 1m+ with vendored OpenSSL.) # Cleanups enabled by this change - crates/headroom-py/Cargo.toml — dropped the `openssl/vendored` workaround from PR #370. - crates/headroom-proxy/Cargo.toml — same dep removed. - e2e/wrap/Dockerfile — dropped `yum install openssl-devel pkgconfig perl-IPC-Cmd`. Comment retained explaining why. - e2e/init/Dockerfile — same. - Dockerfile (main) — dropped `pkg-config libssl-dev` from apt-get. - .devcontainer/Dockerfile — dropped `pkg-config libssl-dev`. - .github/workflows/release.yml — removed the entire before-script-linux block (perl install probe + multi-package-manager dispatch + fail-loud assertion). No longer needed. # Regression gate Three new structural tests in tests/test_release_workflows.py: - test_no_openssl_sys_in_wheel_build_tree — runs `cargo tree -p <crate> -i openssl-sys` for headroom-py / headroom-proxy / headroom-core. If openssl-sys reappears (a future native-tls enabler creeping in via a new dep), this fails AT PR TIME with an actionable message. - test_no_native_tls_in_wheel_build_tree — same shape, native-tls is the proximate cause. - test_fastembed_uses_rustls_features — checks the Cargo.toml so a future "let me bump fastembed and forget the features" doesn't silently re-introduce OpenSSL. Plus two cleanup gates: - test_dockerfiles_no_longer_install_openssl_devel - test_release_yml_does_not_install_openssl_or_perl_for_wheels All 13 release-workflow tests pass. `make ci-precheck` PASSED. # What this teaches us about rollouts (per user's ultrathink ask) The 5-fix cascade exposed three meta-problems: 1. PR checks don't block merges. PR #370 had docker-init-e2e, docker-wrap-e2e, docker-native-e2e all FAILED yet got merged. Branch protection should require these checks. Operator action needed (cannot fix in code). 2. Local validation is misleading. `cargo build -p headroom-py` from the workspace root used the workspace lockfile and looked green; CI did fresh resolution against headroom-py's manifest alone where the feature wasn't enabled. Lesson: verify structural invariants with `cargo tree -e features` before trusting that a build "works." 3. 6+ build surfaces with independent system-dep state. Every Cargo change required matching updates in 6 places. The structural answer (this PR) is to NOT depend on system OpenSSL at all. Where structural fixes are not possible, the answer is a single shared scripts/install-rust-build-deps.sh — but with this PR there's nothing left to install.
2026-05-03 23:26:04 -07:00
def test_dockerfiles_no_longer_install_openssl_devel() -> None:
"""Once openssl-sys is out of the build tree, every Dockerfile
that used to install `openssl-devel` / `libssl-dev` for the Rust
build can drop those packages. This test enforces the cleanup so
a future refactor doesn't carry the old packages forward "just
in case".
fix(ci): vendor OpenSSL via cargo + drop x86_64 macOS from wheel matrix The previous hot-fix (#363) addressed npm artifact downloads and added openssl-devel installs in the manylinux container, but the wheel build still fails on three of four matrix entries with three distinct errors: 1. ubuntu-x86_64 with `manylinux: auto` resolved to manylinux2014 (CentOS 7 / OpenSSL 1.0.2k). `openssl-sys 0.9` requires OpenSSL 1.1.0+ — "different version of OpenSSL was found". 2. ubuntu-aarch64 cross-compiles via `aarch64-unknown-linux-gnu-gcc` from an x86_64 manylinux container. The `yum install openssl-devel` we added installs x86_64 headers; `/usr/aarch64-unknown-linux-gnu/ include/` has no OpenSSL — "openssl/opensslv.h: No such file or directory". 3. macos-15-intel fails on `ort-sys` (transitive via the ML compression backend), which has no prebuilt ONNX Runtime binaries for `x86_64-apple-darwin`. Unrelated to OpenSSL; an upstream limitation. Why the workspace pulls openssl-sys at all: `hf-hub` (transitive via `fastembed`) hard-codes `native-tls` as a default feature. Cargo's feature unification then enables openssl-sys for the whole workspace despite our `reqwest`/`tokio-tungstenite`/`tokio-rustls` preferences. # Fix 1: vendored OpenSSL Add `openssl = { version = "0.10", features = ["vendored"] }` to `crates/headroom-proxy/Cargo.toml`. The `vendored` feature compiles OpenSSL from source as part of the cargo build — works on every target uniformly. Local build verified: cargo now pulls `openssl-src v300.6.0+3.6.2` and compiles it. ~30s extra one-time build cost. The `openssl/vendored` feature DEFEATS `OPENSSL_DIR`. We therefore remove the previous hot-fix's "Install OpenSSL (macOS)" step that exported `OPENSSL_DIR` — leaving it would silently regress to the system-OpenSSL path that broke originally. # Fix 2: pin manylinux floor to 2_28 Change x86_64-unknown-linux-gnu from `manylinux: auto` to `manylinux: 2_28` (matching aarch64 + the e2e Dockerfiles). This isn't strictly required with vendored OpenSSL — the floor is now glibc 2.28 / AlmaLinux 8 which has modern toolchain — but it removes the CentOS-7 surface entirely and matches our runtime container target. # Fix 3: drop x86_64-apple-darwin from the matrix `ort-sys 2.0.0-rc.12` has no prebuilt ONNX Runtime binaries for that target. Building ORT from source would add CMake + ~5 minutes per build. Apple Silicon macOS (`aarch64-apple-darwin`) is fully covered; Intel-mac users install from the platform-independent sdist this matrix also produces. Tracked as a follow-up: switch the ML backend to `ort-tract` or upstream a request for x86_64 macOS prebuilts. # before-script-linux: keep perl-IPC-Cmd, drop openssl-devel OpenSSL's vendored `Configure` script needs `IPC::Cmd` (without it the build fails with "Can't locate IPC/Cmd.pm"). System openssl-devel is no longer needed. # Tests 4 new regression tests gate this: - `test_headroom_proxy_vendors_openssl` - `test_build_wheels_installs_perl_ipc_cmd_for_vendored_openssl` - `test_build_wheels_does_not_set_openssl_dir` - `test_build_wheels_matrix_excludes_intel_macos` Plus the previous 7. All 11 release-workflow tests pass. `make ci-precheck` PASSED. Local `cargo build --release -p headroom-py` green.
2026-05-03 17:41:24 -07:00
fix(ci): rustls-everywhere — eliminate openssl-sys from build tree # Root cause of the wheel-build cascade We have shipped 5 release-pipeline hot-fixes in 12 hours, each addressing a different symptom of the same architectural problem: 1. PR #363 — npm artifact downloads + tried `yum openssl-devel` 2. PR #367 — vendored OpenSSL in `headroom-proxy` + dropped Intel mac 3. PR #369 — Debian-cross perl install (`perl` not `libipc-cmd-perl`) 4. PR #370 — moved `openssl/vendored` from headroom-proxy to headroom-py 5. (this PR) — ELIMINATE OpenSSL entirely Each fix exposed a different missing system package or feature flag in a different build surface (manylinux x86_64 vs aarch64-cross-Debian vs macOS Intel vs e2e/wrap Dockerfile vs e2e/init Dockerfile vs main Dockerfile vs devcontainer). We were playing whack-a-mole because every Cargo dep change to the OpenSSL surface required matching system-package updates in 6+ different Dockerfiles and workflows, and the PR-level CI didn't exercise all of them. # Why this PR is the structural fix `fastembed` exposes clean rustls feature flags: - `hf-hub-rustls-tls` (replaces default `hf-hub-native-tls`) - `ort-download-binaries-rustls-tls` (replaces default `…native-tls`) By disabling fastembed's default features and enabling the rustls variants explicitly, we remove `native-tls` (and therefore `openssl-sys`, `openssl`, `openssl-src`, perl modules, OpenSSL build-time deps, vendored OpenSSL ~30s build cost) from the entire workspace dep tree. Verified locally: $ cargo tree -p headroom-py -i openssl-sys error: package ID specification `openssl-sys` did not match any packages $ cargo tree -p headroom-py -i native-tls error: package ID specification `native-tls` did not match any packages $ cargo build --release -p headroom-py Finished `release` profile [optimized] target(s) in 25.57s (Down from 1m+ with vendored OpenSSL.) # Cleanups enabled by this change - crates/headroom-py/Cargo.toml — dropped the `openssl/vendored` workaround from PR #370. - crates/headroom-proxy/Cargo.toml — same dep removed. - e2e/wrap/Dockerfile — dropped `yum install openssl-devel pkgconfig perl-IPC-Cmd`. Comment retained explaining why. - e2e/init/Dockerfile — same. - Dockerfile (main) — dropped `pkg-config libssl-dev` from apt-get. - .devcontainer/Dockerfile — dropped `pkg-config libssl-dev`. - .github/workflows/release.yml — removed the entire before-script-linux block (perl install probe + multi-package-manager dispatch + fail-loud assertion). No longer needed. # Regression gate Three new structural tests in tests/test_release_workflows.py: - test_no_openssl_sys_in_wheel_build_tree — runs `cargo tree -p <crate> -i openssl-sys` for headroom-py / headroom-proxy / headroom-core. If openssl-sys reappears (a future native-tls enabler creeping in via a new dep), this fails AT PR TIME with an actionable message. - test_no_native_tls_in_wheel_build_tree — same shape, native-tls is the proximate cause. - test_fastembed_uses_rustls_features — checks the Cargo.toml so a future "let me bump fastembed and forget the features" doesn't silently re-introduce OpenSSL. Plus two cleanup gates: - test_dockerfiles_no_longer_install_openssl_devel - test_release_yml_does_not_install_openssl_or_perl_for_wheels All 13 release-workflow tests pass. `make ci-precheck` PASSED. # What this teaches us about rollouts (per user's ultrathink ask) The 5-fix cascade exposed three meta-problems: 1. PR checks don't block merges. PR #370 had docker-init-e2e, docker-wrap-e2e, docker-native-e2e all FAILED yet got merged. Branch protection should require these checks. Operator action needed (cannot fix in code). 2. Local validation is misleading. `cargo build -p headroom-py` from the workspace root used the workspace lockfile and looked green; CI did fresh resolution against headroom-py's manifest alone where the feature wasn't enabled. Lesson: verify structural invariants with `cargo tree -e features` before trusting that a build "works." 3. 6+ build surfaces with independent system-dep state. Every Cargo change required matching updates in 6 places. The structural answer (this PR) is to NOT depend on system OpenSSL at all. Where structural fixes are not possible, the answer is a single shared scripts/install-rust-build-deps.sh — but with this PR there's nothing left to install.
2026-05-03 23:26:04 -07:00
The check looks only at non-comment lines so explanatory comments
that mention the historical packages don't false-positive.
"""
targets = [
ROOT / "e2e" / "wrap" / "Dockerfile",
ROOT / "e2e" / "init" / "Dockerfile",
ROOT / "Dockerfile",
ROOT / ".devcontainer" / "Dockerfile",
]
forbidden = ["openssl-devel", "libssl-dev"]
for target in targets:
content = target.read_text(encoding="utf-8")
non_comment = "\n".join(
line for line in content.splitlines() if not line.lstrip().startswith("#")
)
for pkg in forbidden:
assert pkg not in non_comment, (
f"{target.relative_to(ROOT)} still installs {pkg!r} on a "
f"non-comment line. The rustls-everywhere refactor removed "
f"openssl-sys from the build tree; this package is no "
f"longer needed."
)
def test_release_yml_does_not_install_openssl_or_perl_for_wheels() -> None:
"""With openssl-sys out of the build tree (verified by
test_no_openssl_sys_in_wheel_build_tree), the previous
before-script-linux that installed perl-IPC-Cmd / perl /
perl-utils for the openssl-src vendored Configure script is
obsolete. Removing it speeds the wheel build and keeps the
Linux entry honest every package install we keep here
represents a hidden assumption about the manylinux container.
fix(ci): vendor OpenSSL via cargo + drop x86_64 macOS from wheel matrix The previous hot-fix (#363) addressed npm artifact downloads and added openssl-devel installs in the manylinux container, but the wheel build still fails on three of four matrix entries with three distinct errors: 1. ubuntu-x86_64 with `manylinux: auto` resolved to manylinux2014 (CentOS 7 / OpenSSL 1.0.2k). `openssl-sys 0.9` requires OpenSSL 1.1.0+ — "different version of OpenSSL was found". 2. ubuntu-aarch64 cross-compiles via `aarch64-unknown-linux-gnu-gcc` from an x86_64 manylinux container. The `yum install openssl-devel` we added installs x86_64 headers; `/usr/aarch64-unknown-linux-gnu/ include/` has no OpenSSL — "openssl/opensslv.h: No such file or directory". 3. macos-15-intel fails on `ort-sys` (transitive via the ML compression backend), which has no prebuilt ONNX Runtime binaries for `x86_64-apple-darwin`. Unrelated to OpenSSL; an upstream limitation. Why the workspace pulls openssl-sys at all: `hf-hub` (transitive via `fastembed`) hard-codes `native-tls` as a default feature. Cargo's feature unification then enables openssl-sys for the whole workspace despite our `reqwest`/`tokio-tungstenite`/`tokio-rustls` preferences. # Fix 1: vendored OpenSSL Add `openssl = { version = "0.10", features = ["vendored"] }` to `crates/headroom-proxy/Cargo.toml`. The `vendored` feature compiles OpenSSL from source as part of the cargo build — works on every target uniformly. Local build verified: cargo now pulls `openssl-src v300.6.0+3.6.2` and compiles it. ~30s extra one-time build cost. The `openssl/vendored` feature DEFEATS `OPENSSL_DIR`. We therefore remove the previous hot-fix's "Install OpenSSL (macOS)" step that exported `OPENSSL_DIR` — leaving it would silently regress to the system-OpenSSL path that broke originally. # Fix 2: pin manylinux floor to 2_28 Change x86_64-unknown-linux-gnu from `manylinux: auto` to `manylinux: 2_28` (matching aarch64 + the e2e Dockerfiles). This isn't strictly required with vendored OpenSSL — the floor is now glibc 2.28 / AlmaLinux 8 which has modern toolchain — but it removes the CentOS-7 surface entirely and matches our runtime container target. # Fix 3: drop x86_64-apple-darwin from the matrix `ort-sys 2.0.0-rc.12` has no prebuilt ONNX Runtime binaries for that target. Building ORT from source would add CMake + ~5 minutes per build. Apple Silicon macOS (`aarch64-apple-darwin`) is fully covered; Intel-mac users install from the platform-independent sdist this matrix also produces. Tracked as a follow-up: switch the ML backend to `ort-tract` or upstream a request for x86_64 macOS prebuilts. # before-script-linux: keep perl-IPC-Cmd, drop openssl-devel OpenSSL's vendored `Configure` script needs `IPC::Cmd` (without it the build fails with "Can't locate IPC/Cmd.pm"). System openssl-devel is no longer needed. # Tests 4 new regression tests gate this: - `test_headroom_proxy_vendors_openssl` - `test_build_wheels_installs_perl_ipc_cmd_for_vendored_openssl` - `test_build_wheels_does_not_set_openssl_dir` - `test_build_wheels_matrix_excludes_intel_macos` Plus the previous 7. All 11 release-workflow tests pass. `make ci-precheck` PASSED. Local `cargo build --release -p headroom-py` green.
2026-05-03 17:41:24 -07:00
"""
content = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
bw_start = content.index("\n build-wheels:")
bw_end = content.index("\n collect-dist:")
body = content[bw_start:bw_end]
fix(ci): rustls-everywhere — eliminate openssl-sys from build tree # Root cause of the wheel-build cascade We have shipped 5 release-pipeline hot-fixes in 12 hours, each addressing a different symptom of the same architectural problem: 1. PR #363 — npm artifact downloads + tried `yum openssl-devel` 2. PR #367 — vendored OpenSSL in `headroom-proxy` + dropped Intel mac 3. PR #369 — Debian-cross perl install (`perl` not `libipc-cmd-perl`) 4. PR #370 — moved `openssl/vendored` from headroom-proxy to headroom-py 5. (this PR) — ELIMINATE OpenSSL entirely Each fix exposed a different missing system package or feature flag in a different build surface (manylinux x86_64 vs aarch64-cross-Debian vs macOS Intel vs e2e/wrap Dockerfile vs e2e/init Dockerfile vs main Dockerfile vs devcontainer). We were playing whack-a-mole because every Cargo dep change to the OpenSSL surface required matching system-package updates in 6+ different Dockerfiles and workflows, and the PR-level CI didn't exercise all of them. # Why this PR is the structural fix `fastembed` exposes clean rustls feature flags: - `hf-hub-rustls-tls` (replaces default `hf-hub-native-tls`) - `ort-download-binaries-rustls-tls` (replaces default `…native-tls`) By disabling fastembed's default features and enabling the rustls variants explicitly, we remove `native-tls` (and therefore `openssl-sys`, `openssl`, `openssl-src`, perl modules, OpenSSL build-time deps, vendored OpenSSL ~30s build cost) from the entire workspace dep tree. Verified locally: $ cargo tree -p headroom-py -i openssl-sys error: package ID specification `openssl-sys` did not match any packages $ cargo tree -p headroom-py -i native-tls error: package ID specification `native-tls` did not match any packages $ cargo build --release -p headroom-py Finished `release` profile [optimized] target(s) in 25.57s (Down from 1m+ with vendored OpenSSL.) # Cleanups enabled by this change - crates/headroom-py/Cargo.toml — dropped the `openssl/vendored` workaround from PR #370. - crates/headroom-proxy/Cargo.toml — same dep removed. - e2e/wrap/Dockerfile — dropped `yum install openssl-devel pkgconfig perl-IPC-Cmd`. Comment retained explaining why. - e2e/init/Dockerfile — same. - Dockerfile (main) — dropped `pkg-config libssl-dev` from apt-get. - .devcontainer/Dockerfile — dropped `pkg-config libssl-dev`. - .github/workflows/release.yml — removed the entire before-script-linux block (perl install probe + multi-package-manager dispatch + fail-loud assertion). No longer needed. # Regression gate Three new structural tests in tests/test_release_workflows.py: - test_no_openssl_sys_in_wheel_build_tree — runs `cargo tree -p <crate> -i openssl-sys` for headroom-py / headroom-proxy / headroom-core. If openssl-sys reappears (a future native-tls enabler creeping in via a new dep), this fails AT PR TIME with an actionable message. - test_no_native_tls_in_wheel_build_tree — same shape, native-tls is the proximate cause. - test_fastembed_uses_rustls_features — checks the Cargo.toml so a future "let me bump fastembed and forget the features" doesn't silently re-introduce OpenSSL. Plus two cleanup gates: - test_dockerfiles_no_longer_install_openssl_devel - test_release_yml_does_not_install_openssl_or_perl_for_wheels All 13 release-workflow tests pass. `make ci-precheck` PASSED. # What this teaches us about rollouts (per user's ultrathink ask) The 5-fix cascade exposed three meta-problems: 1. PR checks don't block merges. PR #370 had docker-init-e2e, docker-wrap-e2e, docker-native-e2e all FAILED yet got merged. Branch protection should require these checks. Operator action needed (cannot fix in code). 2. Local validation is misleading. `cargo build -p headroom-py` from the workspace root used the workspace lockfile and looked green; CI did fresh resolution against headroom-py's manifest alone where the feature wasn't enabled. Lesson: verify structural invariants with `cargo tree -e features` before trusting that a build "works." 3. 6+ build surfaces with independent system-dep state. Every Cargo change required matching updates in 6 places. The structural answer (this PR) is to NOT depend on system OpenSSL at all. Where structural fixes are not possible, the answer is a single shared scripts/install-rust-build-deps.sh — but with this PR there's nothing left to install.
2026-05-03 23:26:04 -07:00
non_comment = "\n".join(line for line in body.splitlines() if not line.lstrip().startswith("#"))
# No legacy install commands or env vars must appear on a non-comment
# line. Each forbidden token represents an assumption about system
# OpenSSL that the rustls refactor removed.
forbidden = [
"openssl-devel",
"libssl-dev",
"perl-IPC-Cmd",
"libipc-cmd-perl",
"OPENSSL_DIR",
]
for token in forbidden:
assert token not in non_comment, (
f"release.yml build-wheels job still references {token!r} on "
f"a non-comment line. The rustls-everywhere refactor removed "
f"openssl-sys from the build tree; this command/env is now "
f"obsolete."
)
fix(ci): unbreak release pipeline — wheel openssl + dead npm artifact downloads Three independent failures on the post-merge release run for PR #360, all introduced by the single-wheel maturin refactor: 1. Linux wheel matrix (`ubuntu-x86_64`, `aarch64-unknown-linux-gnu`) failed inside the manylinux container with: Could not find openssl via pkg-config The system library `openssl` required by crate `openssl-sys` was not found. `openssl-sys` is transitive via `fastembed` → `hf-hub` → `ureq` → `native-tls`. The e2e Dockerfiles (`e2e/wrap`, `e2e/init`) we wrote for #360 install `openssl-devel` upfront, but the `build-wheels` matrix uses `PyO3/maturin-action@v1` which spins up its OWN manylinux container that does not inherit those installs. Fix: add a `before-script-linux:` to the action with a yum/apt-get conditional so it works on RHEL-family (manylinux2014, manylinux_2_28) and Debian-family musllinux variants. 2. macOS x86_64 wheel build failed with `maturin` exit 1 from the same `openssl-sys` lookup. The aarch64 macos-14 runner happens to have `/opt/homebrew/opt/openssl@3` on `openssl-sys`'s default discovery path; the Intel macos-15-intel runner uses `/usr/local/Cellar` which is NOT on that path. Fix: add a pre-maturin step that runs `brew install openssl@3` and exports `OPENSSL_DIR` / `OPENSSL_LIB_DIR` / `OPENSSL_INCLUDE_DIR` / `PKG_CONFIG_PATH`. The aarch64 runner happily picks up the explicit env vars too — no regression. 3. `publish-npm` and `publish-github-packages` both fail with "Artifact not found for name: dist". Both jobs `npm pack` + `npm publish` directly from the checked-out source tree — they never consume the Python `dist` artifact. The `Download dist artifact` step was vestigial dead code carried over from a prior workflow shape; the only reason it didn't fail before #360 is that the pre-refactor `build` job DID upload a `dist` artifact. Post-#360, `dist` is produced by `collect-dist` and neither publish job is gated on it (by design — npm vs PyPI ecosystems publish independently). Fix: remove the dead download step from both jobs. Loose coupling is preserved; `create-release` still gates the GitHub Release tag on all of build / build-wheels / collect-dist / publish-* succeeding. Why the PR-level CI didn't catch any of this: `release.yml` only runs on `push: branches: [main]`. PR #360's CI exercised `ci.yml` which has a separate `ci-build-wheels-on-pr` matrix that uses a different setup. The release surface only fires post-merge. Tests added (regression gates): - `test_build_wheels_installs_openssl_devel_on_linux_via_before_script` - `test_build_wheels_resolves_openssl_dir_explicitly_on_macos` - `test_npm_publish_jobs_do_not_download_dist_artifact` All 9 release-workflow tests pass. `make ci-precheck` PASSED.
2026-05-03 16:23:21 -07:00
fix(build): enable Intel macOS pip installs via ort-load-dynamic (#1538) ## Description Fix `pip install headroom-ai` on Intel Mac (`x86_64-apple-darwin`). Source installs failed because `ort-sys 2.0.0-rc.12` (transitive via `fastembed`) does not ship prebuilt ONNX Runtime binaries for that target, causing maturin/cargo to exit during the wheel build. This PR mirrors the existing Windows fix: build the Rust core with `ort-load-dynamic`, pin `ORT_DYLIB_PATH` to the pip `onnxruntime` native library at import time, and publish Intel macOS wheels from CI. Closes # ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [x] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `crates/headroom-core/Cargo.toml`: use `ort-load-dynamic` for `x86_64-apple-darwin` instead of `ort-download-binaries-rustls-tls`. - `headroom/_ort.py`: extend the ORT dylib pin hook to Intel macOS (`darwin` + `x86_64`), resolving `libonnxruntime*.dylib` from the pip `onnxruntime` package. - `.github/workflows/release.yml` and `.github/workflows/rust.yml`: add `macos-15-intel` / `x86_64-apple-darwin` wheel matrix entries. - `tests/test_release_workflows.py` and `tests/test_transforms/test_ort_dylib.py`: update/add coverage for the new target and dylib pin behavior. - `README.md`: note that prebuilt wheels are published for Intel macOS. ## Testing - [x] Unit tests pass (`pytest`) - [ ] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ pytest tests/test_transforms/test_ort_dylib.py \ tests/test_release_workflows.py::test_fastembed_uses_dynamic_ort_on_windows \ tests/test_release_workflows.py::test_build_wheels_matrix_includes_intel_macos_with_dynamic_ort -q .......................... [100%] 10 passed in 0.18s $ maturin build --release -o /tmp/headroom-dist 📦 Built wheel for abi3 Python ≥ 3.10 to /tmp/headroom-dist/headroom_ai-0.27.0-cp310-abi3-macosx_10_12_x86_64.whl $ python3.11 -m venv /tmp/hr-venv && /tmp/hr-venv/bin/pip install . Successfully built headroom-ai Successfully installed headroom-ai-0.27.0 $ cd /tmp && /tmp/hr-venv/bin/python -c "import headroom; import headroom._core; print('ok')" version 0.27.0 _core ok ``` ## Real Behavior Proof - Environment: macOS `x86_64-apple-darwin`, Python 3.11.5, Rust 1.95.0 - Exact command / steps: Reproduced the reported failure with `pip install headroom-ai` (sdist build dies in `ort-sys` for `x86_64-apple-darwin`); after this patch ran `maturin build --release`, then `pip install .` in a clean venv, then `python -c "import headroom._core"`. - Observed result: Before fix, cargo/maturin exit 101 on missing ORT prebuilts; after fix, wheel build succeeds and `headroom._core` imports cleanly (`version 0.27.0`, `_core ok`). - Not tested: `macos-15-intel` GitHub Actions wheel matrix row (will be validated by CI after merge). ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes - ML features (magika detection, fastembed embeddings) still require `onnxruntime` at runtime on Intel Mac. Users should install `headroom-ai[proxy]` or `pip install onnxruntime`; `_ort.py` auto-pins `ORT_DYLIB_PATH` when that package is present. - Apple Silicon (`aarch64-apple-darwin`) behavior is unchanged: it continues to bundle ORT via `ort-download-binaries-rustls-tls`. - Lint/mypy not re-run locally in this pass; targeted pytest + maturin/pip install proof covers the changed surface. --------- Co-authored-by: Bor <you@example.com> Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 19:33:34 -04:00
def test_build_wheels_matrix_includes_intel_macos_with_dynamic_ort() -> None:
"""Intel macOS wheels use `ort-load-dynamic` because `ort-sys 2.0.0-rc.12`
fix(ci): vendor OpenSSL via cargo + drop x86_64 macOS from wheel matrix The previous hot-fix (#363) addressed npm artifact downloads and added openssl-devel installs in the manylinux container, but the wheel build still fails on three of four matrix entries with three distinct errors: 1. ubuntu-x86_64 with `manylinux: auto` resolved to manylinux2014 (CentOS 7 / OpenSSL 1.0.2k). `openssl-sys 0.9` requires OpenSSL 1.1.0+ — "different version of OpenSSL was found". 2. ubuntu-aarch64 cross-compiles via `aarch64-unknown-linux-gnu-gcc` from an x86_64 manylinux container. The `yum install openssl-devel` we added installs x86_64 headers; `/usr/aarch64-unknown-linux-gnu/ include/` has no OpenSSL — "openssl/opensslv.h: No such file or directory". 3. macos-15-intel fails on `ort-sys` (transitive via the ML compression backend), which has no prebuilt ONNX Runtime binaries for `x86_64-apple-darwin`. Unrelated to OpenSSL; an upstream limitation. Why the workspace pulls openssl-sys at all: `hf-hub` (transitive via `fastembed`) hard-codes `native-tls` as a default feature. Cargo's feature unification then enables openssl-sys for the whole workspace despite our `reqwest`/`tokio-tungstenite`/`tokio-rustls` preferences. # Fix 1: vendored OpenSSL Add `openssl = { version = "0.10", features = ["vendored"] }` to `crates/headroom-proxy/Cargo.toml`. The `vendored` feature compiles OpenSSL from source as part of the cargo build — works on every target uniformly. Local build verified: cargo now pulls `openssl-src v300.6.0+3.6.2` and compiles it. ~30s extra one-time build cost. The `openssl/vendored` feature DEFEATS `OPENSSL_DIR`. We therefore remove the previous hot-fix's "Install OpenSSL (macOS)" step that exported `OPENSSL_DIR` — leaving it would silently regress to the system-OpenSSL path that broke originally. # Fix 2: pin manylinux floor to 2_28 Change x86_64-unknown-linux-gnu from `manylinux: auto` to `manylinux: 2_28` (matching aarch64 + the e2e Dockerfiles). This isn't strictly required with vendored OpenSSL — the floor is now glibc 2.28 / AlmaLinux 8 which has modern toolchain — but it removes the CentOS-7 surface entirely and matches our runtime container target. # Fix 3: drop x86_64-apple-darwin from the matrix `ort-sys 2.0.0-rc.12` has no prebuilt ONNX Runtime binaries for that target. Building ORT from source would add CMake + ~5 minutes per build. Apple Silicon macOS (`aarch64-apple-darwin`) is fully covered; Intel-mac users install from the platform-independent sdist this matrix also produces. Tracked as a follow-up: switch the ML backend to `ort-tract` or upstream a request for x86_64 macOS prebuilts. # before-script-linux: keep perl-IPC-Cmd, drop openssl-devel OpenSSL's vendored `Configure` script needs `IPC::Cmd` (without it the build fails with "Can't locate IPC/Cmd.pm"). System openssl-devel is no longer needed. # Tests 4 new regression tests gate this: - `test_headroom_proxy_vendors_openssl` - `test_build_wheels_installs_perl_ipc_cmd_for_vendored_openssl` - `test_build_wheels_does_not_set_openssl_dir` - `test_build_wheels_matrix_excludes_intel_macos` Plus the previous 7. All 11 release-workflow tests pass. `make ci-precheck` PASSED. Local `cargo build --release -p headroom-py` green.
2026-05-03 17:41:24 -07:00
has no prebuilt ONNX Runtime binaries for `x86_64-apple-darwin`.
We assert against the actual matrix entry shape (`target: <triple>`
fix(build): enable Intel macOS pip installs via ort-load-dynamic (#1538) ## Description Fix `pip install headroom-ai` on Intel Mac (`x86_64-apple-darwin`). Source installs failed because `ort-sys 2.0.0-rc.12` (transitive via `fastembed`) does not ship prebuilt ONNX Runtime binaries for that target, causing maturin/cargo to exit during the wheel build. This PR mirrors the existing Windows fix: build the Rust core with `ort-load-dynamic`, pin `ORT_DYLIB_PATH` to the pip `onnxruntime` native library at import time, and publish Intel macOS wheels from CI. Closes # ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [x] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `crates/headroom-core/Cargo.toml`: use `ort-load-dynamic` for `x86_64-apple-darwin` instead of `ort-download-binaries-rustls-tls`. - `headroom/_ort.py`: extend the ORT dylib pin hook to Intel macOS (`darwin` + `x86_64`), resolving `libonnxruntime*.dylib` from the pip `onnxruntime` package. - `.github/workflows/release.yml` and `.github/workflows/rust.yml`: add `macos-15-intel` / `x86_64-apple-darwin` wheel matrix entries. - `tests/test_release_workflows.py` and `tests/test_transforms/test_ort_dylib.py`: update/add coverage for the new target and dylib pin behavior. - `README.md`: note that prebuilt wheels are published for Intel macOS. ## Testing - [x] Unit tests pass (`pytest`) - [ ] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ pytest tests/test_transforms/test_ort_dylib.py \ tests/test_release_workflows.py::test_fastembed_uses_dynamic_ort_on_windows \ tests/test_release_workflows.py::test_build_wheels_matrix_includes_intel_macos_with_dynamic_ort -q .......................... [100%] 10 passed in 0.18s $ maturin build --release -o /tmp/headroom-dist 📦 Built wheel for abi3 Python ≥ 3.10 to /tmp/headroom-dist/headroom_ai-0.27.0-cp310-abi3-macosx_10_12_x86_64.whl $ python3.11 -m venv /tmp/hr-venv && /tmp/hr-venv/bin/pip install . Successfully built headroom-ai Successfully installed headroom-ai-0.27.0 $ cd /tmp && /tmp/hr-venv/bin/python -c "import headroom; import headroom._core; print('ok')" version 0.27.0 _core ok ``` ## Real Behavior Proof - Environment: macOS `x86_64-apple-darwin`, Python 3.11.5, Rust 1.95.0 - Exact command / steps: Reproduced the reported failure with `pip install headroom-ai` (sdist build dies in `ort-sys` for `x86_64-apple-darwin`); after this patch ran `maturin build --release`, then `pip install .` in a clean venv, then `python -c "import headroom._core"`. - Observed result: Before fix, cargo/maturin exit 101 on missing ORT prebuilts; after fix, wheel build succeeds and `headroom._core` imports cleanly (`version 0.27.0`, `_core ok`). - Not tested: `macos-15-intel` GitHub Actions wheel matrix row (will be validated by CI after merge). ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes - ML features (magika detection, fastembed embeddings) still require `onnxruntime` at runtime on Intel Mac. Users should install `headroom-ai[proxy]` or `pip install onnxruntime`; `_ort.py` auto-pins `ORT_DYLIB_PATH` when that package is present. - Apple Silicon (`aarch64-apple-darwin`) behavior is unchanged: it continues to bundle ORT via `ort-download-binaries-rustls-tls`. - Lint/mypy not re-run locally in this pass; targeted pytest + maturin/pip install proof covers the changed surface. --------- Co-authored-by: Bor <you@example.com> Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 19:33:34 -04:00
on a non-comment line) so explanatory comments mentioning other
triples don't false-positive.
fix(ci): unbreak release pipeline — wheel openssl + dead npm artifact downloads Three independent failures on the post-merge release run for PR #360, all introduced by the single-wheel maturin refactor: 1. Linux wheel matrix (`ubuntu-x86_64`, `aarch64-unknown-linux-gnu`) failed inside the manylinux container with: Could not find openssl via pkg-config The system library `openssl` required by crate `openssl-sys` was not found. `openssl-sys` is transitive via `fastembed` → `hf-hub` → `ureq` → `native-tls`. The e2e Dockerfiles (`e2e/wrap`, `e2e/init`) we wrote for #360 install `openssl-devel` upfront, but the `build-wheels` matrix uses `PyO3/maturin-action@v1` which spins up its OWN manylinux container that does not inherit those installs. Fix: add a `before-script-linux:` to the action with a yum/apt-get conditional so it works on RHEL-family (manylinux2014, manylinux_2_28) and Debian-family musllinux variants. 2. macOS x86_64 wheel build failed with `maturin` exit 1 from the same `openssl-sys` lookup. The aarch64 macos-14 runner happens to have `/opt/homebrew/opt/openssl@3` on `openssl-sys`'s default discovery path; the Intel macos-15-intel runner uses `/usr/local/Cellar` which is NOT on that path. Fix: add a pre-maturin step that runs `brew install openssl@3` and exports `OPENSSL_DIR` / `OPENSSL_LIB_DIR` / `OPENSSL_INCLUDE_DIR` / `PKG_CONFIG_PATH`. The aarch64 runner happily picks up the explicit env vars too — no regression. 3. `publish-npm` and `publish-github-packages` both fail with "Artifact not found for name: dist". Both jobs `npm pack` + `npm publish` directly from the checked-out source tree — they never consume the Python `dist` artifact. The `Download dist artifact` step was vestigial dead code carried over from a prior workflow shape; the only reason it didn't fail before #360 is that the pre-refactor `build` job DID upload a `dist` artifact. Post-#360, `dist` is produced by `collect-dist` and neither publish job is gated on it (by design — npm vs PyPI ecosystems publish independently). Fix: remove the dead download step from both jobs. Loose coupling is preserved; `create-release` still gates the GitHub Release tag on all of build / build-wheels / collect-dist / publish-* succeeding. Why the PR-level CI didn't catch any of this: `release.yml` only runs on `push: branches: [main]`. PR #360's CI exercised `ci.yml` which has a separate `ci-build-wheels-on-pr` matrix that uses a different setup. The release surface only fires post-merge. Tests added (regression gates): - `test_build_wheels_installs_openssl_devel_on_linux_via_before_script` - `test_build_wheels_resolves_openssl_dir_explicitly_on_macos` - `test_npm_publish_jobs_do_not_download_dist_artifact` All 9 release-workflow tests pass. `make ci-precheck` PASSED.
2026-05-03 16:23:21 -07:00
"""
content = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
fix(ci): vendor OpenSSL via cargo + drop x86_64 macOS from wheel matrix The previous hot-fix (#363) addressed npm artifact downloads and added openssl-devel installs in the manylinux container, but the wheel build still fails on three of four matrix entries with three distinct errors: 1. ubuntu-x86_64 with `manylinux: auto` resolved to manylinux2014 (CentOS 7 / OpenSSL 1.0.2k). `openssl-sys 0.9` requires OpenSSL 1.1.0+ — "different version of OpenSSL was found". 2. ubuntu-aarch64 cross-compiles via `aarch64-unknown-linux-gnu-gcc` from an x86_64 manylinux container. The `yum install openssl-devel` we added installs x86_64 headers; `/usr/aarch64-unknown-linux-gnu/ include/` has no OpenSSL — "openssl/opensslv.h: No such file or directory". 3. macos-15-intel fails on `ort-sys` (transitive via the ML compression backend), which has no prebuilt ONNX Runtime binaries for `x86_64-apple-darwin`. Unrelated to OpenSSL; an upstream limitation. Why the workspace pulls openssl-sys at all: `hf-hub` (transitive via `fastembed`) hard-codes `native-tls` as a default feature. Cargo's feature unification then enables openssl-sys for the whole workspace despite our `reqwest`/`tokio-tungstenite`/`tokio-rustls` preferences. # Fix 1: vendored OpenSSL Add `openssl = { version = "0.10", features = ["vendored"] }` to `crates/headroom-proxy/Cargo.toml`. The `vendored` feature compiles OpenSSL from source as part of the cargo build — works on every target uniformly. Local build verified: cargo now pulls `openssl-src v300.6.0+3.6.2` and compiles it. ~30s extra one-time build cost. The `openssl/vendored` feature DEFEATS `OPENSSL_DIR`. We therefore remove the previous hot-fix's "Install OpenSSL (macOS)" step that exported `OPENSSL_DIR` — leaving it would silently regress to the system-OpenSSL path that broke originally. # Fix 2: pin manylinux floor to 2_28 Change x86_64-unknown-linux-gnu from `manylinux: auto` to `manylinux: 2_28` (matching aarch64 + the e2e Dockerfiles). This isn't strictly required with vendored OpenSSL — the floor is now glibc 2.28 / AlmaLinux 8 which has modern toolchain — but it removes the CentOS-7 surface entirely and matches our runtime container target. # Fix 3: drop x86_64-apple-darwin from the matrix `ort-sys 2.0.0-rc.12` has no prebuilt ONNX Runtime binaries for that target. Building ORT from source would add CMake + ~5 minutes per build. Apple Silicon macOS (`aarch64-apple-darwin`) is fully covered; Intel-mac users install from the platform-independent sdist this matrix also produces. Tracked as a follow-up: switch the ML backend to `ort-tract` or upstream a request for x86_64 macOS prebuilts. # before-script-linux: keep perl-IPC-Cmd, drop openssl-devel OpenSSL's vendored `Configure` script needs `IPC::Cmd` (without it the build fails with "Can't locate IPC/Cmd.pm"). System openssl-devel is no longer needed. # Tests 4 new regression tests gate this: - `test_headroom_proxy_vendors_openssl` - `test_build_wheels_installs_perl_ipc_cmd_for_vendored_openssl` - `test_build_wheels_does_not_set_openssl_dir` - `test_build_wheels_matrix_excludes_intel_macos` Plus the previous 7. All 11 release-workflow tests pass. `make ci-precheck` PASSED. Local `cargo build --release -p headroom-py` green.
2026-05-03 17:41:24 -07:00
bw_start = content.index("\n build-wheels:")
bw_end = content.index("\n collect-dist:")
body = content[bw_start:bw_end]
matrix_targets: list[str] = []
for raw in body.splitlines():
stripped = raw.lstrip()
# Skip YAML comments — only look at real matrix-entry lines.
if stripped.startswith("#"):
continue
if stripped.startswith("target:"):
# `target: x86_64-apple-darwin` → `x86_64-apple-darwin`
matrix_targets.append(stripped.split(":", 1)[1].strip())
assert "aarch64-apple-darwin" in matrix_targets, "Apple Silicon must stay in the matrix"
assert "x86_64-unknown-linux-gnu" in matrix_targets
assert "aarch64-unknown-linux-gnu" in matrix_targets
fix(build): enable Intel macOS pip installs via ort-load-dynamic (#1538) ## Description Fix `pip install headroom-ai` on Intel Mac (`x86_64-apple-darwin`). Source installs failed because `ort-sys 2.0.0-rc.12` (transitive via `fastembed`) does not ship prebuilt ONNX Runtime binaries for that target, causing maturin/cargo to exit during the wheel build. This PR mirrors the existing Windows fix: build the Rust core with `ort-load-dynamic`, pin `ORT_DYLIB_PATH` to the pip `onnxruntime` native library at import time, and publish Intel macOS wheels from CI. Closes # ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [x] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `crates/headroom-core/Cargo.toml`: use `ort-load-dynamic` for `x86_64-apple-darwin` instead of `ort-download-binaries-rustls-tls`. - `headroom/_ort.py`: extend the ORT dylib pin hook to Intel macOS (`darwin` + `x86_64`), resolving `libonnxruntime*.dylib` from the pip `onnxruntime` package. - `.github/workflows/release.yml` and `.github/workflows/rust.yml`: add `macos-15-intel` / `x86_64-apple-darwin` wheel matrix entries. - `tests/test_release_workflows.py` and `tests/test_transforms/test_ort_dylib.py`: update/add coverage for the new target and dylib pin behavior. - `README.md`: note that prebuilt wheels are published for Intel macOS. ## Testing - [x] Unit tests pass (`pytest`) - [ ] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ pytest tests/test_transforms/test_ort_dylib.py \ tests/test_release_workflows.py::test_fastembed_uses_dynamic_ort_on_windows \ tests/test_release_workflows.py::test_build_wheels_matrix_includes_intel_macos_with_dynamic_ort -q .......................... [100%] 10 passed in 0.18s $ maturin build --release -o /tmp/headroom-dist 📦 Built wheel for abi3 Python ≥ 3.10 to /tmp/headroom-dist/headroom_ai-0.27.0-cp310-abi3-macosx_10_12_x86_64.whl $ python3.11 -m venv /tmp/hr-venv && /tmp/hr-venv/bin/pip install . Successfully built headroom-ai Successfully installed headroom-ai-0.27.0 $ cd /tmp && /tmp/hr-venv/bin/python -c "import headroom; import headroom._core; print('ok')" version 0.27.0 _core ok ``` ## Real Behavior Proof - Environment: macOS `x86_64-apple-darwin`, Python 3.11.5, Rust 1.95.0 - Exact command / steps: Reproduced the reported failure with `pip install headroom-ai` (sdist build dies in `ort-sys` for `x86_64-apple-darwin`); after this patch ran `maturin build --release`, then `pip install .` in a clean venv, then `python -c "import headroom._core"`. - Observed result: Before fix, cargo/maturin exit 101 on missing ORT prebuilts; after fix, wheel build succeeds and `headroom._core` imports cleanly (`version 0.27.0`, `_core ok`). - Not tested: `macos-15-intel` GitHub Actions wheel matrix row (will be validated by CI after merge). ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes - ML features (magika detection, fastembed embeddings) still require `onnxruntime` at runtime on Intel Mac. Users should install `headroom-ai[proxy]` or `pip install onnxruntime`; `_ort.py` auto-pins `ORT_DYLIB_PATH` when that package is present. - Apple Silicon (`aarch64-apple-darwin`) behavior is unchanged: it continues to bundle ORT via `ort-download-binaries-rustls-tls`. - Lint/mypy not re-run locally in this pass; targeted pytest + maturin/pip install proof covers the changed surface. --------- Co-authored-by: Bor <you@example.com> Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 19:33:34 -04:00
assert "x86_64-apple-darwin" in matrix_targets, (
f"x86_64-apple-darwin must be a wheel-matrix target; got {matrix_targets}"
fix(ci): vendor OpenSSL via cargo + drop x86_64 macOS from wheel matrix The previous hot-fix (#363) addressed npm artifact downloads and added openssl-devel installs in the manylinux container, but the wheel build still fails on three of four matrix entries with three distinct errors: 1. ubuntu-x86_64 with `manylinux: auto` resolved to manylinux2014 (CentOS 7 / OpenSSL 1.0.2k). `openssl-sys 0.9` requires OpenSSL 1.1.0+ — "different version of OpenSSL was found". 2. ubuntu-aarch64 cross-compiles via `aarch64-unknown-linux-gnu-gcc` from an x86_64 manylinux container. The `yum install openssl-devel` we added installs x86_64 headers; `/usr/aarch64-unknown-linux-gnu/ include/` has no OpenSSL — "openssl/opensslv.h: No such file or directory". 3. macos-15-intel fails on `ort-sys` (transitive via the ML compression backend), which has no prebuilt ONNX Runtime binaries for `x86_64-apple-darwin`. Unrelated to OpenSSL; an upstream limitation. Why the workspace pulls openssl-sys at all: `hf-hub` (transitive via `fastembed`) hard-codes `native-tls` as a default feature. Cargo's feature unification then enables openssl-sys for the whole workspace despite our `reqwest`/`tokio-tungstenite`/`tokio-rustls` preferences. # Fix 1: vendored OpenSSL Add `openssl = { version = "0.10", features = ["vendored"] }` to `crates/headroom-proxy/Cargo.toml`. The `vendored` feature compiles OpenSSL from source as part of the cargo build — works on every target uniformly. Local build verified: cargo now pulls `openssl-src v300.6.0+3.6.2` and compiles it. ~30s extra one-time build cost. The `openssl/vendored` feature DEFEATS `OPENSSL_DIR`. We therefore remove the previous hot-fix's "Install OpenSSL (macOS)" step that exported `OPENSSL_DIR` — leaving it would silently regress to the system-OpenSSL path that broke originally. # Fix 2: pin manylinux floor to 2_28 Change x86_64-unknown-linux-gnu from `manylinux: auto` to `manylinux: 2_28` (matching aarch64 + the e2e Dockerfiles). This isn't strictly required with vendored OpenSSL — the floor is now glibc 2.28 / AlmaLinux 8 which has modern toolchain — but it removes the CentOS-7 surface entirely and matches our runtime container target. # Fix 3: drop x86_64-apple-darwin from the matrix `ort-sys 2.0.0-rc.12` has no prebuilt ONNX Runtime binaries for that target. Building ORT from source would add CMake + ~5 minutes per build. Apple Silicon macOS (`aarch64-apple-darwin`) is fully covered; Intel-mac users install from the platform-independent sdist this matrix also produces. Tracked as a follow-up: switch the ML backend to `ort-tract` or upstream a request for x86_64 macOS prebuilts. # before-script-linux: keep perl-IPC-Cmd, drop openssl-devel OpenSSL's vendored `Configure` script needs `IPC::Cmd` (without it the build fails with "Can't locate IPC/Cmd.pm"). System openssl-devel is no longer needed. # Tests 4 new regression tests gate this: - `test_headroom_proxy_vendors_openssl` - `test_build_wheels_installs_perl_ipc_cmd_for_vendored_openssl` - `test_build_wheels_does_not_set_openssl_dir` - `test_build_wheels_matrix_excludes_intel_macos` Plus the previous 7. All 11 release-workflow tests pass. `make ci-precheck` PASSED. Local `cargo build --release -p headroom-py` green.
2026-05-03 17:41:24 -07:00
)
matrix_os: list[str] = []
for raw in body.splitlines():
stripped = raw.lstrip()
if stripped.startswith("#"):
continue
if stripped.startswith("os:"):
matrix_os.append(stripped.split(":", 1)[1].strip())
fix(build): enable Intel macOS pip installs via ort-load-dynamic (#1538) ## Description Fix `pip install headroom-ai` on Intel Mac (`x86_64-apple-darwin`). Source installs failed because `ort-sys 2.0.0-rc.12` (transitive via `fastembed`) does not ship prebuilt ONNX Runtime binaries for that target, causing maturin/cargo to exit during the wheel build. This PR mirrors the existing Windows fix: build the Rust core with `ort-load-dynamic`, pin `ORT_DYLIB_PATH` to the pip `onnxruntime` native library at import time, and publish Intel macOS wheels from CI. Closes # ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [x] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `crates/headroom-core/Cargo.toml`: use `ort-load-dynamic` for `x86_64-apple-darwin` instead of `ort-download-binaries-rustls-tls`. - `headroom/_ort.py`: extend the ORT dylib pin hook to Intel macOS (`darwin` + `x86_64`), resolving `libonnxruntime*.dylib` from the pip `onnxruntime` package. - `.github/workflows/release.yml` and `.github/workflows/rust.yml`: add `macos-15-intel` / `x86_64-apple-darwin` wheel matrix entries. - `tests/test_release_workflows.py` and `tests/test_transforms/test_ort_dylib.py`: update/add coverage for the new target and dylib pin behavior. - `README.md`: note that prebuilt wheels are published for Intel macOS. ## Testing - [x] Unit tests pass (`pytest`) - [ ] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ pytest tests/test_transforms/test_ort_dylib.py \ tests/test_release_workflows.py::test_fastembed_uses_dynamic_ort_on_windows \ tests/test_release_workflows.py::test_build_wheels_matrix_includes_intel_macos_with_dynamic_ort -q .......................... [100%] 10 passed in 0.18s $ maturin build --release -o /tmp/headroom-dist 📦 Built wheel for abi3 Python ≥ 3.10 to /tmp/headroom-dist/headroom_ai-0.27.0-cp310-abi3-macosx_10_12_x86_64.whl $ python3.11 -m venv /tmp/hr-venv && /tmp/hr-venv/bin/pip install . Successfully built headroom-ai Successfully installed headroom-ai-0.27.0 $ cd /tmp && /tmp/hr-venv/bin/python -c "import headroom; import headroom._core; print('ok')" version 0.27.0 _core ok ``` ## Real Behavior Proof - Environment: macOS `x86_64-apple-darwin`, Python 3.11.5, Rust 1.95.0 - Exact command / steps: Reproduced the reported failure with `pip install headroom-ai` (sdist build dies in `ort-sys` for `x86_64-apple-darwin`); after this patch ran `maturin build --release`, then `pip install .` in a clean venv, then `python -c "import headroom._core"`. - Observed result: Before fix, cargo/maturin exit 101 on missing ORT prebuilts; after fix, wheel build succeeds and `headroom._core` imports cleanly (`version 0.27.0`, `_core ok`). - Not tested: `macos-15-intel` GitHub Actions wheel matrix row (will be validated by CI after merge). ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes - ML features (magika detection, fastembed embeddings) still require `onnxruntime` at runtime on Intel Mac. Users should install `headroom-ai[proxy]` or `pip install onnxruntime`; `_ort.py` auto-pins `ORT_DYLIB_PATH` when that package is present. - Apple Silicon (`aarch64-apple-darwin`) behavior is unchanged: it continues to bundle ORT via `ort-download-binaries-rustls-tls`. - Lint/mypy not re-run locally in this pass; targeted pytest + maturin/pip install proof covers the changed surface. --------- Co-authored-by: Bor <you@example.com> Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 19:33:34 -04:00
elif stripped.startswith("- os:"):
matrix_os.append(stripped.split(":", 1)[1].strip())
assert "macos-15-intel" in matrix_os
def test_smoke_import_macos_selects_wheel_arch_from_target() -> None:
"""The macOS smoke-import step must pick the wheel tag from the matrix
target (arm64 for Apple Silicon, x86_64 for Intel) instead of
hardcoding `_arm64` for every macOS row."""
content = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
step_start = content.index("- name: Smoke-import wheel on macOS host")
step_end = content.index("- name: Smoke-import wheel on Windows host", step_start)
macos_block = content[step_start:step_end]
assert "WHEEL_TARGET: ${{ matrix.wheel_target }}" in macos_block
assert "aarch64-apple-darwin) mac_arch=arm64" in macos_block
assert "x86_64-apple-darwin) mac_arch=x86_64" in macos_block
assert "macosx_*_${mac_arch}.whl" in macos_block
assert "headroom_ai-*-${py_tag}-${py_tag}-macosx_*_arm64.whl" not in macos_block
assert "headroom_ai-*-abi3-macosx_*_arm64.whl" not in macos_block
fix(ci): unbreak release pipeline — wheel openssl + dead npm artifact downloads Three independent failures on the post-merge release run for PR #360, all introduced by the single-wheel maturin refactor: 1. Linux wheel matrix (`ubuntu-x86_64`, `aarch64-unknown-linux-gnu`) failed inside the manylinux container with: Could not find openssl via pkg-config The system library `openssl` required by crate `openssl-sys` was not found. `openssl-sys` is transitive via `fastembed` → `hf-hub` → `ureq` → `native-tls`. The e2e Dockerfiles (`e2e/wrap`, `e2e/init`) we wrote for #360 install `openssl-devel` upfront, but the `build-wheels` matrix uses `PyO3/maturin-action@v1` which spins up its OWN manylinux container that does not inherit those installs. Fix: add a `before-script-linux:` to the action with a yum/apt-get conditional so it works on RHEL-family (manylinux2014, manylinux_2_28) and Debian-family musllinux variants. 2. macOS x86_64 wheel build failed with `maturin` exit 1 from the same `openssl-sys` lookup. The aarch64 macos-14 runner happens to have `/opt/homebrew/opt/openssl@3` on `openssl-sys`'s default discovery path; the Intel macos-15-intel runner uses `/usr/local/Cellar` which is NOT on that path. Fix: add a pre-maturin step that runs `brew install openssl@3` and exports `OPENSSL_DIR` / `OPENSSL_LIB_DIR` / `OPENSSL_INCLUDE_DIR` / `PKG_CONFIG_PATH`. The aarch64 runner happily picks up the explicit env vars too — no regression. 3. `publish-npm` and `publish-github-packages` both fail with "Artifact not found for name: dist". Both jobs `npm pack` + `npm publish` directly from the checked-out source tree — they never consume the Python `dist` artifact. The `Download dist artifact` step was vestigial dead code carried over from a prior workflow shape; the only reason it didn't fail before #360 is that the pre-refactor `build` job DID upload a `dist` artifact. Post-#360, `dist` is produced by `collect-dist` and neither publish job is gated on it (by design — npm vs PyPI ecosystems publish independently). Fix: remove the dead download step from both jobs. Loose coupling is preserved; `create-release` still gates the GitHub Release tag on all of build / build-wheels / collect-dist / publish-* succeeding. Why the PR-level CI didn't catch any of this: `release.yml` only runs on `push: branches: [main]`. PR #360's CI exercised `ci.yml` which has a separate `ci-build-wheels-on-pr` matrix that uses a different setup. The release surface only fires post-merge. Tests added (regression gates): - `test_build_wheels_installs_openssl_devel_on_linux_via_before_script` - `test_build_wheels_resolves_openssl_dir_explicitly_on_macos` - `test_npm_publish_jobs_do_not_download_dist_artifact` All 9 release-workflow tests pass. `make ci-precheck` PASSED.
2026-05-03 16:23:21 -07:00
ci: native arm64 runners — drop QEMU, cut wheel + docker build time GitHub-hosted Linux arm64 runners (`ubuntu-24.04-arm`) went GA in Aug 2025 and are free for public repositories. Switching the aarch64 wheel + the multi-arch docker matrix off `ubuntu-latest`+QEMU onto the native runner cuts wall-clock on both surfaces. release.yml — build-wheels matrix * `aarch64-unknown-linux-gnu`: `ubuntu-latest` → `ubuntu-24.04-arm`. maturin-action still runs inside `quay.io/pypa/manylinux_2_28_aarch64`, but the container now executes natively on an aarch64 kernel instead of through QEMU emulation. Aarch64 wheel build drops from ~50–60 min to ~10 min. * `x86_64-unknown-linux-gnu`: `ubuntu-latest` → `ubuntu-24.04` (pin the moving alias for reproducibility; no semantic change). docker.yml — fan-out + manifest merge * Pre-#377: one `docker-variant-tags` matrix job per variant on `ubuntu-latest`, using bake's `platforms = [amd64, arm64]` with QEMU for the arm64 leg. ~1h per variant, 8 variants. * Post-#377: split into `docker-build` (variant × arch = 16 parallel jobs, each on its native runner, single-platform push-by-digest) and `docker-manifest` (per variant, merges the two arch digests into a multi-arch tagged manifest with `docker buildx imagetools create`, signs the index manifest with cosign). Wall-clock drops from ~1h per variant to ~10 min. * `docker/setup-qemu-action` removed — there's no QEMU left. * Per-(variant, arch) GHA cache scopes so the two arches don't collide on cache keys. * `promote-latest` rewired to depend on `docker-manifest`. Behavior change: cosign now signs only the multi-arch index digest per variant, not each per-platform image. `cosign verify <repo>:tag` (the typical flow) is unchanged because cosign resolves the tag to the index digest. Verifiers pinning a specific per-arch digest will need to verify the index digest instead. Regression tests in tests/test_release_workflows.py: * `test_aarch64_wheel_uses_native_arm64_runner` — pins the aarch64 row to `ubuntu-24.04-arm` (and the amd64 row to `ubuntu-24.04`, not `-latest`), so a future "let me unify on ubuntu-latest" refactor surfaces the QEMU regression at PR time. * `test_docker_workflow_builds_on_native_arch_runners` — pins the fan-out matrix's arch entries, asserts push-by-digest, asserts `setup-qemu-action` is absent from non-comment lines, asserts the manifest-merge job exists. Verified: * Both workflow files parse as valid YAML with the expected job graph (`docker-build` → `docker-manifest` → `promote-latest`, 16 fan-out jobs, 8 manifest jobs). * `docker buildx imagetools inspect <tag> --format '{{ json . }}'` exposes the index digest at `.manifest.digest` (confirmed via Docker's official reference). * `ubuntu-24.04-arm` is the correct GitHub-hosted runner label (GA 2025-08-07, free for public repos). * `make ci-precheck-rust` and `make ci-precheck-python` both pass locally; `tests/test_release_workflows.py` is 15/15 green (13 existing + 2 new).
2026-05-04 09:37:28 -07:00
def test_aarch64_wheel_uses_native_arm64_runner() -> None:
"""STRUCTURAL INVARIANT: the aarch64 wheel matrix row must run on a
native arm64 runner (`ubuntu-24.04-arm`), NOT a QEMU-emulated x64
runner (`ubuntu-latest`).
Pre-#377 we built the aarch64 wheel on `ubuntu-latest` (x86_64) inside
`manylinux_2_28_aarch64` via QEMU emulation, taking ~5060 min. Native
arm64 GitHub-hosted runners (GA Jan 2025, free for public repos) drop
QEMU and complete the same build in ~10 min.
A future "let me unify all wheel rows on `ubuntu-latest`" refactor
would silently re-introduce QEMU and slow CI back down this test
pins the runner.
"""
content = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
bw_start = content.index("\n build-wheels:")
bw_end = content.index("\n collect-dist:")
body = content[bw_start:bw_end]
# Walk the matrix.include rows. Each row is a contiguous block of
# `key: value` lines starting with `os:` (the first key in our
# convention). Pair `os:` with the immediately-following `target:`
# so we can assert per-row.
rows: list[dict[str, str]] = []
current: dict[str, str] = {}
for raw in body.splitlines():
stripped = raw.lstrip()
if stripped.startswith("#"):
continue
if stripped.startswith("- os:"):
if current:
rows.append(current)
current = {"os": stripped.split(":", 1)[1].strip()}
elif stripped.startswith("target:") and current:
current["target"] = stripped.split(":", 1)[1].strip()
elif stripped.startswith("manylinux:") and current:
current["manylinux"] = stripped.split(":", 1)[1].strip()
if current:
rows.append(current)
aarch64_linux = [r for r in rows if r.get("target") == "aarch64-unknown-linux-gnu"]
assert len(aarch64_linux) == 1, f"expected exactly one aarch64-linux row; got {aarch64_linux}"
assert aarch64_linux[0]["os"] == "ubuntu-24.04-arm", (
f"aarch64-unknown-linux-gnu must run on native arm64 runner "
f"`ubuntu-24.04-arm`, not {aarch64_linux[0]['os']!r}. Reverting "
f"to `ubuntu-latest` re-introduces QEMU emulation and ~6× slower "
f"wheel builds."
)
# The amd64 Linux row should also be pinned to ubuntu-24.04 (not
# `ubuntu-latest`, which is a moving target). Pinning keeps the
# wheel-build environment reproducible across runner image rolls.
amd64_linux = [r for r in rows if r.get("target") == "x86_64-unknown-linux-gnu"]
assert len(amd64_linux) == 1
assert amd64_linux[0]["os"] == "ubuntu-24.04", (
f"x86_64-unknown-linux-gnu should pin `ubuntu-24.04`, not "
f"{amd64_linux[0]['os']!r} — `ubuntu-latest` is a moving alias "
f"and reproducibility benefits from explicit pinning."
)
def test_docker_workflow_builds_on_native_arch_runners() -> None:
"""STRUCTURAL INVARIANT: the docker variant build must fan out per
arch onto native runners `linux/amd64` on `ubuntu-24.04`,
`linux/arm64` on `ubuntu-24.04-arm`. No QEMU.
Pre-#377 each variant ran `docker bake` with
`platforms = ["linux/amd64","linux/arm64"]` on a single x64 runner
using QEMU for arm64 emulation ~1h per variant. Splitting into
16 native single-arch builds (8 variants × 2 arches) + a manifest
merge job per variant cuts wall-clock to ~10 min and removes the
QEMU surface that contributed to transient build failures.
"""
content = (ROOT / ".github" / "workflows" / "docker.yml").read_text(encoding="utf-8")
# The fan-out job must exist with both runners in its arch matrix.
assert "docker-build:" in content, "docker-build fan-out job missing"
assert "runs_on: ubuntu-24.04, platform: linux/amd64" in content, (
"amd64 arch matrix entry must bind ubuntu-24.04 (native x86_64)"
)
assert "runs_on: ubuntu-24.04-arm, platform: linux/arm64" in content, (
"arm64 arch matrix entry must bind ubuntu-24.04-arm (native aarch64)"
)
# Per-arch builds must push by digest only — tags belong on the
# multi-arch manifest, applied later by docker-manifest.
assert "push-by-digest=true,name-canonical=true,push=true" in content, (
"per-arch builds must push by digest only; tags applied at manifest merge step"
)
# The QEMU action must NOT be invoked anywhere — its presence would
# mean someone re-introduced an emulated build path.
non_comment = "\n".join(
line for line in content.splitlines() if not line.lstrip().startswith("#")
)
assert "docker/setup-qemu-action" not in non_comment, (
"docker.yml must not invoke `docker/setup-qemu-action` — native "
"arm64 runners replaced QEMU. A new reference here means someone "
"re-emulated arm64 on an x64 runner."
)
# Manifest merge job must exist and depend on docker-build.
assert "docker-manifest:" in content
assert "needs: docker-build" in content
assert "docker buildx imagetools create" in content
def test_docker_per_arch_build_specifies_image_name_in_output() -> None:
"""STRUCTURAL INVARIANT: the per-arch bake's `*.output` spec must
include `name=<registry>/<image>` without it, buildx fails with
the misleading `ERROR: tag is needed when pushing to registry`.
Background: pre-#377 each docker variant ran with bake-file-tags
(multi-arch tagged push), which gave bake the registry/image name
via the tag strings. PR #376 split into per-arch fan-out and
correctly removed bake-file-tags from the per-arch step (tags
belong on the multi-arch manifest, not on per-arch images). But
that left bake without ANY reference for the push target no
tags AND no explicit `name=` in the output spec.
The first release after #376 merged failed every docker-build job
with "ERROR: tag is needed when pushing to registry". The fix is
to explicitly pass `name=<registry>/<image>` in the output spec
so bake knows the push target without needing tags.
A future refactor that removes the explicit name (e.g., "we
already have labels, surely buildx can figure it out") will
silently re-break this. This test pins it.
"""
content = (ROOT / ".github" / "workflows" / "docker.yml").read_text(encoding="utf-8")
# Find the per-arch build's *.output set line. Must contain
# `name=` with the registry+image-name expression.
output_line_present = (
"*.output=type=image,name=${{ env.REGISTRY }}/${{ steps.image-name.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true"
in content
)
assert output_line_present, (
"per-arch bake `*.output` must include `name=<registry>/<image>`. "
"Without it, buildx fails the push with 'tag is needed when pushing "
"to registry' because no tags AND no explicit name = no push target. "
"This is a regression of the docker-build break right after PR #376."
)
def test_sdist_build_conditional_keyed_on_target_not_os() -> None:
"""STRUCTURAL INVARIANT: the sdist build's `if` conditional must
key on `matrix.target`, not `matrix.os`.
Background: PR #376 changed the wheel matrix from `os: ubuntu-latest`
to `os: ubuntu-24.04` (explicit pinning, no semantic change in
practice). It silently broke the sdist build, whose `if` was
`matrix.os == 'ubuntu-latest' && matrix.target == 'x86_64-unknown-linux-gnu'`
the literal `'ubuntu-latest'` no longer matched. Sdist never
built, `release-assets/*.tar.gz` was empty, and the create-release
job failed `gh release upload release-assets/*.tar.gz` with
"no matches found".
The fix is to key the conditional on `matrix.target` only sdist
is platform-independent, so any single matrix row is a fine host.
`target` is more semantically meaningful than `os` here AND is
decoupled from any future host-runner rename.
This test pins the `target`-only conditional so a future "let's
add `os` back to the conditional for clarity" refactor will fail
at PR time, not 8 minutes into a release.
"""
content = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
# Locate the "Build sdist" step.
sdist_marker = "name: Build sdist"
assert sdist_marker in content, "sdist build step missing from release.yml"
# Walk forward to the next `if:` line — that's the conditional.
sdist_idx = content.index(sdist_marker)
if_idx = content.index("if:", sdist_idx)
if_line_end = content.index("\n", if_idx)
if_line = content[if_idx:if_line_end]
# Must reference `matrix.target`. Must NOT reference `matrix.os`.
assert "matrix.target == 'x86_64-unknown-linux-gnu'" in if_line, (
f"sdist build conditional must check `matrix.target`; got: {if_line!r}"
)
assert "matrix.os" not in if_line, (
f"sdist build conditional must NOT depend on `matrix.os` — that's "
f"how PR #376 silently disabled the sdist build. Got: {if_line!r}"
)
def test_release_workflow_verifies_versions_before_build_outputs() -> None:
"""Release sync must be followed by an explicit cross-package version gate."""
content = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
assert "scripts/verify-versions.py" in content
assert "scripts/version-sync.py" in content
assert content.count("python scripts/verify-versions.py") >= 2
first_sync = content.index("python scripts/version-sync.py --version")
first_verify = content.index("python scripts/verify-versions.py", first_sync)
changelog = content.index("name: Run changelog generation", first_verify)
assert first_sync < first_verify < changelog
second_sync = content.index("python scripts/version-sync.py --version", first_verify)
second_verify = content.index("python scripts/verify-versions.py", second_sync)
build_wheels = content.index("name: Build wheels", second_verify)
assert second_sync < second_verify < build_wheels
def test_sdist_license_is_packaged_and_verified_before_upload() -> None:
"""STRUCTURAL INVARIANT: the sdist tarball must physically contain
every license file PEP 639 declares in PKG-INFO, and the release
workflow must verify that match before upload.
PyPI rejects sdists whose `License-File:` metadata entries
reference files missing from the tarball with `400 License-File X
does not exist in distribution file ...`. Maturin's PEP 639
auto-discovery emits both `LICENSE` and `NOTICE` into PKG-INFO
because both files exist at the project root and match the default
glob but maturin sdists don't get the package-directory
treatment wheels do, so each file must be explicitly listed in
`[tool.maturin].include` with `format = "sdist"`. Issue trail:
sdist publish broke at v0.20.16 (the hatch -> maturin migration
in 2a91cbb dropped NOTICE from the include list), masked for ~22
releases by an earlier twine `400 File already exists` failure on
duplicate wheels, surfaced once PR #412 added skip-existing.
"""
pyproject = (ROOT / "pyproject.toml").read_text(encoding="utf-8")
release_yml = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
assert '{ path = "LICENSE", format = "sdist" }' in pyproject, (
"pyproject.toml [tool.maturin].include must list LICENSE for sdist format"
)
assert '{ path = "NOTICE", format = "sdist" }' in pyproject, (
"pyproject.toml [tool.maturin].include must list NOTICE for sdist format. "
"Maturin's PEP 639 auto-discovery emits `License-File: NOTICE` into "
"PKG-INFO because NOTICE exists at the project root, so the file MUST "
"ship in the tarball or PyPI rejects the sdist with a 400."
)
assert "name: Verify sdist license-file metadata matches tarball contents" in release_yml, (
"release.yml must run the License-File / tarball-contents cross-check before publish"
)
assert 'if line.startswith("License-File:")' in release_yml, (
"release.yml verifier must parse PKG-INFO License-File entries — "
"not just a hardcoded LICENSE check — so any future PEP 639-discoverable "
"file (COPYING, AUTHORS, ...) is also gated."
)
assert "declares License-File entries that are missing from the tarball" in release_yml, (
"release.yml verifier must fail loudly when declared license files "
"are missing — silent passes would let the same regression resurface."
)
def test_pypi_publish_failure_blocks_github_release() -> None:
content = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
pypi_job_start = content.index("publish-pypi:")
npm_job_start = content.index("publish-npm:", pypi_job_start)
pypi_job = content[pypi_job_start:npm_job_start]
fix(deps): remediate dependency CVEs and publish SBOM (#1509) ## Description Supply-chain hardening: takes the **shipped** dependency surface from **26 known CVEs to 0**. `pip install headroom-ai[all]` now resolves with no known vulnerabilities (verified with Anchore syft + grype). Also publishes a checked-in SBOM package (`sbom/`) so any user — especially pilots running their own security review — can verify what's inside and that we track it. This addresses the Dependabot alerts on `main` (9 high / 4 moderate / 7 low at time of writing). Closes # ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [x] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made **Rust** - `pyo3` 0.24 → 0.29 (GHSA-36hh-v3qg-5jq4 High, GHSA-chgr-c6px-7xpp Med). Migrated `Python::allow_threads` → `Python::detach` (10 sites) and added `from_py_object` to the `Clone`-deriving `#[pyclass]` types (both required by the 0.25+ API). - `pyo3-log` 0.12 → 0.13; `lru` 0.12 → 0.18 (GHSA-rhfx-m35p-ff5j). **Python** - `torch` → 2.12.1, `mem0ai` → 2.x. - Floor-pinned transitive CVE deps via `[tool.uv] constraint-dependencies`: `pygments>=2.20.0`, `pydantic-settings>=2.14.2`, `gitpython>=3.1.50`, `langsmith>=0.9.0`. - **Removed `benchmark` from the `[all]` aggregate** so the default install is CVE-free. `lm-eval` is invoked as an external subprocess (`python -m lm_eval`) and never imported, so it is not a true runtime dep — it remains available via the opt-in `[benchmark]` extra. See [Accepted Risks](#additional-notes). **npm (build/test tooling — never shipped in the wheel/container/published SDK)** - `esbuild` override `>=0.28.1` in `sdk/typescript` + `plugins/openclaw` (GHSA-g7r4-m6w7-qqqr). - `docs/`: `@anthropic-ai/sdk` → `^0.106.0` (GHSA-p7fg-763f-g4gf), `postcss` override to force Next.js's bundled copy ≥8.5.10 (GHSA-qx2v-qp2m-jg93); regenerated a stale `bun.lock` that carried a **Critical** vitest/vite. **CI** - Pinned `pypa/gh-action-pypi-publish` `@release/v1` → `@v1.13.0` (GHSA-vxmw-7h4f-hqxh) in `release.yml` + `publish.yml`. **SBOM** - New `sbom/` directory: CycloneDX 1.7 + SPDX 2.3 SBOMs, grype scan evidence, 330-package license inventory, and a regeneration guide. ## Testing - [ ] Unit tests pass (`pytest`) — N/A, no Python source changed (deps/config only) - [x] Linting passes — `cargo fmt --check` + `cargo clippy` clean on the changed crate; 0 `.py` files changed so `ruff`/`mypy` scope is unaffected - [x] Type checking passes — `cargo check --workspace` (0 errors) - [ ] New tests added — N/A (dependency bumps; covered by existing suites) - [x] Manual testing performed — see Real Behavior Proof ### Test Output ```text # headroom-ai[all] product surface — the number that matters $ grype sbom:sbom/headroom-sbom-all-extra.cdx.json No vulnerabilities found # full repo scan (universal lock incl. opt-in [benchmark] + dev) $ grype sbom:sbom/headroom-sbom.cdx.json NAME INSTALLED TYPE VULNERABILITY SEVERITY sqlitedict 2.1.0 python GHSA-g4r7-86gm-pgqc High # [benchmark]-only, unpatchable, accepted nltk 3.9.4 python GHSA-p4gq-832x-fm9v High # [benchmark]-only, unpatchable, accepted # pyo3 0.29 migration — extension builds + imports + runs $ cargo check --workspace Finished `dev` profile [unoptimized + debuginfo] target(s) $ maturin develop && python -c "from headroom._core import DiffCompressor, SmartCrusher; ..." extension OK — detach + from_py_object paths exercised # lru 0.18 — eviction path $ cargo test -p headroom-proxy --lib drift 14 passed, 213 filtered out # per-ecosystem npm audits $ (cd sdk/typescript && npm audit) -> found 0 vulnerabilities $ (cd plugins/openclaw && npm audit) -> found 0 vulnerabilities $ (cd docs && npm audit && bun audit) -> found 0 vulnerabilities / No vulnerabilities found ``` ## Real Behavior Proof - Environment: macOS (darwin 25.4.0, arm64), Python 3.12 `.venv`, Rust 1.95 toolchain, syft 1.46.0, grype 0.115.0, bun 1.3.14, maturin 1.13.3. - Exact command / steps: (1) `uv export --extra all --no-dev --no-emit-project | syft → grype` for the product surface; (2) `cargo check --workspace` + `maturin develop` + extension import/compress smoke test; (3) `cargo test -p headroom-proxy --lib drift`; (4) `cargo fmt --check` + `cargo clippy -p headroom-py`; (5) `npm audit` in sdk/openclaw/docs + `bun audit` in docs. - Observed result: `headroom-ai[all]` resolution scans clean — "No vulnerabilities found" (179 pkgs); full/prod SBOM shows only the 2 documented accepted CVEs; pyo3 0.29 extension imports and runs (detach + from_py_object paths exercised); drift tests 14/14 pass; cargo fmt + clippy clean; all npm/bun audits report 0. - Not tested: full `pytest` suite (no Python source changed); release-profile wheel build (used dev-profile `maturin develop` for the import proof — the extension is semantically identical). ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation (`sbom/README.md`) - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective — N/A (dependency bumps; existing suites + scans cover it) - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md — N/A (Release Please auto-generates from the conventional commit) ## Additional Notes **Accepted risks (the 2 residual CVEs).** Both originate solely from the EleutherAI `lm-evaluation-harness` under the **opt-in `[benchmark]` extra**, which Headroom invokes as a subprocess (never imports): - `sqlitedict` CVE-2024-35515 (High) — pickle deserialization; package abandoned (last release 2021), **no upstream fix exists**. - `nltk` CVE-2026-54293 (High) — path traversal in `nltk.data.load()`; affects ≤3.9.4 (current latest), **no patched release**. Neither is in `[all]`, the published wheel, or the container. They are documented in `sbom/README.md` and will be picked up automatically once upstream ships fixes. **Release/CHANGELOG:** N/A items above are because this is a dependency/security PR with no Python source changes; CHANGELOG is Release-Please-managed via the conventional commit message.
2026-06-27 15:28:12 -07:00
assert "uses: pypa/gh-action-pypi-publish@v1.13.0" in pypi_job
assert "continue-on-error: true" not in pypi_job
assert "(vars.PYPI_SKIP == 'true' || needs.publish-pypi.result == 'success')" in content
fix: ship glibc 2.38 compat shim + wheel symbol audit (closes #355) Issue #355: published headroom_ai-*-manylinux_2_28_*.whl fails to import on Ubuntu 22.04, Debian 11/12, Conda envs with libc < 2.38: 'ImportError: undefined symbol: __isoc23_strtoll'. Root cause: ORT prebuilt artifacts (downloaded via fastembed's ort-download-binaries-rustls-tls feature) are compiled with gcc-14.2.1 on a glibc-2.38+ host and reference __isoc23_strtoll. Our manylinux build host has glibc 2.38 so the link succeeds; end users with older glibc don't. Two-part fix: (1) crates/headroom-py/glibc_compat.c provides weak-alias definitions for __isoc23_strtol/strtoll/strtoul/strtoull delegating to the older strtol* family, compiled by build.rs on Linux/glibc only. The dynamic linker prefers glibc's strong symbol when present (>= 2.38) and falls back to ours when not (< 2.38). (2) scripts/audit_wheel_glibc_symbols.py is a release.yml gate that runs objdump -T on every Linux wheel and rejects any UND symbol whose required glibc version exceeds the wheel's manylinux floor. Validated: the audit correctly rejects the actually-broken v0.20.26 wheel with a precise diagnostic. The shim itself is a tiny static link with zero runtime cost. Regression tests in tests/test_release_workflows.py pin the shim's load-bearing pieces (.c file, build.rs trigger, [build-dependencies] cc dep) and the audit invocation in release.yml. Future drift fails at PR time, not in the next release. This is the same bug class as PR #371 (rustls-everywhere). Each instance gets fixed; the audit gate now catches the *class* — any future static linkage that introduces a post-floor symbol gets blocked before publish.
2026-05-04 21:31:19 -07:00
def test_glibc_compat_shim_present_in_headroom_py() -> None:
"""STRUCTURAL INVARIANT: the headroom-py crate ships a glibc-2.38
compatibility shim that defines weak `__isoc23_*` aliases.
Issue #355 (https://github.com/chopratejas/headroom/issues/355) —
the published wheel's `_core.so` references `__isoc23_strtoll`
(glibc 2.38+) because we statically link prebuilt ONNX Runtime
artifacts compiled with gcc 14. Users with libc < 2.38 (Ubuntu
22.04, most Conda envs, Debian 11/12) hit:
ImportError: undefined symbol: __isoc23_strtoll
The fix is `crates/headroom-py/glibc_compat.c` which provides
weak-alias definitions for the four `__isoc23_*` symbols,
delegating to the older `strtol*` family. `build.rs` compiles
the shim into `_core.so` on Linux/glibc only.
A future "let me drop this weird C file, surely it's dead code"
refactor would silently re-introduce the import failure for
every user on glibc < 2.38. This test pins all three load-bearing
pieces (the .c file, the build.rs trigger, the [build-dependencies]
cc dep).
"""
headroom_py_dir = ROOT / "crates" / "headroom-py"
shim = headroom_py_dir / "glibc_compat.c"
assert shim.exists(), (
"crates/headroom-py/glibc_compat.c is missing — without it, "
"`_core.so` fails to import on every glibc < 2.38 host. See "
"issue #355 for the full bug class. NEVER delete this file "
"without confirming via `scripts/audit_wheel_glibc_symbols.py` "
"that the wheel no longer references __isoc23_* symbols."
)
shim_content = shim.read_text(encoding="utf-8")
for sym in ("__isoc23_strtol", "__isoc23_strtoll", "__isoc23_strtoul", "__isoc23_strtoull"):
assert sym in shim_content, f"shim missing alias for {sym}"
build_rs = headroom_py_dir / "build.rs"
assert build_rs.exists(), "crates/headroom-py/build.rs is missing"
build_rs_content = build_rs.read_text(encoding="utf-8")
assert "glibc_compat.c" in build_rs_content, (
"build.rs must reference glibc_compat.c — otherwise Cargo "
"skips the shim and the wheel's `_core.so` ships without it."
)
cargo_toml = (headroom_py_dir / "Cargo.toml").read_text(encoding="utf-8")
assert 'build = "build.rs"' in cargo_toml, (
'headroom-py/Cargo.toml must declare `build = "build.rs"` — '
"Cargo only auto-detects build.rs when this is set; without "
"it, the shim never compiles."
)
assert "[build-dependencies]" in cargo_toml and 'cc = "1"' in cargo_toml, (
'headroom-py/Cargo.toml must declare `cc = "1"` in '
"[build-dependencies] for build.rs to compile the C shim."
)
def test_release_workflow_audits_wheel_glibc_symbols() -> None:
"""STRUCTURAL INVARIANT: the release workflow audits each Linux
wheel for symbol references that exceed its manylinux glibc floor.
Companion to `test_glibc_compat_shim_present_in_headroom_py`
the shim is the FIX, this audit is the GATE. Without the audit,
a future toolchain bump in the prebuilt ORT artifacts (or any
other statically-linked C/C++ dep) could re-introduce a
post-floor symbol that our current shim doesn't cover. The audit
catches that at release time, before publish-pypi.
"""
content = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
assert "audit_wheel_glibc_symbols.py" in content, (
"release.yml must invoke `scripts/audit_wheel_glibc_symbols.py` "
"on every Linux wheel before publish. Without it, regressions "
"of issue #355's bug class ship to PyPI silently."
)
assert "Audit wheel glibc symbols (Linux only)" in content, (
"audit step name has been renamed; update both this test and the workflow"
)
fix(ci): smoke-import wheels on customer-representative envs before publish (X1) Issue #355 plus the three follow-on hotfixes (#384/#385/#386) all share a pattern: the wheel is technically valid (clippy passes, tests pass, auditwheel is happy, the static-symbol audit added in #384 is happy) but FAILS at runtime on a customer's box because of a dynamic-link symbol mismatch. None of our pre-publish gates actually `import headroom._core` on a representative customer environment. They only build it. What X1 adds ------------ A `smoke-import-wheels` job that runs after `build-wheels` and before `publish-pypi` / `publish-docker` / `create-release`. Matrix (6 jobs in parallel, ~3 min wall-clock): - `manylinux_2_28_x86_64` + Python 3.11 (the floor we promise) - `ubuntu:22.04` (glibc 2.35) + Python 3.12 (issue #355's env) - `ubuntu:20.04` (glibc 2.31) + Python 3.10 (older LTS) - `manylinux_2_28_aarch64` + Python 3.11 (aarch64 floor) - `ubuntu:22.04` arm64 + Python 3.12 (aarch64 customer env) - `macos-14` host + Python 3.13 (Apple Silicon) Each job downloads its arch's wheel artifact, installs the wheel matching its Python version inside the container, and runs the exact command the proxy's `_check_rust_core` runs at startup: from headroom._core import hello as _rust_hello If any matrix entry fails, `publish-pypi` / `publish-docker` / `create-release` are blocked. The matrix tells us exactly which customer environment combination breaks. Regression test in tests/test_release_workflows.py: `test_release_workflow_has_smoke_import_wheel_gate` pins the job's existence, the required matrix entries, and — critically — the gating wires (publish-pypi / publish-docker / create-release all need-and-require-success on the smoke job). A future "this slow CI step always passes anyway, drop it" refactor fails at PR time. Companion tests `test_glibc_compat_shim_present_in_headroom_py` and `test_release_workflow_audits_wheel_glibc_symbols` (added in #384) cover the static-symbol gate; this PR is the dynamic-link gate. Both are needed.
2026-05-04 22:48:55 -07:00
def test_release_workflow_has_smoke_import_wheel_gate() -> None:
"""STRUCTURAL INVARIANT: release.yml runs the just-built wheels
through `import headroom._core` on a matrix of representative
customer environments BEFORE publishing to PyPI / pushing to
GHCR / cutting a GitHub Release.
This is the X1 gate from the post-#355 hardening plan. Issue #355
plus its three follow-on hotfixes (#384/#385/#386) all share a
pattern: the wheel is technically valid (clippy passes, tests
pass, auditwheel is happy) but fails to import on a customer's
box because of a runtime symbol mismatch. Static gates can't
catch that only actually loading the .so does.
Required matrix coverage:
- manylinux floor we promise (`manylinux_2_28_x86_64` and
`manylinux_2_28_aarch64`). If these fail, our manylinux tag
is a lie.
- At least one customer-representative glibc per arch (Ubuntu
LTS, the issue #355 reporter's environment).
- macOS native (Apple Silicon).
Required gating: `publish-pypi`, `publish-docker`, AND
`create-release` must all `needs:` smoke-import-wheels. A
smoke failure has to BLOCK publish, not just produce a
notification.
A future "remove this slow CI step that always passes anyway"
refactor exactly the impulse that landed us PR #382's sdist
gap and PR #386's link-order surprise — fails this test at
PR time.
"""
content = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
# The job itself must exist.
assert "\n smoke-import-wheels:" in content, (
"release.yml must define a `smoke-import-wheels` job. This is the "
"X1 gate that catches runtime symbol mismatches in the published "
"wheel before it hits PyPI. Issue #355 + #384/#385/#386 are the "
"canonical reason this gate exists."
)
# Required matrix entries — pin both the floor (manylinux_2_28)
# and at least one customer environment per arch.
required_matrix_substrings = [
# manylinux floor for x86_64 — pins what we promise customers.
'image: "quay.io/pypa/manylinux_2_28_x86_64"',
# manylinux floor for aarch64 — would have caught PR #386.
'image: "quay.io/pypa/manylinux_2_28_aarch64"',
# At least one Ubuntu LTS — issue #355's environment was
# ubuntu:22.04 + Python 3.12.
'image: "ubuntu:22.04"',
# macOS native (no container) — Apple Silicon wheel.
"runner: macos-14",
]
for sub in required_matrix_substrings:
assert sub in content, (
f"smoke matrix missing required entry: {sub!r}. The matrix "
f"must cover the manylinux floor + at least one customer-"
f"representative environment per arch + macOS native."
)
# Gating: publish-pypi must wait for the smoke job.
publish_pypi_idx = content.index("\n publish-pypi:")
next_job_idx = content.index("\n publish-npm:", publish_pypi_idx)
publish_pypi_block = content[publish_pypi_idx:next_job_idx]
assert "smoke-import-wheels" in publish_pypi_block, (
"publish-pypi must `needs: [..., smoke-import-wheels]` — without "
"the dependency, a broken wheel can be published before the "
"smoke job has even finished. The whole point of X1 is that it "
"BLOCKS publish."
)
# Same for publish-docker.
publish_docker_idx = content.index("\n publish-docker:")
next_idx = content.index("\n create-release:", publish_docker_idx)
publish_docker_block = content[publish_docker_idx:next_idx]
assert "smoke-import-wheels" in publish_docker_block, (
"publish-docker must `needs: [..., smoke-import-wheels]` — the "
"docker image bundles the same wheels; a broken wheel will fail "
"the docker build's `pip install` 3 minutes later anyway. "
"Failing fast in smoke saves matrix budget."
)
# And create-release.
create_release_idx = content.index("\n create-release:")
create_release_block = content[create_release_idx:]
assert "smoke-import-wheels" in create_release_block, (
"create-release must `needs: [..., smoke-import-wheels]` and gate on its success"
)
assert "needs.smoke-import-wheels.result == 'success'" in create_release_block, (
"create-release's `if:` must explicitly require "
"`needs.smoke-import-wheels.result == 'success'` — without "
"this, `always()` would let the release proceed even if the "
"smoke gate failed."
)
# The actual import command must hit `from headroom._core import hello`
# — this is the same call the proxy's `_check_rust_core` makes on
# startup (per `headroom/proxy/server.py` and the issue #355 backtrace).
# Anything else (e.g. just `import headroom`) fails to exercise the
# Rust _core.so binary.
assert "from headroom._core import hello" in content, (
"smoke-import command must call `from headroom._core import hello` "
"— that's what the proxy does at startup. A weaker check (e.g. "
"`import headroom`) wouldn't exercise the .so and wouldn't catch "
"the bugs the gate exists for."
)
fix(ci): unbreak release pipeline — wheel openssl + dead npm artifact downloads Three independent failures on the post-merge release run for PR #360, all introduced by the single-wheel maturin refactor: 1. Linux wheel matrix (`ubuntu-x86_64`, `aarch64-unknown-linux-gnu`) failed inside the manylinux container with: Could not find openssl via pkg-config The system library `openssl` required by crate `openssl-sys` was not found. `openssl-sys` is transitive via `fastembed` → `hf-hub` → `ureq` → `native-tls`. The e2e Dockerfiles (`e2e/wrap`, `e2e/init`) we wrote for #360 install `openssl-devel` upfront, but the `build-wheels` matrix uses `PyO3/maturin-action@v1` which spins up its OWN manylinux container that does not inherit those installs. Fix: add a `before-script-linux:` to the action with a yum/apt-get conditional so it works on RHEL-family (manylinux2014, manylinux_2_28) and Debian-family musllinux variants. 2. macOS x86_64 wheel build failed with `maturin` exit 1 from the same `openssl-sys` lookup. The aarch64 macos-14 runner happens to have `/opt/homebrew/opt/openssl@3` on `openssl-sys`'s default discovery path; the Intel macos-15-intel runner uses `/usr/local/Cellar` which is NOT on that path. Fix: add a pre-maturin step that runs `brew install openssl@3` and exports `OPENSSL_DIR` / `OPENSSL_LIB_DIR` / `OPENSSL_INCLUDE_DIR` / `PKG_CONFIG_PATH`. The aarch64 runner happily picks up the explicit env vars too — no regression. 3. `publish-npm` and `publish-github-packages` both fail with "Artifact not found for name: dist". Both jobs `npm pack` + `npm publish` directly from the checked-out source tree — they never consume the Python `dist` artifact. The `Download dist artifact` step was vestigial dead code carried over from a prior workflow shape; the only reason it didn't fail before #360 is that the pre-refactor `build` job DID upload a `dist` artifact. Post-#360, `dist` is produced by `collect-dist` and neither publish job is gated on it (by design — npm vs PyPI ecosystems publish independently). Fix: remove the dead download step from both jobs. Loose coupling is preserved; `create-release` still gates the GitHub Release tag on all of build / build-wheels / collect-dist / publish-* succeeding. Why the PR-level CI didn't catch any of this: `release.yml` only runs on `push: branches: [main]`. PR #360's CI exercised `ci.yml` which has a separate `ci-build-wheels-on-pr` matrix that uses a different setup. The release surface only fires post-merge. Tests added (regression gates): - `test_build_wheels_installs_openssl_devel_on_linux_via_before_script` - `test_build_wheels_resolves_openssl_dir_explicitly_on_macos` - `test_npm_publish_jobs_do_not_download_dist_artifact` All 9 release-workflow tests pass. `make ci-precheck` PASSED.
2026-05-03 16:23:21 -07:00
def test_npm_publish_jobs_do_not_download_dist_artifact() -> None:
"""`publish-npm` and `publish-github-packages` `npm pack`+`npm publish`
directly from the checked-out source tree; they never read the
Python `dist` artifact. The earlier speculative download was failing
"Artifact not found" because neither job is gated on `collect-dist`.
Ensure no future refactor re-adds the dead step.
"""
content = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
# Locate publish-npm + publish-github-packages bodies and assert
# neither contains a download-artifact step that pulls `name: dist`.
npm_start = content.index("\n publish-npm:")
npm_end = content.index("\n publish-github-packages:")
publish_npm_body = content[npm_start:npm_end]
gpr_start = content.index("\n publish-github-packages:")
gpr_end = content.index("\n publish-docker:")
publish_gpr_body = content[gpr_start:gpr_end]
for body, label in (
(publish_npm_body, "publish-npm"),
(publish_gpr_body, "publish-github-packages"),
):
assert "download-artifact" not in body, (
f"{label} must not download the `dist` artifact — it `npm pack`s "
f"its own tarball and the speculative download fails when "
f"collect-dist hasn't run."
)
feat(ci): X2 — PR-time release dry-run via path-filtered pull_request trigger The X1 smoke-import gate (PR #387) catches runtime symbol mismatches on the wheel before publish, but only at release time. Recent break patterns were upstream of that: - #379 (docker bake `name=` regression in PR #376) - #382 (sdist `os: ubuntu-latest` → `ubuntu-24.04` rename) - #384 / #385 / #386 (glibc shim alias / link-order iterations) - #387's own heredoc-indent regression that broke main on the FIRST release run after merge — the heredoc was inside `bash -ec '...'`, no PR-time check exercised it X2 adds a `pull_request:` trigger to release.yml with a NARROW path filter so the dry-run runs at PR time for changes that affect wheel layout / release pipeline, but skips for source-only PRs to `crates/headroom-core` / `crates/headroom-proxy`. Path filter: release.yml, docker.yml, crates/headroom-py/**, pyproject.toml, root Cargo.toml, Cargo.lock. publish-pypi / publish-npm / publish-github-packages / publish-docker / create-release all gate on `github.event_name != 'pull_request'`, so a PR run never publishes — the dry-run is build + collect-dist + smoke-import only. concurrency rules: - PR runs: namespaced by PR number (`pr-N`), cancel-in-progress=true. - main runs: namespaced by ref_name, cancel-in-progress=false (a tag-push release that's mid-flight must not be cancelled). Test pin covers all four invariants (trigger, path filter, publish gates, concurrency split). 21 tests in test_release_workflows.py, all green locally.
2026-05-05 13:03:07 -07:00
def test_release_workflow_runs_dry_run_on_pull_request() -> None:
"""X2: the release workflow MUST trigger on `pull_request` for paths
that change wheel-layout / release pipeline so the wheel matrix +
smoke-import gate run BEFORE merge.
Issues this gate would have caught at PR time instead of after-merge:
- #379 (docker bake `name=` regression in PR #376)
- #382 (sdist os-mismatch — `ubuntu-latest` → `ubuntu-24.04` rename)
- #384 / #385 / #386 (glibc shim iterations — alias, link-order)
- #387's heredoc-indent regression that broke main on first release
run after merge
Required:
1. `pull_request:` trigger present.
2. Path filter is narrow enough to skip source-only PRs to
`crates/headroom-core` / `crates/headroom-proxy` (where wheel
layout doesn't change), but wide enough to cover release.yml,
docker.yml, headroom-py crate, pyproject.toml, root Cargo.
3. publish-pypi / publish-npm / publish-github-packages /
publish-docker / create-release ALL gate on
`github.event_name != 'pull_request'` so a PR run never
publishes anything the dry-run is build+smoke only.
4. concurrency.group is namespaced by PR number for PR runs and by
ref_name for main runs, AND cancel-in-progress is true for PR
runs (rapid PR pushes cancel stale dry-runs) and false for main
runs (a tag-push release should never be cancelled mid-flight).
A future "lighten CI by dropping the dry-run" refactor exactly
the impulse that gave us PR #382 and PR #387 — fails this test
at PR time.
"""
content = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
# 1. pull_request trigger present.
on_block_end = content.index("\nconcurrency:")
on_block = content[:on_block_end]
assert "\n pull_request:" in on_block, (
"release.yml must trigger on `pull_request` so the wheel matrix + "
"smoke-import gate run BEFORE merge. Without this, wheel-layout / "
"release-pipeline regressions are only caught after the tag is "
"pushed and main is broken (see #382, #387 for canonical examples)."
)
# 2. Path filter covers wheel-layout-affecting paths.
pr_idx = content.index("\n pull_request:")
pr_end = content.index("\n workflow_dispatch:", pr_idx)
pr_block = content[pr_idx:pr_end]
required_paths = [
".github/workflows/release.yml",
".github/workflows/docker.yml",
"crates/headroom-py/**",
"pyproject.toml",
"Cargo.toml",
"Cargo.lock",
]
for path in required_paths:
assert f'"{path}"' in pr_block, (
f"pull_request path filter missing {path!r}. The dry-run must "
f"trigger when this path changes — otherwise a regression in "
f"that file lands on main without exercising the wheel matrix."
)
# 3. Each publish job + create-release gates on event_name != pull_request.
publish_jobs = [
("publish-pypi", "\n publish-npm:"),
("publish-npm", "\n publish-github-packages:"),
("publish-github-packages", "\n publish-docker:"),
("publish-docker", "\n create-release:"),
]
for job_name, next_marker in publish_jobs:
start = content.index(f"\n {job_name}:")
end = content.index(next_marker, start)
body = content[start:end]
assert "github.event_name != 'pull_request'" in body, (
f"{job_name} must gate on `github.event_name != 'pull_request'`. "
f"Without this gate, a PR dry-run would attempt to publish — "
f"in the best case the publish credentials are missing and the "
f"job fails noisily; in the worst case it succeeds and a "
f"non-merged PR ships to PyPI / npm / GHCR."
)
create_release_idx = content.index("\n create-release:")
create_release_block = content[create_release_idx:]
assert "github.event_name != 'pull_request'" in create_release_block, (
"create-release must gate on `github.event_name != 'pull_request'`. "
"Without it, a PR dry-run would cut a GitHub Release for an unmerged "
"branch."
)
# 4. Concurrency: PR runs use a per-PR group and DO cancel-in-progress;
# main runs use ref_name and DO NOT cancel.
concurrency_idx = content.index("\nconcurrency:")
jobs_idx = content.index("\njobs:", concurrency_idx)
concurrency_block = content[concurrency_idx:jobs_idx]
assert "github.event.pull_request.number" in concurrency_block, (
"concurrency.group must include the PR number for pull_request runs "
"(via `format('pr-{0}', github.event.pull_request.number)`) — "
"otherwise PR runs collide with each other or with main."
)
assert "cancel-in-progress: ${{ github.event_name == 'pull_request' }}" in concurrency_block, (
"concurrency.cancel-in-progress must be conditional: TRUE for "
"pull_request (rapid PR pushes shouldn't queue N parallel wheel "
"builds) and FALSE for main (a tag-push release that's mid-flight "
"must not be cancelled — partial PyPI/Docker state is worse than "
"a slow CI queue)."
)
ci(release): adopt release-please for gated publishes Replace "every push to main = release" with release-please's release-PR pattern: the bot watches main and maintains a single "chore: release vX.Y.Z" PR aggregating conventional commits; merging that PR creates the tag + GitHub Release, which fires the release:published event that release.yml now triggers on. Why --- Per-merge releases burned PyPI's 10 GiB per-project storage quota (one fresh wheel matrix ~= 200 MB per merged fix/feat PR). publish-pypi has failed on every main merge since PR #482 with "400 Project size too large". Consolidating many fixes into one release cuts upload frequency ~5x. What changed ------------ - .github/workflows/release-please.yml: bot watching main - .release-please-config.json: python release-type + extra-files for sdk/typescript and plugins/openclaw package.json - .release-please-manifest.json: tracks current 0.9.1 - .github/workflows/release.yml: * trigger: push to main -> release: published * detect-version: reads tag from github.event.release.tag_name (strips leading "v") so release_version.py does not re-bump past the bot's tag * create-release: when release already exists (typical release-please path), do not pass --notes-file -- that would clobber the bot's auto-generated changelog body Tests ----- Five new regression tests in test_release_workflows.py prevent silent reversion to per-push triggering and assert the bot workflow + config invariants. Note ---- This commit does NOT fix the existing quota breach. Request a PyPI quota increase, yank old releases, or shrink the wheel matrix to free immediate space. This PR ensures the future release cadence stops growing the problem.
2026-05-25 18:21:37 -07:00
def test_release_yml_triggers_on_release_published_not_every_push_to_main() -> None:
"""release.yml fires when release-please publishes a release, not per main push.
The prior trigger (`push: branches: [main]`) caused a fresh wheel
matrix to be uploaded to PyPI for every merged `fix:`/`feat:` PR.
PyPI enforces a 10 GiB per-project storage quota and the project
breached it in May 2026 (publish-pypi failing on every main merge
from PR #482 forward). The fix routes releases through
release-please's release-PR pattern: bot opens/maintains a
`chore: release vX.Y.Z` PR aggregating conventional-commit traffic;
merging that PR creates the tag + GitHub Release; THAT release
event is what triggers this workflow.
Reverting to a per-push trigger would re-create the quota
blowup. This test fails any refactor that does so silently.
"""
content = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
on_block_end = content.index("\nconcurrency:")
on_block = content[:on_block_end]
assert "\n release:\n types: [published]" in on_block, (
"release.yml must trigger on the `release: published` event so "
"release-please's release-PR merge is the only way to publish — "
"see .github/workflows/release-please.yml."
)
assert "\n push:\n branches: [main]" not in on_block, (
"release.yml MUST NOT trigger on every push to main. That pattern "
"burned PyPI's 10 GiB storage quota (one fresh wheel matrix per "
"merged PR). Route releases through release-please instead."
)
def test_release_yml_resolves_manual_ver_from_release_tag() -> None:
"""When fired by release event, MANUAL_VER must come from the release tag.
release_version.py defaults to deriving the next version from git
log + canonical pyproject.toml version. On a release-published
run, that derivation would re-bump past the version the bot just
tagged, producing wheels for the wrong version. The detect-version
job must read `github.event.release.tag_name` and strip the leading
`v` so the SemVer parser accepts it.
"""
content = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
assert "Resolve MANUAL_VER from trigger" in content, (
"detect-version must include a step that resolves MANUAL_VER from "
"the trigger context (release.tag_name on release events; "
"inputs.version on workflow_dispatch)."
)
assert "RELEASE_TAG: ${{ github.event.release.tag_name }}" in content, (
"Resolver must read the tag from github.event.release.tag_name."
)
assert "${RELEASE_TAG#v}" in content, (
"Resolver must strip the leading 'v' from the release tag — "
"release_version.py's SemVer regex rejects 'v0.9.2'."
)
assert "MANUAL_VER: ${{ steps.manualver.outputs.value }}" in content, (
"Compute-version step must consume the resolver's output."
)
def test_release_yml_preserves_release_please_notes_when_release_exists() -> None:
"""create-release must not clobber release-please's auto-generated notes.
release-please creates the GitHub Release with an auto-generated
changelog body when its release PR merges. If create-release then
runs `gh release edit --notes-file .changelog.md`, the bot's
changelog gets overwritten with this workflow's full-history
fallback (which has no `--since` bound when MANUAL_VER is set
and previous_tag comes back empty). Keep the bot's notes intact;
only update title.
"""
content = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
create_release_idx = content.index("\n create-release:")
create_release_block = content[create_release_idx:]
assert 'gh release edit "$TAG" --title "$TITLE"\n' in create_release_block, (
"When the release already exists (release-please case), the edit "
"must only sync title — NOT pass --notes-file, which would "
"clobber the bot's auto-generated changelog."
)
def test_release_please_workflow_exists_and_targets_main() -> None:
"""The release-please bot workflow must be present and watch main."""
rp_path = ROOT / ".github" / "workflows" / "release-please.yml"
assert rp_path.exists(), (
"release-please.yml is the bot that opens/maintains the release "
"PR. Without it, no release ever fires (release.yml now only "
"triggers on the release event the bot emits)."
)
content = rp_path.read_text(encoding="utf-8")
ci: speed up GitHub Actions — path filters, caching, timeouts, version upgrades (#620) * ci: speed up GitHub Actions - path filters, caching, timeouts, version upgrades Performance improvements: - init-e2e.yml, wrap-e2e.yml: add path filters so e2e Docker builds only run when e2e-related files change (saves ~10 min per irrelevant PR push) - init-e2e.yml, wrap-e2e.yml: add concurrency groups to cancel superseded PR runs - ci.yml: add pip caching to lint and build jobs - ci.yml: cache actionlint + act binaries in workflow-validation (skip curl on hits) - eval.yml: add pip caching to smoke-test and weekly-suite jobs - docs.yml: add pip caching for mkdocs-material install - rust.yml: replace cargo install --locked cargo-audit/deny with taiki-e/install-action (prebuilt binaries; saves 2-5 min per audit run) Bug fixes: - docker.yml: fix actions/checkout@v6 -> @v4 (v6 does not exist; would break all Docker builds on every release/PR touching docker paths) Version upgrades: - wagoid/commitlint-github-action: @v5 -> @v6 - devcontainers.yml: docker/setup-buildx-action@v3 -> @v4 (align with docker.yml) Safety improvements: - ci.yml: add timeout-minutes to all 13 jobs (changes, lint, build-wheel, prefetch-model, test x4, test-extras, test-agno, commitlint, build, workflow-validation, docker-native-e2e, windows-native-wrapper, macos-native-wrapper) - docker.yml: add timeout-minutes to docker-build (75m), docker-manifest (20m), promote-latest (10m) - eval.yml: add timeout-minutes to smoke-test (30m); bump weekly-suite 60->90m - rust.yml: add timeout-minutes to test (30m), wheels (45m), audit (20m) Observed wall-clock impact on recent PRs: - Init E2E and Wrap E2E were running on every single PR push regardless of content - CI workflow was taking 12-17 min; path filters reduce unnecessary e2e runs to 0 * fix(ci): bust actionlint+act cache when workflow file changes Static cache key 'ci-tools-actionlint-act-v1' never invalidated on tool version updates. Switched to hashFiles('.github/workflows/ci.yml') so the cache busts automatically whenever the download scripts are updated to point at a newer release. Flagged by adversarial review (Architecture + Testing/Reliability personas). * fix(ci): add missing Dockerfile COPY paths to e2e path filters e2e/init/Dockerfile and e2e/wrap/Dockerfile COPY files not covered by the initial path filter set: init-e2e: Cargo.toml, Cargo.lock, rust-toolchain.toml, uv.lock, .claude-plugin, .github/plugin/**, plugins/headroom-agent-hooks/** wrap-e2e: Cargo.toml, Cargo.lock, rust-toolchain.toml, uv.lock, sdk/typescript/**, plugins/openclaw/** Without these, a Rust toolchain bump or SDK change on a PR would skip the e2e gate entirely, only catching it on the merge to main. Flagged by adversarial review (Domain/Correctness persona). * fix(devcontainer): upgrade uv to >=0.7.0 to parse uv.lock revision=3 * fix(devcontainer): set UV_SKIP_WHEEL_FILENAME_CHECK=1 in post-create.sh for gitpython wheel * ci: bump actions/checkout and actions/setup-node to v5 (Node.js 20 EOL Jun 16) * fix(devcontainer): export UV_SKIP_WHEEL_FILENAME_CHECK so uv run also skips wheel check * ci: bump all GitHub Actions to latest versions (Node.js 24) * fix(test): accept release-please-action v4 or v5 in workflow assertion * fix(format): ruff format test_release_workflows.py
2026-06-05 18:32:53 -04:00
assert any(f"googleapis/release-please-action@v{v}" in content for v in (4, 5)), (
"release-please.yml must use the v4 or v5 action — earlier versions "
ci(release): adopt release-please for gated publishes Replace "every push to main = release" with release-please's release-PR pattern: the bot watches main and maintains a single "chore: release vX.Y.Z" PR aggregating conventional commits; merging that PR creates the tag + GitHub Release, which fires the release:published event that release.yml now triggers on. Why --- Per-merge releases burned PyPI's 10 GiB per-project storage quota (one fresh wheel matrix ~= 200 MB per merged fix/feat PR). publish-pypi has failed on every main merge since PR #482 with "400 Project size too large". Consolidating many fixes into one release cuts upload frequency ~5x. What changed ------------ - .github/workflows/release-please.yml: bot watching main - .release-please-config.json: python release-type + extra-files for sdk/typescript and plugins/openclaw package.json - .release-please-manifest.json: tracks current 0.9.1 - .github/workflows/release.yml: * trigger: push to main -> release: published * detect-version: reads tag from github.event.release.tag_name (strips leading "v") so release_version.py does not re-bump past the bot's tag * create-release: when release already exists (typical release-please path), do not pass --notes-file -- that would clobber the bot's auto-generated changelog body Tests ----- Five new regression tests in test_release_workflows.py prevent silent reversion to per-push triggering and assert the bot workflow + config invariants. Note ---- This commit does NOT fix the existing quota breach. Request a PyPI quota increase, yank old releases, or shrink the wheel matrix to free immediate space. This PR ensures the future release cadence stops growing the problem.
2026-05-25 18:21:37 -07:00
"have different manifest semantics."
)
assert "branches: [main]" in content, (
"release-please.yml must watch main; that's where the bot reads "
"conventional-commit traffic to compute version bumps."
)
assert "config-file: .release-please-config.json" in content
assert "manifest-file: .release-please-manifest.json" in content
assert "pull-requests: write" in content, (
"Bot needs write permission to open/update its release PR."
)
assert "contents: write" in content, (
"Bot needs contents write to tag the release commit on merge."
)
def test_release_please_config_and_manifest_are_present_and_consistent() -> None:
"""Config and manifest must agree with pyproject.toml's version."""
import json
# tomllib is stdlib on 3.11+; tomli is the backport for 3.10 (which
# the project still supports per pyproject.toml `requires-python`).
# Matches the same fallback pattern in headroom/release_version.py.
try:
import tomllib
except ModuleNotFoundError: # pragma: no cover - Python 3.10 only
import tomli as tomllib # type: ignore[no-redef]
ci(release): adopt release-please for gated publishes Replace "every push to main = release" with release-please's release-PR pattern: the bot watches main and maintains a single "chore: release vX.Y.Z" PR aggregating conventional commits; merging that PR creates the tag + GitHub Release, which fires the release:published event that release.yml now triggers on. Why --- Per-merge releases burned PyPI's 10 GiB per-project storage quota (one fresh wheel matrix ~= 200 MB per merged fix/feat PR). publish-pypi has failed on every main merge since PR #482 with "400 Project size too large". Consolidating many fixes into one release cuts upload frequency ~5x. What changed ------------ - .github/workflows/release-please.yml: bot watching main - .release-please-config.json: python release-type + extra-files for sdk/typescript and plugins/openclaw package.json - .release-please-manifest.json: tracks current 0.9.1 - .github/workflows/release.yml: * trigger: push to main -> release: published * detect-version: reads tag from github.event.release.tag_name (strips leading "v") so release_version.py does not re-bump past the bot's tag * create-release: when release already exists (typical release-please path), do not pass --notes-file -- that would clobber the bot's auto-generated changelog body Tests ----- Five new regression tests in test_release_workflows.py prevent silent reversion to per-push triggering and assert the bot workflow + config invariants. Note ---- This commit does NOT fix the existing quota breach. Request a PyPI quota increase, yank old releases, or shrink the wheel matrix to free immediate space. This PR ensures the future release cadence stops growing the problem.
2026-05-25 18:21:37 -07:00
manifest = json.loads((ROOT / ".release-please-manifest.json").read_text(encoding="utf-8"))
config = json.loads((ROOT / ".release-please-config.json").read_text(encoding="utf-8"))
pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))
# Manifest tracks current version per package; the root package must
# match pyproject.toml exactly. A drift here means the bot will
# propose a version bump from the wrong base.
assert manifest["."] == pyproject["project"]["version"], (
f"manifest['.'] ({manifest['.']}) must match "
f"pyproject.toml version ({pyproject['project']['version']}). "
"Update the manifest when you bump pyproject.toml manually, or "
"let release-please own both."
)
# Config: the root package must declare python release-type so the
# bot updates pyproject.toml.
root_pkg = config["packages"]["."]
assert root_pkg["release-type"] == "python"
assert root_pkg["package-name"] == "headroom-ai"
# Tag format: existing tags in this repo are `vX.Y.Z`, NOT
# `headroom-ai-vX.Y.Z`. release-please's default for manifest
# configs prepends the component name; that would produce
# `headroom-ai-v0.22.4` and the bot would never find the existing
# `v0.22.3` baseline tag. include-component-in-tag MUST be false
# to keep tag format consistent with the project's pre-bot tags.
assert config.get("include-component-in-tag") is False, (
"include-component-in-tag must be false — existing tags are "
"`vX.Y.Z`, not `headroom-ai-vX.Y.Z`. Reverting this setting "
"would orphan every prior tag and produce a months-long "
"changelog because the bot can't find its baseline."
)
ci(release): adopt release-please for gated publishes Replace "every push to main = release" with release-please's release-PR pattern: the bot watches main and maintains a single "chore: release vX.Y.Z" PR aggregating conventional commits; merging that PR creates the tag + GitHub Release, which fires the release:published event that release.yml now triggers on. Why --- Per-merge releases burned PyPI's 10 GiB per-project storage quota (one fresh wheel matrix ~= 200 MB per merged fix/feat PR). publish-pypi has failed on every main merge since PR #482 with "400 Project size too large". Consolidating many fixes into one release cuts upload frequency ~5x. What changed ------------ - .github/workflows/release-please.yml: bot watching main - .release-please-config.json: python release-type + extra-files for sdk/typescript and plugins/openclaw package.json - .release-please-manifest.json: tracks current 0.9.1 - .github/workflows/release.yml: * trigger: push to main -> release: published * detect-version: reads tag from github.event.release.tag_name (strips leading "v") so release_version.py does not re-bump past the bot's tag * create-release: when release already exists (typical release-please path), do not pass --notes-file -- that would clobber the bot's auto-generated changelog body Tests ----- Five new regression tests in test_release_workflows.py prevent silent reversion to per-push triggering and assert the bot workflow + config invariants. Note ---- This commit does NOT fix the existing quota breach. Request a PyPI quota increase, yank old releases, or shrink the wheel matrix to free immediate space. This PR ensures the future release cadence stops growing the problem.
2026-05-25 18:21:37 -07:00
# extra-files: TypeScript SDK and openclaw plugin package.json
# files must be in lockstep with pyproject.toml.
extra_paths = {ef["path"] for ef in root_pkg.get("extra-files", [])}
assert "sdk/typescript/package.json" in extra_paths, (
"release-please must bump sdk/typescript/package.json so the npm "
"publish in release.yml ships the same version as the wheel."
)
assert "plugins/openclaw/package.json" in extra_paths, (
"release-please must bump plugins/openclaw/package.json so the "
"openclaw npm publish stays in sync."
)