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
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 }}.*" }
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
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 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.