Skip to content

three wheels per platform, and a gate that counts them - #5

Merged
tamnd merged 2 commits into
mainfrom
python-wheels
Aug 18, 2026
Merged

tamnd merged 2 commits into
mainfrom
python-wheels

Conversation

@tamnd

@tamnd tamnd commented Aug 18, 2026 •

Copy link
Copy Markdown
Owner

The wheel matrix, which is twenty-one wheels and an sdist.

Tag Covers
cp311-abi3 CPython 3.11 through 3.14, GIL-enabled
cp314-cp314t free-threaded 3.14
cp315-abi3.abi3t 3.15 and every later 3.x, both builds

Each on manylinux_2_28 and musllinux_1_2 for x86_64 and aarch64, macOS universal2, and Windows x64 and arm64, every row built on its own architecture because an emulated build takes twenty minutes to produce the same bytes. Three per platform is CPython's arithmetic and not ours: the free-threaded build had no stable ABI until 3.15 and PEP 803, so 3.14t is a version-specific wheel for as long as 3.14 is supported. From 3.15 one wheel serves both builds and says so in two ABI tags at once, which is what stops this at three rather than four.

Which ABI is now a feature

abi3 was a feature of the pyo3 dependency, which meant every build got it and the other two rows had no way to say otherwise. It is a feature of this crate now:

[features]
default = ["abi3"]
abi3 = ["pyo3/abi3-py311"]
abi3t = ["pyo3/abi3t-py315"]

The default is unchanged, so an ordinary build and every pip install . produce the same wheel they did. The 3.14t row builds --no-default-features against the free-threaded interpreter it is given, and the 3.15 row adds --features abi3t. The lint job compiles both with no interpreter present at all, which is the thing a stable ABI is for and the reason a change that breaks one is found on the pull request rather than at release time.

The gate

tools/wheel_tags.py, run twice. Each build is held to the cell it asked for, and then the grid is checked as a grid: every cell filled, nothing outside it, exactly one sdist.

Both halves matter and the first one especially. A build that cannot find the interpreter it wants does not always fail. Sometimes it falls back, and what comes out is a wheel that works on the machine that built it and claims nothing about any other version, which is invisible until somebody's install resolves to it.

What the first run of it taught

Written against real maturin output rather than against strings, which is the only reason any of this is right, because the first run got two things wrong and both of them were quiet.

Four Linux rows built a wheel for CPython 3.12. The container is not the runner: setup-python puts an interpreter on the runner's path and the build happens inside an image that cannot see it, so maturin took the image's own /usr/bin/python3 and the free-threaded rows produced cp312-cp312. The abi3 rows produced the right wheel by luck, which is the same bug with a better outcome. Every Linux row now names its interpreter under /opt/python, lists what the image carries before it builds, and stops on the spot if the one it asked for is not there. The musl rows take the pypa image rather than the cross image the action picks by default, which carries four GIL-enabled interpreters and no free-threaded one at all.

The other thing is the tag itself. A PEP 803 wheel is cp315-abi3.abi3t and not cp315-abi3t: an ABI tag is a set the same way a platform tag is, and this wheel is the stable ABI for the GIL-enabled build and for the free-threaded build at once. The check compares the set, so the order maturin writes the two members in is not something a release is held to, and a wheel that claims only abi3 is caught as what it is, a wheel the free-threaded interpreter will not load.

The universal2 tag was the same kind of surprise a commit earlier: macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2, three true things in one name. A platform is the one whose pattern any member of the set matches, and a wheel for a single architecture matches none of them.

22 tests for the rule, covering a complete release, a missing wheel, a missing ABI, a version-specific wheel nobody asked for, the two ABI tags in either order, the stable ABI without the free-threaded one, a platform not on the list, a single-architecture mac wheel, a duplicate cell, a missing sdist, and something that is not ours at all. 198 tests in the client.

Where it runs

The wheels workflow runs on a tag, on dispatch, and on any pull request that touches how a wheel is built, because the alternative is finding out on the tag. All twenty-two artifacts, and the grid they make, are green on this branch.

tamnd added 2 commits August 18, 2026 07:40
The wheel matrix, which is twenty-one wheels and an sdist: the stable
ABI from 3.11 up, free-threaded 3.14, and PEP 803's free-threaded
stable ABI from 3.15 up, each on manylinux_2_28 and musllinux for
x86_64 and aarch64, macOS universal2, and Windows x64 and arm64. Three
per platform is CPython's arithmetic rather than ours: the
free-threaded build had no stable ABI until 3.15, so 3.14t is a
version-specific wheel for as long as 3.14 is supported.

Which ABI a build gets is now a feature of this crate rather than a
feature of the pyo3 dependency, because a wheel cannot be told which
ABI it is after it is built and the three builds have to differ
somewhere. `abi3` is the default, so the ordinary build and every
`pip install .` is unchanged; the other two rows turn it off, one
building against whichever interpreter it is given and the other
against `abi3t-py315`, which compiles with no interpreter present at
all. That last part is the point of a stable ABI and the only reason a
3.15 wheel can be built before 3.15 is installable.

The gate is `tools/wheel_tags.py`, run twice. Each build is held to the
cell it asked for, and then the grid is checked as a grid: every cell
filled, nothing outside it, exactly one sdist. Both halves matter, and
the first one especially: a build that cannot find the interpreter it
wants does not fail, it falls back, and what comes out is a wheel that
works on the machine that built it and claims nothing about any other
version. That is invisible until somebody's install resolves to it.

Verified against real maturin output rather than only against strings.
A universal2 build here produced
`macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2`, a tag
set and not a tag, which the first draft refused; a platform is now the
one whose pattern any member of the set matches, and a wheel for one
architecture matches none of them. A `--no-default-features` build
against the 3.14 on this machine produced `cp314-cp314`, and the check
caught it as the cp314-cp314t row it was not.

18 tests for the rule, 194 in the client. The lint job now compiles
both of the other two ABIs, so a change that breaks one is found on the
pull request rather than on the tag.
The first run of this workflow taught it two things. Four Linux rows
built a wheel for the image's own Python 3.12: the free-threaded and
3.15 rows never had the interpreter they asked for, because the
container is not the runner and setup-python is not visible inside it.
The rows now name their interpreter under /opt/python, list what the
image carries before building, and fail on the spot when the one they
asked for is not there. The musl rows take the pypa image rather than
the cross image the action picks by default, which carries four
GIL-enabled interpreters and no free-threaded one at all.

The other thing is the tag. A PEP 803 wheel is cp315-abi3.abi3t, not
cp315-abi3t: one wheel that is the stable ABI for the GIL-enabled build
and for the free-threaded build at once, which is the whole point of
the PEP and the reason three wheels a platform does not become four.
An ABI tag is a set the same way a platform tag is, so the check
compares it as one and holds a wheel that claims only abi3 to be a
wheel the free-threaded interpreter will not load.
@tamnd
tamnd merged commit b800b4e into main Aug 18, 2026
30 checks passed
@tamnd
tamnd deleted the python-wheels branch August 18, 2026 01:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant