Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Publishing with cibuildwheel

Slides

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:

3 OSs×2 architectures×5 CPython versions=30 wheels3 \text{ OSs} \times 2 \text{ architectures} \times 5 \text{ CPython versions} = 30 \text{ wheels}

...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:

  1. Installs every Python version it needs (you don’t set up Python yourself).

  2. Builds a wheel for each one with your normal build backend.

  3. 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).

  4. 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:

pyproject.toml
[tool.cibuildwheel]
test-groups = ["test"]
test-command = "pytest {project}/tests"
build-frontend = "build[uv]"

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_64

Identifiers 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 wheelhouse

The 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.

.github/workflows/cd.yml
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.gz

The 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/*.whl

Six 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/v1

The 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.