Publishing to PyPI — trusted publishing and CI
Cutting a release, trusted publishing from GitHub Actions, and yanking a bad version safely.
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
- Pick a package name and avoid common pitfalls.
- Structure a publishable library.
- Build, version, and tag releases.
- Publish via
uv(token or trusted publishing). - 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_packageformy-package. - Avoid bare nouns (
utils,tools); they'll collide.
Reserve before you forget by uploading an empty 0.0.0:
[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.md4. The pyproject.toml for a library
[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 - Planning→7 - 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:
[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:
# my-package
[](https://pypi.org/project/my-package/)
[](https://github.com/me/my-package/actions)
[](https://pypi.org/project/my-package/)
One-sentence pitch.
## Installpip 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:
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:
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/v1Configure Trusted Publishing on PyPI: project → Settings → Trusted Publisher Management → Add → GitHub Actions.
9. Release process (manual or tag-driven)
- Update
pyproject.tomlversion:0.1.0→0.2.0. - Update
CHANGELOG.md: move "Unreleased" content under new version. - Commit:
git commit -am "release: 0.2.0". - Tag:
git tag v0.2.0. - Push:
git push && git push --tags. - CI builds + publishes.
- (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 --stricton your own code in CI. - Test consumers with
pytest+mypyto 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.mdwith 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.
# mkdocs.yml
site_name: my-package
theme:
name: material
plugins:
- mkdocstrings
nav:
- Home: index.md
- API: api.mduv 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-pythonlists 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
# 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 --tagsVisit https://pypi.org/project/retry-helper/ and bask.
Hands-on lab (3 hours)
- Pick a name; verify availability on PyPI.
- Scaffold the repo with
uv init; add fullpyproject.toml. - Write tests + docs + LICENSE + CHANGELOG.
- Set up GitHub Actions CI matrix (3.10–3.13 × Linux/macOS/Win).
- Configure Trusted Publishing.
- Publish a 0.0.1 to TestPyPI; install in a sandbox; verify imports.
- Publish 0.1.0 to real PyPI; tweet about it.
Common pitfalls
- Forgetting
py.typed— mypy ignores your hints. - Missing
README.mdcontent_type → PyPI doesn't render (setcontent-type = "text/markdown"if not auto-detected). - Tagging
v0.1.0butpyproject.tomlstill says0.0.1. - Publishing 0.1.0 directly to real PyPI — you can't delete a version. Test first.
- Breaking changes in a patch release → angry users.
- Adding a heavy dep nobody asked for. Keep dependencies minimal.
- No CHANGELOG.
Self-check
- Why is
py.typednecessary? - What does Trusted Publishing replace?
- How do you "yank" a broken release?
- SemVer — when bump major, minor, patch?
- How would you support Python 3.10 through 3.14?
References
- Python Packaging User Guide: https://packaging.python.org/.
uvpublish docs: https://docs.astral.sh/uv/concepts/publish/.- PyPI Trusted Publishing: https://docs.pypi.org/trusted-publishers/.
- Keep a Changelog: https://keepachangelog.com/.
- Choose a License: https://choosealicense.com/.
- Hynek Schlawack, "How to write a great Python library."
Sign in to save your progress and earn badges.