Publishing to PyPI — trusted publishing and CI

Cutting a release, trusted publishing from GitHub Actions, and yanking a bad version safely.

🚚 Module 9 9 min read Not started

Why this matters

Publishing your first PyPI package is a rite of passage — it forces you to think about naming, versioning, dependencies, docs, CI, and how others will use your code. This lesson is a step-by-step recipe for shipping a small, polished library.

Learning objectives

  1. Pick a package name and avoid common pitfalls.
  2. Structure a publishable library.
  3. Build, version, and tag releases.
  4. Publish via uv (token or trusted publishing).
  5. Maintain — issues, PRs, deprecations.

1. Before you publish: should you?

A few questions:

  • Does PyPI already have a package solving this? Use it.
  • Is this a 50-line helper that's better as a copy-paste recipe?
  • Are you willing to support it for ≥ 6 months?
  • Does it have docs and tests?

If yes to all, publish. If no, gist / copy-paste / private repo.


2. Naming

Names on PyPI are first-come-first-served and can't be reused. Pick wisely:

  • Short, descriptive, lower-case-with-hyphens: httpx, polars, pydantic-settings.
  • Avoid trademarks and existing names (search PyPI before committing).
  • The import name (in code) uses underscores: my_package for my-package.
  • Avoid bare nouns (utils, tools); they'll collide.

Reserve before you forget by uploading an empty 0.0.0:

toml
[project]
name = "my-package"
version = "0.0.0"

Then squat with a placeholder README.


3. Repository structure

my-package/
├── pyproject.toml
├── README.md                ← appears as the PyPI page
├── LICENSE
├── CHANGELOG.md
├── .gitignore
├── .python-version
├── uv.lock
├── .pre-commit-config.yaml
├── .github/
│   └── workflows/
│       ├── ci.yml
│       └── release.yml
├── src/
│   └── my_package/
│       ├── __init__.py
│       └── core.py
├── tests/
│   └── test_core.py
└── docs/                    ← optional but recommended for non-trivial libs
    ├── index.md
    └── api.md

4. The pyproject.toml for a library

toml
[project]
name = "my-package"
version = "0.1.0"
description = "A friendly HTTP retry helper."
readme = "README.md"
license = { text = "MIT" }
authors = [{ name = "Ada Lovelace", email = "ada@example.com" }]
requires-python = ">=3.10"
keywords = ["http", "retry", "async"]
classifiers = [
    "Development Status :: 4 - Beta",
    "Intended Audience :: Developers",
    "License :: OSI Approved :: MIT License",
    "Programming Language :: Python :: 3",
    "Programming Language :: Python :: 3.10",
    "Programming Language :: Python :: 3.11",
    "Programming Language :: Python :: 3.12",
    "Programming Language :: Python :: 3.13",
    "Topic :: Software Development :: Libraries",
    "Typing :: Typed",
]
dependencies = [
    "httpx>=0.27",
    "tenacity>=8",
]

[project.optional-dependencies]
test = ["pytest>=8", "pytest-asyncio", "respx"]

[project.urls]
Homepage      = "https://github.com/me/my-package"
Documentation = "https://my-package.readthedocs.io"
Issues        = "https://github.com/me/my-package/issues"
Changelog     = "https://github.com/me/my-package/blob/main/CHANGELOG.md"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/my_package"]

Classifiers

Browse https://pypi.org/classifiers/. Pick relevant ones:

  • Development Status: 1 - Planning7 - Inactive.
  • Programming Language: list every Python version you test.
  • Topic, Intended Audience, License.

Typing :: Typed

Include this classifier and add a py.typed marker file:

src/my_package/py.typed         (empty file)

Plus in pyproject.toml:

toml
[tool.hatch.build.targets.wheel.force-include]
"src/my_package/py.typed" = "my_package/py.typed"

Without py.typed, mypy / pyright ignore your inline type hints.


5. README that works as your PyPI page

PyPI renders your README (Markdown or RST). Make it useful:

markdown
# my-package

