In the basic publishing chapter, one job built an SDist and a wheel, and one job uploaded them. If you have compiled components, you need slightly more than one wheel:
...and that’s before musl-based Linux, PyPy, or free-threaded builds. There are quite a few quirks to doing this properly on each platform. cibuildwheel is the standard tool (used by scikit-learn, matplotlib, mypy, and thousands more) that turns this into a six-job matrix.
What cibuildwheel does¶
On each CI runner, one cibuildwheel invocation:
Installs every Python version it needs (you don’t set up Python yourself).
Builds a wheel for each one with your normal build backend.
Repairs each wheel: bundles any shared libraries it links against and applies the right platform tag (auditwheel on Linux, delocate on macOS, optionally delvewheel on Windows).
Tests each wheel in a fresh environment, so you know the wheel works, not just your source tree.
On Linux, the builds happen inside manylinux containers: old, standardized
distro images that guarantee the resulting wheel runs on essentially any Linux.
This is why a wheel built on ubuntu-latest doesn’t require Ubuntu.
cibuildwheel runs on GitHub Actions, GitLab CI, Azure Pipelines, and more, and also runs locally, which is a great way to debug.
Configuration¶
cibuildwheel reads configuration from [tool.cibuildwheel] in
pyproject.toml (every option also has a CIBW_* environment variable, handy
for one-off overrides in CI). A good starting set:
[tool.cibuildwheel]
test-groups = ["test"]
test-command = "pytest {project}/tests"
build-frontend = "build[uv]"test-groupsinstalls yourtestdependency-group before testing each wheel, andtest-commandruns your tests against the installed wheel;{project}is a placeholder for your source checkout, since the tests run from a different directory (you’re testing the wheel, not the source tree!).build-frontend = "build[uv]"uses uv to make the build environments, which is noticeably faster; it just needsuvon the runner.
Other options you’ll reach for eventually: skip (e.g. "*musllinux*" to drop
musl wheels), and environment (set environment variables like compiler flags
inside the build; Linux builds run in a container, so they don’t see the
host’s environment). See the options docs.
Try it locally¶
You don’t need CI to run cibuildwheel; it’s just a Python app. It builds
wheels for the platform you’re on (Linux wheels use Docker or
Podman, since they build inside manylinux containers, which also means you can
build Linux wheels from macOS or Windows). On macOS, it needs python.org Python,
so I’d recommend targeting linux there.
Building every Python version locally is slow, so there’s a --only flag that
takes a single build identifier:
uvx cibuildwheel --only cp314-manylinux_x86_64Identifiers follow the pattern <python>-<platform>, like
cp314-manylinux_x86_64, cp313-macosx_arm64, or cp312-win_amd64.
Solution to Exercise 1
uvx cibuildwheel --print-build-identifiers
uvx cibuildwheel --only cp314-macosx_arm64 # pick one from the list
ls wheelhouseThe identifier list depends on your OS; on Linux you’ll see both manylinux
and musllinux entries, which is why the count is larger there. The wheel in
wheelhouse/ has a platform-specific tag like cp314-cp314-macosx_11_0_arm64
instead of pure Python’s py3-none-any.
The workflow¶
Everything from the pure Python workflow carries over: separate CD workflow, build jobs feeding artifacts to a guarded publish job, Trusted Publishing. Only the build jobs change.
The trigger¶
Building 30+ wheels takes time, so don’t do it on every PR. Releases and the manual button are the usual triggers, plus one nice trick: rebuild when the workflow file itself changes in a PR, so you can’t break the release pipeline without noticing.
name: CD
on:
workflow_dispatch:
release:
types:
- published
pull_request:
paths:
- .github/workflows/cd.yml
jobs:The SDist job¶
Don’t forget the SDist; it’s what everyone without a matching wheel (and every distro) builds from. This is the same single job as before, just split out from wheel building:
make_sdist:
name: Make SDist
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0 # Optional, use if you use setuptools_scm
submodules: true # Optional, use if you have submodules
- uses: astral-sh/setup-uv@v8
- name: Build SDist
run: uv build --sdist
- uses: actions/upload-artifact@v7
with:
name: cibw-sdist
path: dist/*.tar.gzThe wheels job¶
The matrix is over runners only; cibuildwheel handles the loop over Python versions inside each job:
build_wheels:
name: Wheel on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os:
- ubuntu-latest
- ubuntu-24.04-arm
- windows-latest
- windows-11-arm
- macos-15-intel
- macos-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
submodules: true
- uses: astral-sh/setup-uv@v8
- uses: pypa/cibuildwheel@v4.1
- name: Upload wheels
uses: actions/upload-artifact@v7
with:
name: cibw-wheels-${{ matrix.os }}
path: wheelhouse/*.whlSix jobs, one line of real work each; all the configuration came from
pyproject.toml. Every architecture here is built natively (GitHub provides
ARM Linux/Windows runners and both macOS architectures); rarer platforms can
be emulated with QEMU, one extra setup step.
The publish job¶
Nearly identical to the pure Python version; the only difference is
collecting artifacts from seven jobs instead of one, which is what the
pattern: + merge-multiple: options do (and why we prefixed every artifact
name with cibw-):
upload_all:
needs: [build_wheels, make_sdist]
environment: pypi
permissions:
id-token: write
attestations: write
contents: read
runs-on: ubuntu-latest
if: github.event_name == 'release' && github.event.action == 'published'
steps:
- uses: actions/download-artifact@v8
with:
pattern: cibw-*
path: dist
merge-multiple: true
- name: Generate artifact attestations
uses: actions/attest-build-provenance@v4
with:
subject-path: "dist/*"
- uses: pypa/gh-action-pypi-publish@release/v1The needs: list now has both build jobs, so nothing uploads unless every
wheel built and passed its tests. Setting up Trusted Publishing (and the token
fallback) is exactly as described in
the previous publishing chapter.
Solution to Exercise 2
The full file is the four snippets above concatenated: the on: block, then
make_sdist, build_wheels, and upload_all under jobs:. Compare with
cookie’s generated cd.yml if you get stuck, or trim the matrix to a
single OS while experimenting to keep runs fast.