Skip to content

Commit ea550e7

Browse files
authored
Merge pull request #552 from igerber/ci/release-build-guard
ci: pre-merge guard for the PyPI release build path (+ CHANGELOG fix)
2 parents 606318c + 466fee4 commit ea550e7

6 files changed

Lines changed: 215 additions & 148 deletions

File tree

.github/workflows/build-wheels.yml

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
name: Build wheels
2+
3+
# Reusable wheel-build matrix shared by:
4+
# - publish.yml (on release: builds the full matrix, then uploads to PyPI)
5+
# - release-build-check.yml (on PR: build-only smoke of the release path)
6+
# Keeping the build jobs in ONE place means the pre-merge guard can never
7+
# drift from what actually publishes.
8+
on:
9+
workflow_call:
10+
inputs:
11+
linux_only:
12+
description: "Build only the manylinux Linux wheels (PR-guard mode); skip macOS/Windows/sdist."
13+
type: boolean
14+
default: false
15+
16+
permissions:
17+
contents: read
18+
19+
jobs:
20+
# Build wheels on Linux using manylinux containers
21+
build-linux:
22+
name: Build Linux ${{ matrix.arch }} wheels
23+
runs-on: ${{ matrix.runner }}
24+
container: ${{ matrix.container }}
25+
strategy:
26+
matrix:
27+
include:
28+
- arch: x86_64
29+
runner: ubuntu-latest
30+
container: quay.io/pypa/manylinux_2_28_x86_64
31+
artifact: wheels-linux-x86_64
32+
- arch: aarch64
33+
runner: ubuntu-24.04-arm
34+
container: quay.io/pypa/manylinux_2_28_aarch64
35+
artifact: wheels-linux-aarch64
36+
steps:
37+
- uses: actions/checkout@v7
38+
39+
- name: Install system dependencies
40+
run: dnf install -y openssl-devel perl-IPC-Cmd openblas-devel
41+
42+
- name: Install Rust
43+
uses: dtolnay/rust-toolchain@stable
44+
45+
- name: Install maturin
46+
run: /opt/python/cp312-cp312/bin/pip install maturin
47+
48+
- name: Build wheels
49+
run: |
50+
expected=0
51+
for pyver in 39 310 311 312 313 314; do
52+
pybin="/opt/python/cp${pyver}-cp${pyver}/bin/python"
53+
if [ ! -f "$pybin" ]; then
54+
echo "ERROR: Expected Python interpreter not found: $pybin"
55+
exit 1
56+
fi
57+
/opt/python/cp312-cp312/bin/maturin build --release --out dist -i "$pybin" --features extension-module,openblas
58+
expected=$((expected + 1))
59+
done
60+
actual=$(find dist -maxdepth 1 -name '*.whl' | wc -l)
61+
echo "Built $actual wheels (expected $expected)"
62+
if [ "$actual" -ne "$expected" ]; then
63+
echo "ERROR: Expected $expected wheels but found $actual"
64+
exit 1
65+
fi
66+
67+
- name: Upload wheels
68+
uses: actions/upload-artifact@v7
69+
with:
70+
name: ${{ matrix.artifact }}
71+
path: dist/*.whl
72+
73+
# Build wheels on macOS ARM64 (native build)
74+
# Note: macOS x86_64 skipped - Intel runners retired, users can install from sdist
75+
build-macos-arm:
76+
name: Build macOS ARM64 wheels
77+
if: ${{ !inputs.linux_only }}
78+
runs-on: macos-14
79+
strategy:
80+
matrix:
81+
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14']
82+
steps:
83+
- uses: actions/checkout@v7
84+
85+
- name: Set up Python
86+
uses: actions/setup-python@v6
87+
with:
88+
python-version: ${{ matrix.python-version }}
89+
90+
- name: Install Rust
91+
uses: dtolnay/rust-toolchain@stable
92+
93+
- name: Install maturin
94+
run: pip install maturin
95+
96+
- name: Build wheel
97+
run: maturin build --release --out dist --features extension-module,accelerate
98+
99+
- name: Upload wheels
100+
uses: actions/upload-artifact@v7
101+
with:
102+
name: wheels-macos-arm64-py${{ matrix.python-version }}
103+
path: dist/*.whl
104+
105+
# Build wheels on Windows
106+
build-windows:
107+
name: Build Windows wheels
108+
if: ${{ !inputs.linux_only }}
109+
runs-on: windows-latest
110+
strategy:
111+
matrix:
112+
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14']
113+
steps:
114+
- uses: actions/checkout@v7
115+
116+
- name: Set up Python
117+
uses: actions/setup-python@v6
118+
with:
119+
python-version: ${{ matrix.python-version }}
120+
121+
- name: Install Rust
122+
uses: dtolnay/rust-toolchain@stable
123+
124+
- name: Install maturin
125+
run: pip install maturin
126+
127+
- name: Build wheel
128+
run: maturin build --release --out dist --features extension-module
129+
130+
- name: Upload wheels
131+
uses: actions/upload-artifact@v7
132+
with:
133+
name: wheels-windows-py${{ matrix.python-version }}
134+
path: dist/*.whl
135+
136+
# Build source distribution
137+
build-sdist:
138+
name: Build source distribution
139+
if: ${{ !inputs.linux_only }}
140+
runs-on: ubuntu-latest
141+
steps:
142+
- uses: actions/checkout@v7
143+
144+
- name: Set up Python
145+
uses: actions/setup-python@v6
146+
with:
147+
python-version: '3.11'
148+
149+
- name: Install maturin
150+
run: pip install maturin
151+
152+
- name: Build sdist
153+
run: maturin sdist --out dist
154+
155+
- name: Upload sdist
156+
uses: actions/upload-artifact@v7
157+
with:
158+
name: sdist
159+
path: dist/*.tar.gz

.github/workflows/publish.yml

Lines changed: 4 additions & 138 deletions
Original file line numberDiff line numberDiff line change
@@ -8,148 +8,14 @@ permissions:
88
contents: read
99

1010
jobs:
11-
# Build wheels on Linux using manylinux containers
12-
build-linux:
13-
name: Build Linux ${{ matrix.arch }} wheels
14-
runs-on: ${{ matrix.runner }}
15-
container: ${{ matrix.container }}
16-
strategy:
17-
matrix:
18-
include:
19-
- arch: x86_64
20-
runner: ubuntu-latest
21-
container: quay.io/pypa/manylinux_2_28_x86_64
22-
artifact: wheels-linux-x86_64
23-
- arch: aarch64
24-
runner: ubuntu-24.04-arm
25-
container: quay.io/pypa/manylinux_2_28_aarch64
26-
artifact: wheels-linux-aarch64
27-
steps:
28-
- uses: actions/checkout@v7
29-
30-
- name: Install system dependencies
31-
run: dnf install -y openssl-devel perl-IPC-Cmd openblas-devel
32-
33-
- name: Install Rust
34-
uses: dtolnay/rust-toolchain@stable
35-
36-
- name: Install maturin
37-
run: /opt/python/cp312-cp312/bin/pip install maturin
38-
39-
- name: Build wheels
40-
run: |
41-
expected=0
42-
for pyver in 39 310 311 312 313 314; do
43-
pybin="/opt/python/cp${pyver}-cp${pyver}/bin/python"
44-
if [ ! -f "$pybin" ]; then
45-
echo "ERROR: Expected Python interpreter not found: $pybin"
46-
exit 1
47-
fi
48-
/opt/python/cp312-cp312/bin/maturin build --release --out dist -i "$pybin" --features extension-module,openblas
49-
expected=$((expected + 1))
50-
done
51-
actual=$(ls dist/*.whl 2>/dev/null | wc -l)
52-
echo "Built $actual wheels (expected $expected)"
53-
if [ "$actual" -ne "$expected" ]; then
54-
echo "ERROR: Expected $expected wheels but found $actual"
55-
exit 1
56-
fi
57-
58-
- name: Upload wheels
59-
uses: actions/upload-artifact@v7
60-
with:
61-
name: ${{ matrix.artifact }}
62-
path: dist/*.whl
63-
64-
# Build wheels on macOS ARM64 (native build)
65-
# Note: macOS x86_64 skipped - Intel runners retired, users can install from sdist
66-
build-macos-arm:
67-
name: Build macOS ARM64 wheels
68-
runs-on: macos-14
69-
strategy:
70-
matrix:
71-
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14']
72-
steps:
73-
- uses: actions/checkout@v7
74-
75-
- name: Set up Python
76-
uses: actions/setup-python@v6
77-
with:
78-
python-version: ${{ matrix.python-version }}
79-
80-
- name: Install Rust
81-
uses: dtolnay/rust-toolchain@stable
82-
83-
- name: Install maturin
84-
run: pip install maturin
85-
86-
- name: Build wheel
87-
run: maturin build --release --out dist --features extension-module,accelerate
88-
89-
- name: Upload wheels
90-
uses: actions/upload-artifact@v7
91-
with:
92-
name: wheels-macos-arm64-py${{ matrix.python-version }}
93-
path: dist/*.whl
94-
95-
# Build wheels on Windows
96-
build-windows:
97-
name: Build Windows wheels
98-
runs-on: windows-latest
99-
strategy:
100-
matrix:
101-
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14']
102-
steps:
103-
- uses: actions/checkout@v7
104-
105-
- name: Set up Python
106-
uses: actions/setup-python@v6
107-
with:
108-
python-version: ${{ matrix.python-version }}
109-
110-
- name: Install Rust
111-
uses: dtolnay/rust-toolchain@stable
112-
113-
- name: Install maturin
114-
run: pip install maturin
115-
116-
- name: Build wheel
117-
run: maturin build --release --out dist --features extension-module
118-
119-
- name: Upload wheels
120-
uses: actions/upload-artifact@v7
121-
with:
122-
name: wheels-windows-py${{ matrix.python-version }}
123-
path: dist/*.whl
124-
125-
# Build source distribution
126-
build-sdist:
127-
name: Build source distribution
128-
runs-on: ubuntu-latest
129-
steps:
130-
- uses: actions/checkout@v7
131-
132-
- name: Set up Python
133-
uses: actions/setup-python@v6
134-
with:
135-
python-version: '3.11'
136-
137-
- name: Install maturin
138-
run: pip install maturin
139-
140-
- name: Build sdist
141-
run: maturin sdist --out dist
142-
143-
- name: Upload sdist
144-
uses: actions/upload-artifact@v7
145-
with:
146-
name: sdist
147-
path: dist/*.tar.gz
11+
# Build the full wheel matrix via the shared reusable workflow.
12+
build:
13+
uses: ./.github/workflows/build-wheels.yml
14814

14915
# Publish to PyPI
15016
publish:
15117
name: Publish to PyPI
152-
needs: [build-linux, build-macos-arm, build-windows, build-sdist]
18+
needs: build
15319
runs-on: ubuntu-latest
15420
environment: pypi
15521
permissions:
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
name: Release build check
2+
3+
# Pre-merge guard for the PyPI release build path. publish.yml only runs on
4+
# `release: published`, so its manylinux container build (checkout@v7 inside
5+
# glibc-2.28, openblas, py3.9-3.14) is never exercised by PR CI. This calls the
6+
# SAME reusable workflow build-only (no PyPI upload) so a PR that would break our
7+
# ability to mint a release fails here instead of at release time.
8+
#
9+
# On PRs it builds only the manylinux leg (the gap rust-test.yml doesn't cover);
10+
# `workflow_dispatch` runs the full matrix as a manual pre-release rehearsal.
11+
on:
12+
pull_request:
13+
branches: [main]
14+
types: [opened, synchronize, reopened, labeled, unlabeled]
15+
paths:
16+
- 'rust/**'
17+
- 'pyproject.toml'
18+
- '.github/workflows/build-wheels.yml'
19+
- '.github/workflows/publish.yml'
20+
- '.github/workflows/release-build-check.yml'
21+
workflow_dispatch:
22+
23+
permissions:
24+
contents: read
25+
26+
jobs:
27+
release-build:
28+
# Skip unrelated label churn: a non-ready-for-ci label add/remove won't run this job.
29+
if: >-
30+
github.event_name != 'pull_request'
31+
|| (contains(github.event.pull_request.labels.*.name, 'ready-for-ci')
32+
&& (github.event.action != 'labeled' && github.event.action != 'unlabeled'
33+
|| github.event.label.name == 'ready-for-ci'))
34+
uses: ./.github/workflows/build-wheels.yml
35+
with:
36+
linux_only: ${{ github.event_name == 'pull_request' }}

.github/workflows/rust-test.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ on:
2020
- '.github/workflows/docs-tests.yml'
2121
- '.github/workflows/notebooks.yml'
2222
- '.github/workflows/ci-gate.yml'
23+
- '.github/workflows/release-build-check.yml'
2324
# The AI-review surfaces below are tested by
2425
# tests/test_openai_review.py (TestWorkflowPromptHardening,
2526
# TestAdaptReviewCriteria, etc.). Without these path filters, a
@@ -47,6 +48,7 @@ on:
4748
- '.github/workflows/docs-tests.yml'
4849
- '.github/workflows/notebooks.yml'
4950
- '.github/workflows/ci-gate.yml'
51+
- '.github/workflows/release-build-check.yml'
5052
- '.github/workflows/ai_pr_review.yml'
5153
- '.github/codex/prompts/pr_review.md'
5254
- '.claude/scripts/openai_review.py'

CHANGELOG.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1717
`maturin develop --features accelerate` against the pinned `ndarray 0.17`, the Rust unit
1818
tests, and the full Python⇄Rust equivalence suite (`tests/test_rust_backend.py`).
1919

20+
### Security
21+
- **Bumped the Rust backend's `pyo3` and `numpy` crates 0.28 → 0.29.** Resolves two RustSec
22+
advisories in `pyo3 < 0.29` — RUSTSEC-2026-0176 (out-of-bounds read in `PyList`/`PyTuple`
23+
`nth`/`nth_back`, High) and RUSTSEC-2026-0177 (missing `Sync` bound on
24+
`PyCFunction::new_closure`, Medium). Neither vulnerable path was reachable in this crate
25+
(no `PyList`/`PyTuple` iteration, no `new_closure`, no free-threaded wheels); `numpy` 0.29 is
26+
bumped in lockstep because it requires `pyo3` ^0.29. No API or numerical change — both crates
27+
are FFI/binding layers, and the math/RNG crates (`ndarray`, `faer`, `rand`, `rand_xoshiro`)
28+
are unchanged.
29+
2030
## [3.5.3] - 2026-06-25
2131

2232
### Added
@@ -43,16 +53,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4353
`treatment_fraction` remains inert (balanced 2×2×2); pass `group_frac`/`partition_frac`
4454
via `data_generator_kwargs`. See `docs/methodology/REGISTRY.md` §PowerAnalysis.
4555

46-
### Security
47-
- **Bumped the Rust backend's `pyo3` and `numpy` crates 0.28 → 0.29.** Resolves two RustSec
48-
advisories in `pyo3 < 0.29` — RUSTSEC-2026-0176 (out-of-bounds read in `PyList`/`PyTuple`
49-
`nth`/`nth_back`, High) and RUSTSEC-2026-0177 (missing `Sync` bound on
50-
`PyCFunction::new_closure`, Medium). Neither vulnerable path was reachable in this crate
51-
(no `PyList`/`PyTuple` iteration, no `new_closure`, no free-threaded wheels); `numpy` 0.29 is
52-
bumped in lockstep because it requires `pyo3` ^0.29. No API or numerical change — both crates
53-
are FFI/binding layers, and the math/RNG crates (`ndarray`, `faer`, `rand`, `rand_xoshiro`)
54-
are unchanged.
55-
5656
## [3.5.2] - 2026-06-08
5757

5858
### Added

0 commit comments

Comments
 (0)