From 88e67edf03cffa85dafa52e519e47314187d2bce Mon Sep 17 00:00:00 2001 From: Lakshya Sharma <169168095+Lakshya77089@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:18:37 +0530 Subject: [PATCH] ci(release): publish win_amd64 wheel so Windows installs need no Rust (#1328) (#1335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description We ship wheels for macOS arm64 and manylinux x86_64/aarch64, but there's no `win_amd64` wheel on PyPI for any Python version. So on Windows, pip/uv can't find a binary and try to build from the sdist with maturin, which pulls the Rust toolchain from static.rust-lang.org and crates from crates.io. On locked-down machines (corporate proxies, CI runners, the GitHub Copilot CLI sandbox, anything air-gapped) those hosts aren't reachable and the install just dies: ``` error: could not download file from 'https://static.rust-lang.org/dist/channel-rust-stable.toml.sha256' error: failed to get pyo3-macros as a dependency of package pyo3 v0.24.2 [28] Timeout was reached (Failed to connect to index.crates.io port 443) ``` This adds the Windows wheel to the release matrix so `pip install headroom-ai` works on Windows without a local Rust install. Closes #1328 ## 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) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Added a `windows-latest` / `x86_64-pc-windows-msvc` row to the `build-wheels` matrix. The runner already has MSVC and maturin-action sets up Rust, so it produces `headroom_ai-*-win_amd64.whl` on every release. I checked `crates/headroom-core/Cargo.toml` first — the Windows ONNX path is already on `ort-load-dynamic` under `cfg(windows)`, so the wheel loads ORT at runtime instead of linking the DirectML SDK libs. Nothing else was needed on the Rust side. - Added a matching `windows-latest` row to `smoke-import-wheels` so a broken Windows wheel blocks publish like the other platforms do. Windows needed its own step: the venv puts Python under `Scripts\` not `bin/`, and the runner defaults to pwsh. I also pinned the shared script-staging step to `shell: bash` since it uses a heredoc that pwsh can't run (Git Bash is on the runner), and added a `setup-python` step to get the right minor version. - Updated the README install section so the "install Rust first" workaround is clearly only for the sdist fallback (e.g. Intel macOS) now that Windows/Linux/macOS-arm64 all have prebuilt wheels. ## Testing - [x] Unit tests pass (`pytest`) - [ ] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed This is a CI workflow + docs change, no Python runtime code. I leaned on the existing `tests/test_release_workflows.py` structural gates plus a YAML parse and matrix-shape sanity check. ### Test Output ```text $ python -m pytest tests/test_release_workflows.py -q 28 passed, 1 skipped, 1 failed # The one failure, test_no_native_tls_in_wheel_build_tree, shells out to cargo, which # isn't installed here. I confirmed with `git stash` that it fails the same way on main # without my changes, so it's pre-existing and unrelated. $ python -c "import yaml; d=yaml.safe_load(open('.github/workflows/release.yml',encoding='utf-8')); \ j=d['jobs']; print('build-wheels rows:', len(j['build-wheels']['strategy']['matrix']['include'])); \ print('smoke rows:', len(j['smoke-import-wheels']['strategy']['matrix']['include']))" build-wheels rows: 4 smoke rows: 6 ``` ## Real Behavior Proof - Environment: Windows 11 local clone; CI runs on GitHub-hosted `windows-latest`. - Exact command / steps: edited the build-wheels and smoke-import-wheels matrices in `.github/workflows/release.yml` and the README, then ran the release-workflow tests and the YAML/matrix-shape check above. - Observed result: tests pass, YAML parses, build matrix is now 4 rows (Linux x64, Linux arm64, macOS arm64, Windows x64) and the smoke matrix is 6 rows including the new native Windows row. - Not tested: the actual win_amd64 build + PyPI publish. Those jobs only run in the release workflow on a tag or workflow_dispatch, not on a feature PR. The PR-time release dry-run will exercise the new rows once a maintainer approves the workflow run. I couldn't run `maturin build --target x86_64-pc-windows-msvc` end to end here. ## 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 - [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 unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes - No new test file: the existing structural gates in `tests/test_release_workflows.py` (`test_build_wheels_matrix_excludes_intel_macos`, `test_aarch64_wheel_uses_native_arm64_runner`, the smoke-import gate test) already assert the matrix contract and still pass with the Windows row added. - I didn't touch CHANGELOG.md — release-please generates it from the Conventional Commit subject, so the `ci(release):` commit gets picked up automatically. - The win_amd64 wheel actually shows up on PyPI on the next tagged release. --- .github/workflows/release.yml | 78 ++++++++++++++++++++++++++++++++++- README.md | 5 ++- headroom/release_version.py | 23 ++++++++++- 3 files changed, 103 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e2b4a2114..2008a47b1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -259,6 +259,19 @@ jobs: - os: macos-14 target: aarch64-apple-darwin manylinux: "" + # Windows x86_64. `windows-latest` ships the MSVC toolchain and + # `maturin-action` provisions the Rust toolchain, so users no + # longer need a local Rust install to `pip install headroom-ai` + # on Windows (issue #1328 — sdist build was failing in sandboxed + # / air-gapped environments that can't reach static.rust-lang.org + # or crates.io). `manylinux` is meaningless on Windows (that tag + # is Linux-only) — leave it empty. The ONNX Runtime story is + # already handled: `crates/headroom-core/Cargo.toml` selects + # `ort-load-dynamic` under `cfg(windows)`, so the wheel does not + # bundle/link DirectML SDK libs and loads ORT at runtime instead. + - os: windows-latest + target: x86_64-pc-windows-msvc + manylinux: "" runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v6 @@ -499,6 +512,13 @@ jobs: # ship today (Intel macOS dropped in PR #371 — ort-sys # has no x86_64-apple-darwin prebuilts). - { runner: macos-14, image: "", python: "3.13", wheel_target: aarch64-apple-darwin, wheel_artifact: wheels-macos-14-aarch64-apple-darwin, glibc_label: "" } + # Windows x86_64 — runs natively on the host runner; no + # container. `image: ""` routes this row past the Linux docker + # path; a dedicated PowerShell step below installs + imports the + # wheel (venv layout differs: Scripts\ not bin/). This is the + # gate that issue #1328's win_amd64 wheel must clear before + # publish. + - { runner: windows-latest, image: "", python: "3.12", wheel_target: x86_64-pc-windows-msvc, wheel_artifact: wheels-windows-latest-x86_64-pc-windows-msvc, glibc_label: "" } steps: - name: Download wheels artifact for this target @@ -507,6 +527,17 @@ jobs: name: ${{ matrix.wheel_artifact }} path: dist/ + # Windows is the only host-runner row that can't rely on a + # preinstalled interpreter at the exact requested minor (the Linux + # rows install inside their container; the macOS runner ships + # multiple Pythons). Provision it explicitly so the requested minor + # is on PATH as `python` for the smoke step below. + - name: Set up Python (Windows host) + if: matrix.image == '' && runner.os == 'Windows' + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python }} + - name: Stage smoke-import script # The smoke script lives in a host file rather than an inline # heredoc/`-c` invocation. Rationale: @@ -525,6 +556,12 @@ jobs: # YAML `run: |` level (uniform strip), the docker container # sees it as a read-only mount, and macOS host runs the # same script — no quoting drift between paths. + # + # `shell: bash` is explicit because the Windows runner defaults to + # pwsh, which can't run this heredoc. GitHub-hosted windows-latest + # ships Git Bash, and `${RUNNER_TEMP}` resolves there too — the + # Windows smoke step below reads the same file via `$env:RUNNER_TEMP`. + shell: bash run: | cat > "${RUNNER_TEMP}/smoke_import.py" <<'PY' import sys @@ -640,7 +677,7 @@ jobs: ' - name: Smoke-import wheel on macOS host - if: matrix.image == '' + if: matrix.image == '' && runner.os == 'macOS' env: PYTHON_VERSION: ${{ matrix.python }} run: | @@ -677,6 +714,45 @@ jobs: # invoked directly on the macOS host (no docker mount needed). /tmp/venv/bin/python "${RUNNER_TEMP}/smoke_import.py" + - name: Smoke-import wheel on Windows host + if: matrix.image == '' && runner.os == 'Windows' + shell: pwsh + env: + PYTHON_VERSION: ${{ matrix.python }} + run: | + $ErrorActionPreference = "Stop" + $pyTag = "cp" + ($env:PYTHON_VERSION -replace '\.','') + # Windows wheels are tagged win_amd64. Prefer a version-specific + # cp3XY wheel; fall back to a stable-ABI (abi3) wheel whose floor + # is <= the interpreter minor — same selection logic as the Linux + # and macOS paths, just expressed in PowerShell. + $whl = Get-ChildItem -Path dist -Filter "headroom_ai-*-$pyTag-$pyTag-win_amd64.whl" -File | + Select-Object -First 1 + if (-not $whl) { + $pyMinor = [int]($env:PYTHON_VERSION -split '\.')[1] + $abi3 = Get-ChildItem -Path dist -Filter "headroom_ai-*-abi3-win_amd64.whl" -File | + Where-Object { + if ($_.Name -match '-cp3(\d+)-abi3-') { [int]$Matches[1] -le $pyMinor } else { $false } + } | Select-Object -First 1 + if ($abi3) { $whl = $abi3 } + } + if (-not $whl) { + Write-Error "no Windows wheel matching python=$env:PYTHON_VERSION (win_amd64)" + Get-ChildItem -Path dist -File | ForEach-Object { $_.Name } + exit 1 + } + Write-Host "Installing: $($whl.FullName)" + + # `actions/setup-python` (above) put the requested minor on PATH + # as `python`, so use it directly. The venv exposes the + # interpreter under Scripts\python.exe (not bin/), which is why + # Windows needs its own step rather than reusing the macOS path. + python -m venv venv + venv\Scripts\python.exe -m pip install --quiet --upgrade pip + venv\Scripts\python.exe -m pip install --quiet "$($whl.FullName)" + # Same staged smoke script as the other host/container paths. + venv\Scripts\python.exe "$env:RUNNER_TEMP\smoke_import.py" + publish-pypi: needs: [collect-dist, smoke-import-wheels] # X2: skip on pull_request — dry-run only verifies build+smoke, diff --git a/README.md b/README.md index b2da4dfd0..f4c90afb5 100644 --- a/README.md +++ b/README.md @@ -349,7 +349,10 @@ winget install Rustlang.Rustup && rustup default stable ``` Restart your shell, then `pip install "headroom-ai[all]"`. A prebuilt wheel avoids the Rust -build entirely where available: `pip install --only-binary headroom-ai headroom-ai`. +build entirely where available: `pip install --only-binary headroom-ai headroom-ai`. Prebuilt +wheels are published for Windows (`win_amd64`), Linux (`x86_64` / `aarch64`), and macOS +(Apple Silicon), so installs on those platforms never need a local Rust toolchain — the +Rust-first dance above is only for the platform-independent sdist fallback (e.g. Intel macOS). Two runtime assets are fetched over TLS; if they are blocked, trust your corporate CA via `REQUESTS_CA_BUNDLE` / `SSL_CERT_FILE` / `CURL_CA_BUNDLE`: diff --git a/headroom/release_version.py b/headroom/release_version.py index ee09d5431..65cffbf48 100644 --- a/headroom/release_version.py +++ b/headroom/release_version.py @@ -8,7 +8,28 @@ from collections.abc import Sequence from dataclasses import dataclass, replace from pathlib import Path -from headroom._subprocess import run +# Load the UTF-8-forcing subprocess wrapper WITHOUT importing the `headroom` +# package. The release workflow runs this file as a bare script +# (`python headroom/release_version.py`), where `sys.path[0]` is `headroom/` +# rather than the repo root, so `from headroom._subprocess import run` fails +# with `ModuleNotFoundError: No module named 'headroom'` — and even when it +# resolves, it drags in `headroom/__init__.py` (the Rust `_core` import), which +# isn't built in the detect-version job (issue #1328). Loading `_subprocess.py` +# by path sidesteps both while still routing every text-mode git call through +# the shared wrapper (keeps the `test_text_mode_subprocess_calls_use_wrapper` +# guard happy — no raw `subprocess.run(..., text=True)` here). +try: # normal package context (tests import `headroom.release_version`) + from headroom._subprocess import run +except ModuleNotFoundError: # bare-script context (the release workflow) + import importlib.util as _ilu + + _spec = _ilu.spec_from_file_location( + "_headroom_subprocess", Path(__file__).resolve().parent / "_subprocess.py" + ) + assert _spec and _spec.loader # for type checkers; spec is always present here + _mod = _ilu.module_from_spec(_spec) + _spec.loader.exec_module(_mod) + run = _mod.run SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") RELEASE_TAG_RE = re.compile(r"^v(\d+)\.(\d+)\.(\d+)(?:\.(\d+))?$")