Table of contents

GitHub Actions: Binary wheels

Building binary wheels is a bit more involved, but can still be done effectively with GHA. This document will introduce cibuildwheel for use in your project. We will focus on GHA below.

Wheel building should only happen rarely, so you will want to limit it to releases, and maybe a rarely moving branch or other special tag (such as stable if you mostly update some other branch. You may occasionally want to trigger wheels manually.

name: Wheels

on:
  workflow_dispatch:
  release:
    types:
      - published

This will run on releases. If you use a develop branch, you could include pull_request: branches: [stable], since it changes rarely. GitHub actions also has a workflow_dispatch option, which will allow you to click a button in the GUI to trigger a build, which is perfect for testing wheels before making a release; you can download them from the “artifacts”. You can even define variables that you can set in the GUI and access in the CI!

Useful suggestion:

Since these variables will be used by all jobs, you could make them available in your pyproject.toml file, so they can be used everywhere (even locally for Linux and Windows):

[tool.cibuildwheel]
test-extras = "test"
test-command = "pytest {project}/tests"
# Optional
build-verbosity = 1

The test-extras will cause the pip install to use [test]. The test-command will use pytest to run your tests. You can also set the build verbosity (-v in pip) if you want to.

Making an SDist

You probably should not forget about making an SDist! A simple job, like before, will work:

make_sdist:
  name: Make SDist
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
      with:
        fetch-depth: 0 # Optional, use if you use setuptools_scm
        submodules: true # Optional, use if you have submodules

    - name: Build SDist
      run: pipx run build --sdist

    - uses: actions/upload-artifact@v4
      with:
        name: cibw-sdist
        path: dist/*.tar.gz

You can instead install build via pip and use python -m build --sdist. You can also pin the version with pipx run --spec build==... build.

The core job (3 main OS’s)

The core of the work is down here:

build_wheels:
  name: Wheel on ${{ matrix.os }}
  runs-on: ${{ matrix.os }}
  strategy:
    fail-fast: false
    matrix:
      os: [ubuntu-latest, windows-latest, macos-13, macos-14]

  steps:
    - uses: actions/checkout@v4
      with:
        fetch-depth: 0
        submodules: true

    - uses: pypa/cibuildwheel@v2.17

    - name: Upload wheels
      uses: actions/upload-artifact@v4
      with:
        name: cibw-wheels-${{ matrix.os }}
        path: wheelhouse/*.whl

There are several things to note here. First, one of the reasons this works is because you followed the suggestions in the previous sections, and your package builds nicely into a wheel without strange customizations (if you really need them, check out CIBW_BEFORE_BUILD and CIBW_ENVIRONMENT).

This lists all three OS’s; if you do not support Windows, you can remove that here. If you would rather make universal2 wheels for macOS, you can remove either the Intel (macos-13) or Apple Silicon (macos-14) job and set CIBW_ARCHS_MACOS to "universal2". You can also set CIBW_TEST_SKIP to "*universal2:arm64" if building from Intel to acknowledge you understand that you can’t test Apple Silicon from Intel. You can do this from the pyproject.toml file instead if you want.

The build step is controlled almost exclusively through environment variables, which makes it easier (usually) to setup in CI. The main variable needed here is usually CIBW_BUILD to select the platforms you want to build for - see the docs here for all the identifiers. Note that the ARM and other alternative architectures need emulation, so are not shown here (adds one extra step).

You can also select different base images (the default is manylinux2014). If you want a different supported image, set CIBW_MANYLINUX_X86_64_IMAGE, CIBW_MANYLINUX_I686_IMAGE, etc. If you always need a specific image, you can set that in the pyproject.toml file instead.

Publishing

upload_all:
  needs: [build_wheels, make_sdist]
  environment: pypi
  permissions:
    id-token: write
  runs-on: ubuntu-latest
  if: github.event_name == 'release' && github.event.action == 'published'
  steps:
    - uses: actions/download-artifact@v4
      with:
        pattern: cibw-*
        path: dist
        merge-multiple: true

    - uses: pypa/gh-action-pypi-publish@release/v1

When you make a GitHub release in the web UI, we publish to PyPI. You’ll just need to tell PyPI which org, repo, workflow, and set the pypi environment to allow pushes from GitHub. If it’s the first time you’ve published a package, go to the PyPI trusted publisher docs for instructions on preparing PyPI to accept your initial package publish.

If you have multiple jobs, you will want to collect your artifacts from above. If you only have one job, you can combine this into a single job like we did for pure Python wheels, using dist instead of wheelhouse. If you upload from multiple places, you can set skip_existing (but generally it’s better to not try to upload the same file from two places - you can trick Travis into avoiding the sdist, for example).

Other architectures

On Travis, cibuildwheel even has the ability to create ARM and PowerPC builds natively. IBM Z builds are also available but in beta. However, due to Travis CI’s recent dramatic reduction on open source support, emulating these architectures on GHA or Azure is probably better. Maybe look into Cirrus CI, which has some harder-to-find architectures.