diff --git a/.github/workflows/build-wheels.yml b/.github/workflows/build-wheels.yml new file mode 100644 index 00000000..6cd90d6c --- /dev/null +++ b/.github/workflows/build-wheels.yml @@ -0,0 +1,159 @@ +name: Build wheels + +# Reusable wheel-build matrix shared by: +# - publish.yml (on release: builds the full matrix, then uploads to PyPI) +# - release-build-check.yml (on PR: build-only smoke of the release path) +# Keeping the build jobs in ONE place means the pre-merge guard can never +# drift from what actually publishes. +on: + workflow_call: + inputs: + linux_only: + description: "Build only the manylinux Linux wheels (PR-guard mode); skip macOS/Windows/sdist." + type: boolean + default: false + +permissions: + contents: read + +jobs: + # Build wheels on Linux using manylinux containers + build-linux: + name: Build Linux ${{ matrix.arch }} wheels + runs-on: ${{ matrix.runner }} + container: ${{ matrix.container }} + strategy: + matrix: + include: + - arch: x86_64 + runner: ubuntu-latest + container: quay.io/pypa/manylinux_2_28_x86_64 + artifact: wheels-linux-x86_64 + - arch: aarch64 + runner: ubuntu-24.04-arm + container: quay.io/pypa/manylinux_2_28_aarch64 + artifact: wheels-linux-aarch64 + steps: + - uses: actions/checkout@v7 + + - name: Install system dependencies + run: dnf install -y openssl-devel perl-IPC-Cmd openblas-devel + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install maturin + run: /opt/python/cp312-cp312/bin/pip install maturin + + - name: Build wheels + run: | + expected=0 + for pyver in 39 310 311 312 313 314; do + pybin="/opt/python/cp${pyver}-cp${pyver}/bin/python" + if [ ! -f "$pybin" ]; then + echo "ERROR: Expected Python interpreter not found: $pybin" + exit 1 + fi + /opt/python/cp312-cp312/bin/maturin build --release --out dist -i "$pybin" --features extension-module,openblas + expected=$((expected + 1)) + done + actual=$(find dist -maxdepth 1 -name '*.whl' | wc -l) + echo "Built $actual wheels (expected $expected)" + if [ "$actual" -ne "$expected" ]; then + echo "ERROR: Expected $expected wheels but found $actual" + exit 1 + fi + + - name: Upload wheels + uses: actions/upload-artifact@v7 + with: + name: ${{ matrix.artifact }} + path: dist/*.whl + + # Build wheels on macOS ARM64 (native build) + # Note: macOS x86_64 skipped - Intel runners retired, users can install from sdist + build-macos-arm: + name: Build macOS ARM64 wheels + if: ${{ !inputs.linux_only }} + runs-on: macos-14 + strategy: + matrix: + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] + steps: + - uses: actions/checkout@v7 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install maturin + run: pip install maturin + + - name: Build wheel + run: maturin build --release --out dist --features extension-module,accelerate + + - name: Upload wheels + uses: actions/upload-artifact@v7 + with: + name: wheels-macos-arm64-py${{ matrix.python-version }} + path: dist/*.whl + + # Build wheels on Windows + build-windows: + name: Build Windows wheels + if: ${{ !inputs.linux_only }} + runs-on: windows-latest + strategy: + matrix: + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] + steps: + - uses: actions/checkout@v7 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install maturin + run: pip install maturin + + - name: Build wheel + run: maturin build --release --out dist --features extension-module + + - name: Upload wheels + uses: actions/upload-artifact@v7 + with: + name: wheels-windows-py${{ matrix.python-version }} + path: dist/*.whl + + # Build source distribution + build-sdist: + name: Build source distribution + if: ${{ !inputs.linux_only }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Install maturin + run: pip install maturin + + - name: Build sdist + run: maturin sdist --out dist + + - name: Upload sdist + uses: actions/upload-artifact@v7 + with: + name: sdist + path: dist/*.tar.gz diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index daccec67..303354b1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,148 +8,14 @@ permissions: contents: read jobs: - # Build wheels on Linux using manylinux containers - build-linux: - name: Build Linux ${{ matrix.arch }} wheels - runs-on: ${{ matrix.runner }} - container: ${{ matrix.container }} - strategy: - matrix: - include: - - arch: x86_64 - runner: ubuntu-latest - container: quay.io/pypa/manylinux_2_28_x86_64 - artifact: wheels-linux-x86_64 - - arch: aarch64 - runner: ubuntu-24.04-arm - container: quay.io/pypa/manylinux_2_28_aarch64 - artifact: wheels-linux-aarch64 - steps: - - uses: actions/checkout@v7 - - - name: Install system dependencies - run: dnf install -y openssl-devel perl-IPC-Cmd openblas-devel - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Install maturin - run: /opt/python/cp312-cp312/bin/pip install maturin - - - name: Build wheels - run: | - expected=0 - for pyver in 39 310 311 312 313 314; do - pybin="/opt/python/cp${pyver}-cp${pyver}/bin/python" - if [ ! -f "$pybin" ]; then - echo "ERROR: Expected Python interpreter not found: $pybin" - exit 1 - fi - /opt/python/cp312-cp312/bin/maturin build --release --out dist -i "$pybin" --features extension-module,openblas - expected=$((expected + 1)) - done - actual=$(ls dist/*.whl 2>/dev/null | wc -l) - echo "Built $actual wheels (expected $expected)" - if [ "$actual" -ne "$expected" ]; then - echo "ERROR: Expected $expected wheels but found $actual" - exit 1 - fi - - - name: Upload wheels - uses: actions/upload-artifact@v7 - with: - name: ${{ matrix.artifact }} - path: dist/*.whl - - # Build wheels on macOS ARM64 (native build) - # Note: macOS x86_64 skipped - Intel runners retired, users can install from sdist - build-macos-arm: - name: Build macOS ARM64 wheels - runs-on: macos-14 - strategy: - matrix: - python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] - steps: - - uses: actions/checkout@v7 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: ${{ matrix.python-version }} - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Install maturin - run: pip install maturin - - - name: Build wheel - run: maturin build --release --out dist --features extension-module,accelerate - - - name: Upload wheels - uses: actions/upload-artifact@v7 - with: - name: wheels-macos-arm64-py${{ matrix.python-version }} - path: dist/*.whl - - # Build wheels on Windows - build-windows: - name: Build Windows wheels - runs-on: windows-latest - strategy: - matrix: - python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] - steps: - - uses: actions/checkout@v7 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: ${{ matrix.python-version }} - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Install maturin - run: pip install maturin - - - name: Build wheel - run: maturin build --release --out dist --features extension-module - - - name: Upload wheels - uses: actions/upload-artifact@v7 - with: - name: wheels-windows-py${{ matrix.python-version }} - path: dist/*.whl - - # Build source distribution - build-sdist: - name: Build source distribution - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: '3.11' - - - name: Install maturin - run: pip install maturin - - - name: Build sdist - run: maturin sdist --out dist - - - name: Upload sdist - uses: actions/upload-artifact@v7 - with: - name: sdist - path: dist/*.tar.gz + # Build the full wheel matrix via the shared reusable workflow. + build: + uses: ./.github/workflows/build-wheels.yml # Publish to PyPI publish: name: Publish to PyPI - needs: [build-linux, build-macos-arm, build-windows, build-sdist] + needs: build runs-on: ubuntu-latest environment: pypi permissions: diff --git a/.github/workflows/release-build-check.yml b/.github/workflows/release-build-check.yml new file mode 100644 index 00000000..ee7c4dd5 --- /dev/null +++ b/.github/workflows/release-build-check.yml @@ -0,0 +1,36 @@ +name: Release build check + +# Pre-merge guard for the PyPI release build path. publish.yml only runs on +# `release: published`, so its manylinux container build (checkout@v7 inside +# glibc-2.28, openblas, py3.9-3.14) is never exercised by PR CI. This calls the +# SAME reusable workflow build-only (no PyPI upload) so a PR that would break our +# ability to mint a release fails here instead of at release time. +# +# On PRs it builds only the manylinux leg (the gap rust-test.yml doesn't cover); +# `workflow_dispatch` runs the full matrix as a manual pre-release rehearsal. +on: + pull_request: + branches: [main] + types: [opened, synchronize, reopened, labeled, unlabeled] + paths: + - 'rust/**' + - 'pyproject.toml' + - '.github/workflows/build-wheels.yml' + - '.github/workflows/publish.yml' + - '.github/workflows/release-build-check.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + release-build: + # Skip unrelated label churn: a non-ready-for-ci label add/remove won't run this job. + if: >- + github.event_name != 'pull_request' + || (contains(github.event.pull_request.labels.*.name, 'ready-for-ci') + && (github.event.action != 'labeled' && github.event.action != 'unlabeled' + || github.event.label.name == 'ready-for-ci')) + uses: ./.github/workflows/build-wheels.yml + with: + linux_only: ${{ github.event_name == 'pull_request' }} diff --git a/.github/workflows/rust-test.yml b/.github/workflows/rust-test.yml index ef12a463..3908f293 100644 --- a/.github/workflows/rust-test.yml +++ b/.github/workflows/rust-test.yml @@ -20,6 +20,7 @@ on: - '.github/workflows/docs-tests.yml' - '.github/workflows/notebooks.yml' - '.github/workflows/ci-gate.yml' + - '.github/workflows/release-build-check.yml' # The AI-review surfaces below are tested by # tests/test_openai_review.py (TestWorkflowPromptHardening, # TestAdaptReviewCriteria, etc.). Without these path filters, a @@ -47,6 +48,7 @@ on: - '.github/workflows/docs-tests.yml' - '.github/workflows/notebooks.yml' - '.github/workflows/ci-gate.yml' + - '.github/workflows/release-build-check.yml' - '.github/workflows/ai_pr_review.yml' - '.github/codex/prompts/pr_review.md' - '.claude/scripts/openai_review.py' diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d8fcbec..61140c38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `maturin develop --features accelerate` against the pinned `ndarray 0.17`, the Rust unit tests, and the full Python⇄Rust equivalence suite (`tests/test_rust_backend.py`). +### Security +- **Bumped the Rust backend's `pyo3` and `numpy` crates 0.28 → 0.29.** Resolves two RustSec + advisories in `pyo3 < 0.29` — RUSTSEC-2026-0176 (out-of-bounds read in `PyList`/`PyTuple` + `nth`/`nth_back`, High) and RUSTSEC-2026-0177 (missing `Sync` bound on + `PyCFunction::new_closure`, Medium). Neither vulnerable path was reachable in this crate + (no `PyList`/`PyTuple` iteration, no `new_closure`, no free-threaded wheels); `numpy` 0.29 is + bumped in lockstep because it requires `pyo3` ^0.29. No API or numerical change — both crates + are FFI/binding layers, and the math/RNG crates (`ndarray`, `faer`, `rand`, `rand_xoshiro`) + are unchanged. + ## [3.5.3] - 2026-06-25 ### Added @@ -43,16 +53,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `treatment_fraction` remains inert (balanced 2×2×2); pass `group_frac`/`partition_frac` via `data_generator_kwargs`. See `docs/methodology/REGISTRY.md` §PowerAnalysis. -### Security -- **Bumped the Rust backend's `pyo3` and `numpy` crates 0.28 → 0.29.** Resolves two RustSec - advisories in `pyo3 < 0.29` — RUSTSEC-2026-0176 (out-of-bounds read in `PyList`/`PyTuple` - `nth`/`nth_back`, High) and RUSTSEC-2026-0177 (missing `Sync` bound on - `PyCFunction::new_closure`, Medium). Neither vulnerable path was reachable in this crate - (no `PyList`/`PyTuple` iteration, no `new_closure`, no free-threaded wheels); `numpy` 0.29 is - bumped in lockstep because it requires `pyo3` ^0.29. No API or numerical change — both crates - are FFI/binding layers, and the math/RNG crates (`ndarray`, `faer`, `rand`, `rand_xoshiro`) - are unchanged. - ## [3.5.2] - 2026-06-08 ### Added diff --git a/tests/test_openai_review.py b/tests/test_openai_review.py index e9cd8055..3ae27dde 100644 --- a/tests/test_openai_review.py +++ b/tests/test_openai_review.py @@ -2084,6 +2084,7 @@ def test_rust_test_yml_pr_filter_covers_ai_review_surfaces(self, workflow_paths) ".github/workflows/docs-tests.yml", ".github/workflows/notebooks.yml", ".github/workflows/ci-gate.yml", + ".github/workflows/release-build-check.yml", ) def test_rust_test_yml_push_filter_covers_guarded_workflows(self, workflow_paths): @@ -2128,6 +2129,9 @@ class TestCiWorkflowLabelEventGuard: "rust-test.yml": ("rust-tests", "python-tests", "python-fallback"), "notebooks.yml": ("execute-notebooks",), "docs-tests.yml": ("doc-snippets", "sphinx-build", "docs-deps-py39-smoke"), + # release-build-check.yml is a single reusable-workflow caller job gated on + # ready-for-ci (it build-tests the PyPI release path on PRs); lock its guard too. + "release-build-check.yml": ("release-build",), } # The exact guard every gated job must carry (folded to one line). Asserting