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+))?$")