Project Setup

Building Cross-Platform Release Binaries in CI

Build, verify and publish Python CLI binaries for Linux, macOS and Windows from one tagged release - matrix jobs, artifact naming, checksums and rollbacks.

Updated

A frozen binary must be built on the platform it targets, so shipping to Linux, macOS and Windows means three or four builds per release. Doing that by hand is where release processes rot; doing it in a tagged CI workflow makes the whole thing one git push --follow-tags.

TL;DR

  • One job builds the wheel; a matrix builds one binary per platform.
  • Verify inside the matrix — run the artifact on the runner that produced it.
  • Name artifacts mytool-<os>-<arch> so downloads are unambiguous.
  • Publish checksums with every release, generated in CI rather than by hand.
  • Trigger on a tag, never on a branch push, so releases are reproducible from history.

Prerequisites

A project that already builds a wheel, a working freeze or zipapp step (see PyInstaller and shiv), and a single-sourced version as described in managing CLI versioning.

The target matrix

A release matrix that covers real users A table of build targets for cross-platform binary releases, listing the runner, the produced artifact and the naming convention. A release matrix that covers real users Target Runner Artifact Linux x86-64 ubuntu-latest mytool-linux-x86_64.tar.gz macOS arm64 macos-latest mytool-macos-arm64.tar.gz macOS x86-64 macos-13 mytool-macos-x86_64.tar.gz Windows x86-64 windows-latest mytool-windows-x86_64.zip A frozen binary must be built on the platform it targets — there is no cross-compilation.

Four targets cover the overwhelming majority of users. macOS needs two entries because Apple silicon and Intel are different architectures and there is no cross-compilation; the Intel build runs on an older macos-13 image, which still has x86-64 runners.

Linux deserves one decision: build on the oldest distribution you intend to support, because a binary linked against a newer glibc will not run on an older system. ubuntu-22.04 is a reasonable default; a manylinux container is the thorough answer if you need to reach further back. Alpine and other musl systems need their own build entirely.

The workflow

name: release
on:
  push:
    tags: ["v*"]

permissions:
  contents: write        # to create the release
  id-token: write        # for trusted publishing to PyPI

jobs:
  wheel:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v3
      - run: uv build
      - uses: actions/upload-artifact@v4
        with: { name: wheel, path: dist/ }

  binaries:
    strategy:
      fail-fast: false             # one broken platform must not hide the others
      matrix:
        include:
          - { os: ubuntu-22.04,  target: linux-x86_64,   ext: tar.gz }
          - { os: macos-latest,  target: macos-arm64,    ext: tar.gz }
          - { os: macos-13,      target: macos-x86_64,   ext: tar.gz }
          - { os: windows-latest, target: windows-x86_64, ext: zip }
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v3
      - run: uv sync --frozen --no-dev
      - run: uv run --with pyinstaller pyinstaller mytool.spec --clean --noconfirm

      # verification, on the platform that produced it
      - run: ./dist/mytool/mytool --version
        if: runner.os != 'Windows'
      - run: .\dist\mytool\mytool.exe --version
        if: runner.os == 'Windows'

      - name: package
        shell: bash
        run: |
          cd dist
          if [ "${{ matrix.ext }}" = "zip" ]; then
            7z a "../mytool-${{ matrix.target }}.zip" mytool
          else
            tar czf "../mytool-${{ matrix.target }}.tar.gz" mytool
          fi
      - uses: actions/upload-artifact@v4
        with: { name: "binary-${{ matrix.target }}", path: "mytool-${{ matrix.target }}.*" }
From tag to downloadable binaries A release pipeline triggered by a tag: build a wheel once, build a binary per platform, verify each one, then attach them all to the release. From tag to downloadable binaries Build wheel the shared source of truth tag Freeze per OS one job per target fan out Run --version on that platform verify Attach checksums and notes publish every artifact is verified on the platform that produced it before anything is published Verification inside the matrix is what stops a broken macOS build shipping alongside three good ones.

Three choices in there are worth calling out. fail-fast: false means a broken Windows build does not cancel the three that were about to succeed, so you learn everything from one run. uv sync --frozen --no-dev freezes from exactly the runtime dependency set, which keeps the binary small and catches a dev dependency that leaked into runtime. And the verification step runs before packaging, so a broken artifact never reaches the upload.

Checksums and the release

