Modern Python Package Publishing: PyGeoHash's New CI/CD Pipeline
Gone are the days of manually building wheels and uploading them to PyPI – we now have a fully automated pipeline using GitHub Actions and PyPI’s Trusted Publisher system. Let me walk you through how we set this up, because it’s pretty cool.
The Old Way vs The New Way
Back in the day (like, last month), releasing PyGeoHash went something like this:
- Build the source distribution locally
- Build wheels for each platform (hopefully)
- Find that PyPI token I stored somewhere “safe”
- Cross fingers and upload
- Realize I forgot to build wheels for ARM
- Do it all again
Now? I just tag a release, and GitHub Actions does everything else. Let’s dive into how this works.
Our Publishing Pipeline
The new workflow kicks in automatically when I push a tag starting with ‘v’ (like v3.0.0). Here’s what happens:
1. Building Wheels
First, we build wheels for every platform we support:
jobs:
build_wheels:
name: Build wheels on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
steps:
- uses: actions/checkout@v3
- name: Build wheels
run: python -m cibuildwheel --output-dir wheelhouse
env:
CIBW_SKIP: "pp* *-musllinux*" # Skip PyPy and musllinux
CIBW_ARCHS_MACOS: "x86_64 arm64" # Build for Intel and Apple Silicon
This is where the magic happens. We’re using cibuildwheel
, which is like a Swiss Army knife for wheel building. It:
- Builds wheels for Python 3.8 through 3.12
- Handles both Intel and ARM architectures on macOS
- Takes care of all the platform-specific quirks
- Makes sure our C extension compiles everywhere
2. Building the Source Distribution
In parallel, we build the source distribution (sdist):
build_sdist:
name: Build source distribution
runs-on: ubuntu-latest
steps:
- name: Build source distribution
run: |
# Clean build directories
rm -fr build/ dist/ .eggs/
find . -name '*.egg-info' -exec rm -fr {} +
# Build the sdist
uv run python -m build --sdist
We’re using uv
here instead of pip because it’s blazing fast and has excellent dependency resolution. The cleanup step is probably overkill, but hey, better safe than sorry.
3. Publishing to PyPI
Here’s where PyPI’s Trusted Publisher system comes in:
publish:
needs: [build_wheels, build_sdist]
runs-on: ubuntu-latest
permissions:
id-token: write # Required for trusted publishing
steps:
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: dist_flat/
No more API tokens to manage! The Trusted Publisher system uses OpenID Connect to verify that the upload is coming from our specific GitHub Actions workflow. It’s more secure and way less hassle.
You set this up on the PyPI side. I’ve had to do a first publishing manually or with a token to get it started, but then remove the token and do this forward.
Why This Matters
This new setup gives us several benefits:
- Consistency: Every release is built the same way, every time
- Coverage: We automatically build wheels for all supported platforms and Python versions
- Security: No more API tokens floating around
- Speed: Parallel builds mean faster releases
- Reliability: If something goes wrong, we can see exactly what happened in the GitHub Actions logs
Setting It Up Yourself
If you maintain a Python package, especially one with C extensions like PyGeoHash, here’s what you need:
- Set up trusted publishing in your PyPI account settings
- Create a
.github/workflows/publish-pypi.yml
file with your build configuration - Configure
cibuildwheel
for your specific needs - Start tagging releases!
The hardest part was probably figuring out the right cibuildwheel
configuration for our C extension. We had to make sure we were building for both Intel and ARM on macOS.
A Note on PyPy and musllinux
You might have noticed this line in our configuration:
CIBW_SKIP: "pp* *-musllinux*" # Skip PyPy and musllinux
Let’s talk about why we skip these builds and what you might want to consider for your own package.
PyPy Support
We currently skip PyPy builds because our C extension isn’t tested against PyPy’s JIT compiler. If you want to support PyPy, you’ll need to:
- Test C Extension Compatibility: PyPy’s C API implementation differs from CPython
- Provide Pure Python Fallbacks: Consider having a pure Python implementation that PyPy can use
- Use CFFI Instead: If you need native code, CFFI works better with PyPy than the C API
Here’s what the config might look like if you want to support PyPy:
env:
CIBW_BUILD: "cp* pp*" # Build for both CPython and PyPy
CIBW_BEFORE_BUILD_LINUX: |
if [[ $PYPY == 1 ]]; then
pip install cffi # For PyPy builds
fi
musllinux Support
musllinux wheels are built against musl libc instead of glibc. We skip these for now for simplicity, but may add them later to better support alpine linux.
But if you want to support musllinux (which can be great for Alpine Linux users), here’s what you need:
env:
CIBW_BUILD: "*" # Build all platforms
CIBW_BEFORE_BUILD_LINUX: |
# Install musl-dev for musllinux builds
if [[ "$CIBW_BUILD" == *"musllinux"* ]]; then
apk add musl-dev
fi
You’ll also need to ensure your C code is compatible with musl libc, which means:
- Avoiding glibc-specific features
- Testing thread-local storage carefully
- Being cautious with errno handling
- Checking all your dependencies work with musl
For most packages, I’d recommend starting with standard Linux wheels and only adding musllinux support if users specifically request it. The maintenance overhead can be significant.
Looking Forward
This automation is part of our ongoing efforts to modernize PyGeoHash’s infrastructure. Combined with our recent type hints update, we’re making the library more maintainable and reliable.
Next up? We’re looking at adding some automated performance benchmarking to make sure our C extension stays fast across all platforms. But that’s a story for another post!
Want to see the full workflow? Check out our publish-pypi.yml on GitHub. And as always, if you have suggestions for improvements, our issue tracker is open!
Subscribe to the Newsletter
Get the latest posts and insights delivered straight to your inbox.