10 Publish to PyPI
This blog post in a work in progress.
PyPI (Python Package Index)
PyPI is the default online repository for Python packages. This is where packages are stored so that others can find and install them.
When you run:
pip install requestsYou’re downloading the requests package from PyPI. More specifically, you’re downloading the package’s distribution (which might be source code or precompiled binaries) to your local machine from PyPI servers.
You don’t need PyPI
If you’re here to learn how to publish a Python package to PyPI, you’re in the right place.
But it’s important that you become aware of the following facts:
- you don’t need PyPI to allow anyone in the world to
pip installyour package - adding your package to PyPI takes a namespace name forever (related and interesting blog post)
- it’s a bit complicated to upload your package the clean way
pip install without PyPI
If you’re package is on Github/Gitlab (it should) and is open source, then people can super easily install it with:
pip install git+https://github.com/github_username/repository_name.git- No extra work
- Updates automatically when you push changes
- Managing dependencies is harder (for users)
- You don’t have a clean
pip install package_name
The main reason behind using PyPI is that it simplifies a lot package versionning (installing a specific version of a package) and you’re sure of what you’re getting, while a Github repo might just disappear one day.
If your project is just a toy project, with no clear versionning of and actual maintenance, you probably shouldn’t put it on PyPI.
Before distributing
Before putting your package on PyPI, we need to make sure it checks all the valid boxes.
Dependencies
If you haven’t already, check out the handling dependencies blog post, which is an important pre-requisite.
Everything that is inside your pyproject.toml defines what will be used for your package once on PyPI. Fields like name or author will be displayed on the PyPI page (example of the PyPI page of scikit-learn).
But more importantly, the dependencies and Python version required will be used from this file. If you don’t know if things are missing/invalid from your pyproject.toml, check out the official guide.
Security
When distributing your package, security is critical. Since an installed package can execute arbitrary code, everything that we decide to put on PyPI should be clear.
Rules to follow absolutely:
- Keep source in sync: The code on PyPI should match the code on GitHub (or any other public repo). Users trust that what they download is the same as what’s publicly visible. Avoid “hidden” changes that only exist in the PyPI release.
- Strong authentication: Use a strong, unique password for your PyPI account and enable two-factor authentication (2FA). PyPI now requires 2FA for maintainers of critical projects, and it’s a good habit for all packages. Avoid relying on “trusted publisher” features until you fully understand them.
Distributing to PyPI
Distributing to PyPI means multiple things:
- building your package
- uploading that build to PyPI servers
Build
When we talk about building a package, we mean taking your source code and metadata (from pyproject.toml, README.md, etc.) and transforming them into distribution artifacts.
There are two main types of distributions:
- Source distribution (
sdist) → a tarball (.tar.gz) containing your source code. - Built distribution (
wheel) → a pre-built archive (.whl) that can be installed faster because it doesn’t require building on the user’s machine.
Having a proper build step matters because:
- it ensures reproducibility (everyone installing your package gets the same artifact)
- it avoids shipping incomplete or broken files
- it decouples packaging from distribution (you can build once and upload anywhere)
In practice, we don’t want to build the package manually, and for this we’ll use trust publishing.
Publishing
Once you have your built artifacts, the next step is uploading them to PyPI. Historically (and probably still a lot today), this has been done with twine:
twine upload dist/*This is still valid, but it requires you to handle credentials (API tokens) locally.
The modern, recommended approach is to use trusted publishing — where PyPI integrates directly with GitHub Actions, GitLab CI, or other CI/CD providers. This way, you don’t store secrets at all, and only builds coming from your repository can publish under your package name.
Here we’ll use Github Actions (check out the dedicated blog post), and in particular the following script:
# This is taken from
# https://packaging.python.org/en/latest/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/#the-whole-ci-cd-workflow
# but with the following differences
# - removed the TestPyPI part
# - instead of `on: push`, we have `tags` in there too
name: Publish Python 🐍 distribution 📦 to PyPI
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+*"
jobs:
build:
name: Build distribution 📦
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Install pypa/build
run: python3 -m pip install build --user
- name: Build a binary wheel and a source tarball
run: python3 -m build
- name: Store the distribution packages
uses: actions/upload-artifact@v4
with:
name: python-package-distributions
path: dist/
publish-to-pypi:
name: >-
Publish Python 🐍 distribution 📦 to PyPI
if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes
needs:
- build
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/pytest-cov
permissions:
id-token: write # IMPORTANT: mandatory for trusted publishing
steps:
- name: Download all the dists
uses: actions/download-artifact@v4
with:
name: python-package-distributions
path: dist/
- name: Publish distribution 📦 to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
github-release:
name: >-
Sign the Python 🐍 distribution 📦 with Sigstore
and upload them to GitHub Release
needs:
- publish-to-pypi
runs-on: ubuntu-latest
permissions:
contents: write # IMPORTANT: mandatory for making GitHub Releases
id-token: write # IMPORTANT: mandatory for sigstore
steps:
- name: Download all the dists
uses: actions/download-artifact@v4
with:
name: python-package-distributions
path: dist/
- name: Sign the dists with Sigstore
uses: sigstore/gh-action-sigstore-python@v3.0.0
with:
inputs: >-
./dist/*.tar.gz
./dist/*.whl
- name: Create GitHub Release
env:
GITHUB_TOKEN: ${{ github.token }}
run: >-
gh release create
"$GITHUB_REF_NAME"
--repo "$GITHUB_REPOSITORY"
--notes ""
- name: Upload artifact signatures to GitHub Release
env:
GITHUB_TOKEN: ${{ github.token }}
# Upload to GitHub Release using the `gh` CLI.
# `dist/` contains the built packages, and the
# sigstore-produced signatures and certificates.
run: >-
gh release upload
"$GITHUB_REF_NAME" dist/**
--repo "$GITHUB_REPOSITORY"What happens in the workflow? Once a tag is pushed:
- Build job: GitHub Actions checks out your repo, installs
build, and creates both the wheel (.whl) and the source distribution (.tar.gz). - Publish job: Those artifacts are uploaded directly to PyPI using trusted publishing (no API tokens needed).
- GitHub Release job: The distributions are signed with Sigstore and attached to a GitHub Release, so users can download the same artifacts outside PyPI if they prefer.
For the above script to work, you first need to add a publisher in PyPI, and this for each package you have.
The best way to use the GitHub Actions workflow above is to pair it with a small release script like this:
#!/bin/bash
# Usage: ./release.sh 1.2.3
# Assumes version is set in pyproject.toml or setup.cfg
set -e
VERSION=$1
if [[ -z "$VERSION" ]]; then
echo "❌ Error: No version number supplied."
echo "👉 Usage: ./release.sh 1.2.3"
exit 1
fi
TAG="v$VERSION"
# Confirm version bump is in code
echo "📦 Preparing release: $TAG"
grep "$VERSION" pyproject.toml || {
echo "❌ Version $VERSION not found in pyproject.toml. Did you forget to bump it?"
exit 1
}
# Commit the version bump
git add -A
git commit -m "Release $TAG"
# Create tag
git tag "$TAG"
# Push commit and tag
git push origin main
git push origin "$TAG"
echo "✅ Release $TAG pushed! GitHub Actions will handle the rest."Save this script as release.sh (for example), make it executable (chmod +x release.sh), and keep it at the root of your repository.
How to use it
Bump the version in
pyproject.toml.Run:
./release.sh 1.2.3The script will:
- check the version string exists in
pyproject.toml - commit the change
- tag it as
v1.2.3 - push both the commit and the tag
- check the version string exists in
This way:
- you don’t need to manually run
python -m buildortwine upload - you don’t need to manage PyPI tokens or secrets
- releases are reproducible and cryptographically signed
- the release process becomes a single command:
./release.sh 1.2.3