Publishing something users can verify Each release artifact is hashed, the hashes are collected into one checksums file, and both are attached to the release for verification after download. Publishing something users can verify Artifacts one per platform sha256sum per file checksums.txt attached to the release User verifies before running it hash collect download A binary from the internet with no published hash is one nobody security-conscious can adopt.
  publish:
    needs: [wheel, binaries]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
        with: { path: artifacts, merge-multiple: true }
      - name: checksums
        run: |
          cd artifacts
          sha256sum mytool-* > checksums.txt
          cat checksums.txt
      - uses: softprops/action-gh-release@v2
        with:
          files: artifacts/*
          generate_release_notes: true
      - uses: pypa/gh-action-pypi-publish@release/v1
        with: { packages-dir: artifacts }

Generating the checksums in CI rather than locally is the point: they then describe the artifacts that were actually published, produced by a workflow anyone can read. A hash computed on a laptop and pasted into a release description proves considerably less.

Naming and stability

Artifact names are an interface. Scripts, package manager manifests and documentation all encode them, so pick a convention and keep it:

mytool-1.4.0-linux-x86_64.tar.gz
mytool-1.4.0-macos-arm64.tar.gz
mytool-1.4.0-windows-x86_64.zip
checksums.txt

Include the version in the filename so downloads are self-describing, and — if you can — publish a latest redirect so install snippets do not need editing on every release. Renaming an artifact between versions breaks every Homebrew formula, Scoop manifest and install script that referenced it, which makes it a breaking change in everything but name.

UX considerations

Make failure visible per platform. With fail-fast: false and clear job names, a red release run says which platform broke. That is the difference between a five-minute fix and an investigation.

Publish release notes from the changelog. generate_release_notes: true produces something from commit titles; a curated changelog entry is better, and if you already derive one from conventional commits it is free.

Keep the download instructions next to the artifacts. A release page listing four .tar.gz files and nothing else leaves a reader guessing which is theirs. Two lines in the release body — the recommended pipx command and the verification snippet — cost nothing to template.

Testing the behaviour

The release workflow itself deserves a dry run before you rely on it. Two techniques help.

Build on pull requests without publishing. A second trigger that runs the matrix and uploads artifacts, but skips the release and PyPI steps, keeps the build honest between releases — so the first time you tag is not the first time the matrix runs.

on:
  push:
    tags: ["v*"]
  pull_request:
    paths: ["mytool.spec", ".github/workflows/release.yml", "pyproject.toml"]

Rehearse the whole thing on a pre-release tag. v1.4.0-rc.1 exercises every step including the release creation, and a pre-release is easy to delete. Doing it once after any change to the workflow is cheap insurance against discovering a permissions problem halfway through a real release.

Conclusion

A tagged workflow with a build matrix, in-matrix verification and generated checksums turns multi-platform distribution from a manual ritual into a push. The parts that matter are verifying each artifact on its own platform, never failing fast across the matrix, and keeping artifact names stable enough that everything downstream can depend on them.

Frequently asked questions

Should the release run on a tag or on a branch?

A tag. It makes the release reproducible from history, prevents accidental publishes from a merge, and gives you a natural version source. Pair it with a workflow_dispatch trigger if you need to re-run the publish step without creating another tag.

How do I handle a release that fails halfway through?

Design the jobs so re-running is safe: builds are idempotent, and the publish job should tolerate artifacts that already exist. If PyPI has already accepted the wheel, that version is spent — yank it and release a patch rather than trying to overwrite.

Do you need to sign the binary? A decision diagram: signing and notarisation are required for macOS distribution outside a package manager, and optional elsewhere. Do you need to sign the binary? Will users download the binary directly on macOS or Windows? Yes — a download link on a website Sign it or Gatekeeper blocks it No — Homebrew, Scoop or a CI runner Checksums are enough the manager handles trust An unsigned macOS download shows a warning most users will not click past.
Do I need to sign the binaries?

For direct downloads on macOS, effectively yes: Gatekeeper blocks unsigned executables and most users will not work around it. Windows SmartScreen warns rather than blocks, which is survivable but unhelpful. Distributing through Homebrew or Scoop avoids the question, since the manager handles trust.

Can one job build for several architectures?

For pure-Python artifacts like a wheel or a zipapp, yes — one build serves everyone. For frozen binaries, no: each needs a native runner. Emulation through QEMU works for Linux arm64 and is slow enough that a native runner is usually worth the configuration.

How large should a release be?

For a typical CLI, four archives of 15–40 MB each. If you are pushing past that, check what the freeze pulled in with a --onedir build — the usual culprit is a transitive dependency dragging in a scientific stack that only one command needs, which is a good argument for making it an optional extra.

How do I keep CI costs reasonable with a four-way matrix?

Only run the matrix when it can produce something: on tags, and on pull requests that touch the packaging configuration. macOS runners in particular are billed at a multiple of Linux ones, so a matrix that fires on every commit to every branch is expensive for no benefit. Caching the dependency install with a key derived from the lockfile removes most of the remaining time.

Should the wheel and the binaries come from the same commit?

Always, and from the same tag. If the wheel is built in one workflow and the binaries in another, nothing guarantees they contain the same code, and a user comparing pipx install behaviour with a downloaded binary will eventually find the difference. One workflow, one tag, one set of artifacts.

What belongs in the release body?

The install line for the recommended route, the verification command for direct downloads, and the changelog entry for this version. Everything else — a table of every artifact, build metadata, a list of contributors — is generated noise that pushes the useful three lines below the fold.

How do I roll back a bad binary release?

Delete or mark the GitHub release as a pre-release so it stops being "latest", then tag a patch and let the workflow publish a corrected set. Do not replace assets on an existing release: anyone who already downloaded has a file whose checksum no longer matches the published list, which is far more confusing than a new version. If a package manager already picked it up, revert the formula or manifest in the same pass.