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:

  1. 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.
  2. In your GitHub repository, navigate to Settings > Secrets and variables > Actions.
  3. Click New repository secret.
  4. Create a secret named PYPI_API_TOKEN and 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.