[![PyPI](https://img.shields.io/pypi/v/my-package.svg)](https://pypi.org/project/my-package/)
[![CI](https://github.com/me/my-package/actions/workflows/ci.yml/badge.svg)](https://github.com/me/my-package/actions)
[![Python](https://img.shields.io/pypi/pyversions/my-package.svg)](https://pypi.org/project/my-package/)

One-sentence pitch.

## Install

pip install my-package


## Quickstart
```python
from my_package import retry_get
resp = retry_get("https://api.example.com/data")

Features

  • ...

Why?

  • ...

License

MIT


Don't dump full API docs in README — link to docs site (`mkdocs`, `Sphinx`, ReadTheDocs).

---

## 6. CHANGELOG

```markdown
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

## [0.2.0] - 2026-06-01
### Added
- Async `aretry_get` helper.
### Changed
- `retry_get` now respects `Retry-After` headers.
### Deprecated
- Old `retry()` decorator (will be removed in 1.0).

## [0.1.0] - 2026-05-15
### Added
- Initial release.

Update with every release. Users read this to decide whether to upgrade.


7. LICENSE

Pick one:

  • MIT / Apache-2.0: permissive, business-friendly. Default for libraries.
  • GPL-3.0: copyleft. Makes dependents also GPL.
  • BSD-3-Clause: like MIT, slightly different attribution clause.
  • MPL-2.0: file-level copyleft, used by Mozilla / Polars.

If you don't include a license, the work is not free — others can't legally use it.

choosealicense.com walks you through.


8. CI for build + release

.github/workflows/ci.yml:

yaml
name: ci
on: [push, pull_request]

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
        python-version: ["3.10", "3.11", "3.12", "3.13"]
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v3
        with:
          python-version: ${{ matrix.python-version }}
      - run: uv sync --frozen --all-extras
      - run: uv run ruff check .
      - run: uv run mypy src/
      - run: uv run pytest -ra

.github/workflows/release.yml:

yaml
name: release
on:
  push:
    tags: ["v*"]

jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      id-token: write              # required for PyPI Trusted Publishing
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v3
      - run: uv build
      - uses: pypa/gh-action-pypi-publish@release/v1

Configure Trusted Publishing on PyPI: project → Settings → Trusted Publisher Management → Add → GitHub Actions.


9. Release process (manual or tag-driven)

  1. Update pyproject.toml version: 0.1.00.2.0.
  2. Update CHANGELOG.md: move "Unreleased" content under new version.
  3. Commit: git commit -am "release: 0.2.0".
  4. Tag: git tag v0.2.0.
  5. Push: git push && git push --tags.
  6. CI builds + publishes.
  7. (Manual fallback) uv build && uv publish.

For automated semantic releases: python-semantic-release reads commit messages (Conventional Commits) and computes the next version + CHANGELOG automatically.


10. Supporting users

Once on PyPI, you have users. Be a good citizen:

  • Respond to issues within a week or set expectations in README.
  • Accept PRs with clear contribution guidelines (CONTRIBUTING.md).
  • Deprecate, don't delete: mark old APIs with warnings.warn(..., DeprecationWarning) for at least one minor cycle before removal.
  • Document breaking changes prominently in CHANGELOG.
  • Keep CI green on the Python versions you advertise in classifiers.

Stability levels

  • Pre-1.0: anything can change. Set expectations.
  • Post-1.0: follow SemVer strictly. Breaking changes only in major versions.
  • 2.0+: provide a migration guide.

11. Type stubs for typed libraries

If your library is typed:

  • Include py.typed (above).
  • Make sure your public API has full type hints.
  • Run mypy --strict on your own code in CI.
  • Test consumers with pytest + mypy to ensure their code type-checks against yours.

If your library is NOT typed but heavily used, ship type stubs as a separate package: types-mylib.


12. Code of conduct & security

For widely-used libraries:

  • CODE_OF_CONDUCT.md (Contributor Covenant is standard).
  • SECURITY.md with a private disclosure email and your policy.
  • Enable GitHub's Dependabot for security updates.
  • Sign tags / commits if you can.

13. Docs

Options:

  • MkDocs Material (uv add --dev mkdocs-material) — clean, easy, hosted on GitHub Pages / Read the Docs.
  • Sphinx — the traditional choice; supports autodoc, reST.
  • mkdocstrings — auto-generates API docs from docstrings.
yaml
# mkdocs.yml
site_name: my-package
theme:
  name: material
plugins:
  - mkdocstrings
nav:
  - Home: index.md
  - API: api.md
powershell
uv run mkdocs serve         # local preview
uv run mkdocs build         # static site to site/

Publish via GitHub Actions to GitHub Pages or to Read the Docs.


14. Marketing your release

Once it ships:

  • Tweet / post on Bluesky / LinkedIn.
  • Submit to awesome-python lists if relevant.
  • Write a blog post about what problem it solves.
  • File "introducing" issue on related projects (if they could integrate).

15. Worked example: from zero to PyPI

powershell
# 1. Scaffold
uv init retry-helper --python 3.10
cd retry-helper

# 2. Write code in src/retry_helper/
# 3. Author pyproject.toml (above)
# 4. Add tests, ruff, mypy, pre-commit
uv add httpx tenacity
uv add --dev pytest pytest-asyncio respx ruff mypy

# 5. Run everything
uv run ruff check .
uv run mypy src/
uv run pytest

# 6. Build + publish to TestPyPI
uv build
uv publish --publish-url https://test.pypi.org/legacy/ --token $env:TESTPYPI_TOKEN

# 7. Install in fresh venv; verify
uv tool install --index-url https://test.pypi.org/simple/ retry-helper

# 8. If happy, publish for real
uv publish --token $env:PYPI_TOKEN

# 9. Tag and release
git commit -am "release: 0.1.0"
git tag v0.1.0
git push --tags

Visit https://pypi.org/project/retry-helper/ and bask.


Hands-on lab (3 hours)

  1. Pick a name; verify availability on PyPI.
  2. Scaffold the repo with uv init; add full pyproject.toml.
  3. Write tests + docs + LICENSE + CHANGELOG.
  4. Set up GitHub Actions CI matrix (3.10–3.13 × Linux/macOS/Win).
  5. Configure Trusted Publishing.
  6. Publish a 0.0.1 to TestPyPI; install in a sandbox; verify imports.
  7. Publish 0.1.0 to real PyPI; tweet about it.

Common pitfalls

  1. Forgetting py.typed — mypy ignores your hints.
  2. Missing README.md content_type → PyPI doesn't render (set content-type = "text/markdown" if not auto-detected).
  3. Tagging v0.1.0 but pyproject.toml still says 0.0.1.
  4. Publishing 0.1.0 directly to real PyPI — you can't delete a version. Test first.
  5. Breaking changes in a patch release → angry users.
  6. Adding a heavy dep nobody asked for. Keep dependencies minimal.
  7. No CHANGELOG.

Self-check

  1. Why is py.typed necessary?
  2. What does Trusted Publishing replace?
  3. How do you "yank" a broken release?
  4. SemVer — when bump major, minor, patch?
  5. How would you support Python 3.10 through 3.14?

References

Sign in to save your progress and earn badges.