44.8 Continuous Delivery of Packages with GitHub Actions
Continuous delivery for Python packages automates the release process, transforming it from a manual, error-prone series of steps into a reliable, repeatable, and traceable workflow. By leveraging GitHub Actions, you can configure your repository to automatically build and publish a new version of your package to PyPI whenever a new Git tag is pushed. This practice, often called “tag-and-release,” ensures that your published package artifacts are always built in a clean, consistent environment, eliminating the “works on my machine” problem and significantly reducing the risk of human error during deployment.
Configuring GitHub Secrets for Authentication
The most critical security aspect of this workflow is safely handling your PyPI credentials. You must never hardcode API tokens or passwords into your workflow file. Instead, GitHub Secrets provide a secure way to store sensitive information that your actions can access at runtime.
To set up the necessary secrets:
- Obtain a PyPI API token from https://pypi.org/manage/account/. For enhanced security, create a token scoped only to the specific project you are publishing.
- In your GitHub repository, navigate to Settings > Secrets and variables > Actions.
- Click New repository secret.
- Create a secret named
PYPI_API_TOKENand paste your token as its value.
This token will be used by the pypa/gh-action-pypi-publish action to authenticate with PyPI on your behalf.
Creating the Release Workflow
The core of continuous delivery is a YAML file defining your workflow, placed in the .github/workflows/ directory of your repository. This file instructs GitHub Actions on when and how to run.
# .github/workflows/release.yml
name: Publish Python Package to PyPI
on:
release:
types: [published]
jobs:
build-and-publish:
name: Build and publish to PyPI
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Install build dependencies
run: |
python -m pip install --upgrade pip
pip install build
- name: Build package distributions
run: python -m build
- name: Verify distributions with twine
run: |
pip install twine
twine check dist/*
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
user: __token__
password: ${{ secrets.PYPI_API_TOKEN }}
This workflow is triggered by the release event, specifically when a new release is published (which happens when you create a release associated with a Git tag). The job then runs on a latest Ubuntu runner. Key steps include checking out the code, setting up Python, installing build to create the source distribution (sdist) and wheel (bdist_wheel), and using twine check to validate the built packages before upload. Finally, the dedicated pypa/gh-action-pypi-publish action handles the secure upload to PyPI using the API token stored in your secrets.
Testing Against Multiple Python Versions
Before publishing, it is a best practice to ensure your package builds and passes its test suite across all versions of Python it claims to support. This prevents releasing a broken package that only works in your local development environment. A matrix strategy allows you to parallelize this testing efficiently.
# Excerpt added to the release.yml workflow
jobs:
test:
name: Test on Python ${{ matrix.python-version }}
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]" # Install package in editable mode with dev extras
- name: Run tests
run: pytest -x --cov=your_package_name tests/
build-and-publish:
name: Build and publish
needs: test # This ensures tests pass before publishing
runs-on: ubuntu-latest
# ... rest of the build-and-publish job
Here, the test job runs for each Python version defined in the matrix. The build-and-publish job uses needs: test to create a dependency, meaning it will only execute if all instances of the test job complete successfully. This gatekeeping is essential for maintaining quality.
Managing TestPyPI and Production PyPI
A robust continuous delivery pipeline includes a staging environment. For PyPI, this is TestPyPI (https://test.pypi.org/), a separate package index that mirrors the real PyPI. You should always publish to TestPyPI first to validate the entire process end-to-end before executing the production release.
To implement this, create a separate workflow or use environment protection rules. The following example uses a manual trigger (workflow_dispatch) for production and a different trigger for staging.
# .github/workflows/release.yml
name: Publish Python Package
on:
release:
types: [published]
workflow_dispatch:
inputs:
environment:
type: choice
description: 'Select target'
options: ['testpypi', 'pypi']
required: true
default: 'testpypi'
jobs:
build-and-publish:
# ... setup steps remain the same
- name: Publish to TestPyPI
if: github.event_name == 'release' || github.event.inputs.environment == 'testpypi'
uses: pypa/gh-action-pypi-publish@release/v1
with:
repository-url: https://test.pypi.org/legacy/
user: __token__
password: ${{ secrets.TEST_PYPI_API_TOKEN }}
- name: Publish to Production PyPI
if: github.event.inputs.environment == 'pypi'
uses: pypa/gh-action-pypi-publish@release/v1
with:
user: __token__
password: ${{ secrets.PYPI_API_TOKEN }}
This configuration requires you to create a second secret, TEST_PYPI_API_TOKEN, with a token from TestPyPI. The if conditions control which action runs. The automatic release trigger will only publish to TestPyPI. To publish to production PyPI, you would manually trigger the workflow via the GitHub UI and select ‘pypi’—a final manual approval step for production deploys.