Skip to main content

Publish Python Packages to PyPI with GitHub Actions

Publishing a Python package to PyPI (Python Package Index) manually is error-prone: forget to increment the version, miss running tests, or upload stale wheels. Automating publication in GitHub Actions eliminates these mistakes and enables one-command releases: push a version tag, and the workflow automatically builds wheels, runs tests, and publishes to PyPI.

This guide covers configuring PyPI credentials securely, building and testing wheels, and uploading to PyPI using twine. By the end, you'll release new versions with a single git tag command.

Preparing Your Package for PyPI

Every package on PyPI requires a unique name, valid version, and metadata. Configure these in pyproject.toml:

[project]
name = "mypackage"
version = "1.0.0"
description = "A useful Python package"
authors = [{name = "Alice", email = "[email protected]"}]
license = {text = "MIT"}
readme = "README.md"
requires-python = ">=3.9"
dependencies = ["requests>=2.25.0"]

[project.urls]
Homepage = "https://github.com/alice/mypackage"
Repository = "https://github.com/alice/mypackage.git"
Documentation = "https://mypackage.readthedocs.io"

Ensure the name is unique; check https://pypi.org to confirm. The version should follow MAJOR.MINOR.PATCH (semantic versioning).

Storing PyPI Credentials Securely

Never commit PyPI API tokens to your repository. Instead, store them as GitHub Actions secrets:

  1. Go to your repository Settings > Secrets and variables > Actions
  2. Click New repository secret
  3. Create a secret named PYPI_API_TOKEN with your PyPI token
  4. Create a secret named TEST_PYPI_API_TOKEN for TestPyPI (optional but recommended for testing)

To get a PyPI API token:

  1. Go to https://pypi.org
  2. Sign in or create an account
  3. Navigate to Account settings > API tokens
  4. Create a token (keep it secret; regenerate if leaked)

Creating a Publish Workflow

Create .github/workflows/publish.yml:

name: Publish to PyPI

on:
push:
tags:
- 'v*'

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install build dependencies
run: |
python -m pip install --upgrade pip
pip install build twine
- name: Build distributions
run: python -m build
- name: Verify distribution metadata
run: twine check dist/*
- name: Store artifacts
uses: actions/upload-artifact@v4
with:
name: distributions
path: dist/

test:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: distributions
path: dist/
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install wheel and test
run: |
pip install dist/mypackage*.whl
python -c "import mypackage; print(f'Version: {mypackage.__version__}')"

publish:
needs: test
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags')
steps:
- uses: actions/download-artifact@v4
with:
name: distributions
path: dist/
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@v1
with:
password: ${{ secrets.PYPI_API_TOKEN }}

This workflow:

  1. Triggers on version tags (e.g., git tag v1.0.0 && git push origin v1.0.0)
  2. Builds wheels and source distributions
  3. Verifies metadata with twine check
  4. Tests the built wheel in a fresh environment
  5. Publishes to PyPI if tests pass

The needs: keyword chains jobs; test waits for build, and publish waits for test.

Using TestPyPI for Dry Runs

Before publishing to the live PyPI, test your workflow on TestPyPI (a staging environment):

- name: Publish to TestPyPI
uses: pypa/gh-action-pypi-publish@v1
with:
repository-url: https://test.pypi.org/legacy/
password: ${{ secrets.TEST_PYPI_API_TOKEN }}

Publish to TestPyPI first, verify the package installs correctly, then update the workflow to publish to production PyPI.

Versioning and Release Tags

Automate version bumping using setuptools_scm (reads version from git tags):

[tool.setuptools_scm]
version_attr = "mypackage.__version__"

Then, instead of hardcoding version in pyproject.toml, derive it from git tags:

git tag v1.0.0
git push origin v1.0.0

The workflow detects the tag, builds the distribution with version 1.0.0, and publishes.

Validating Before Publishing

Always run tests before publishing:

- name: Run tests
run: pytest tests/
- name: Build distributions
run: python -m build
- name: Check metadata
run: twine check dist/*

The twine check command validates that your package metadata (name, version, author, etc.) is correct. Common issues: missing README, invalid license field, or invalid version format.

Publishing Multiple Distributions

Your workflow builds both wheels and source distributions (sdist). PyPI hosts both; users can choose. The pypa/gh-action-pypi-publish action uploads all files in dist/:

- name: Build distributions (wheel + sdist)
run: python -m build # Builds *.whl and *.tar.gz

To publish only wheels (not recommended):

- name: Publish wheels only
run: twine upload dist/*.whl

Handling Failed Publishes

If publishing fails (network error, duplicate version), the workflow stops. To prevent duplicate publishes:

  1. Never re-tag the same version number
  2. Check PyPI before publishing: twine check dist/* validates locally; PyPI does additional validation
  3. Use a pre-publish check to ensure the version doesn't already exist:
- name: Check if version exists on PyPI
run: |
VERSION=$(python -c "import setuptools_scm; print(setuptools_scm.get_version())")
curl -s https://pypi.org/pypi/mypackage/$VERSION/json | jq -e '.info.version' && exit 1 || exit 0

Key Takeaways

  • Store PyPI API tokens as GitHub Actions secrets, not in your repository.
  • Configure package metadata in pyproject.toml (name, version, author, dependencies).
  • Use python -m build to create wheels and source distributions.
  • Chain jobs with needs: to run tests before publishing.
  • Use pypa/gh-action-pypi-publish to upload to PyPI securely.
  • Test on TestPyPI before publishing to production.
  • Trigger releases with version tags (e.g., git tag v1.0.0).

Frequently Asked Questions

What is the difference between PyPI and TestPyPI?

PyPI is the official Python Package Index. TestPyPI is a staging environment for testing. Always test your workflow on TestPyPI first; you can publish the same version multiple times to TestPyPI but only once to PyPI.

Can I publish to PyPI without a tag trigger?

Yes, but tagging is recommended for releases. You can also trigger on branch push or manually via workflow_dispatch, though this introduces risks (accidental publishes, version conflicts).

What if I want to publish a pre-release version (alpha, beta)?

Use pre-release version numbers: 1.0.0a1 (alpha), 1.0.0b1 (beta), 1.0.0rc1 (release candidate). PyPI treats these as pre-releases and won't suggest them as default installs.

How do I unpublish a package from PyPI?

You can't delete a released version (immutability prevents breaking users' pins). Yanked versions are marked as unsafe but remain downloadable. Go to your package's settings and mark the version as yanked.

Can I use PyPI's trusted publisher instead of API tokens?

Yes. GitHub can authenticate to PyPI using OpenID Connect (OIDC), eliminating secrets. Use pypa/gh-action-pypi-publish@v1 with id-provider: github-token (requires PyPI configuration). This is more secure than tokens.

Further Reading