Skip to content

How to Publish Python Packages to PyPI with uv

I spent hours trying to publish my first Python package to PyPI. I created a setup.py file, added a MANIFEST.in, installed twine, and ran into cryptic errors about missing files. Then I discovered I had accidentally included my .env file in the distribution. Publishing Python packages shouldn’t be this hard.

The traditional approach requires multiple tools: setuptools for building, wheel for creating distributions, and twine for uploading. You need to configure setup.py or setup.cfg, manage versions manually, and hope you didn’t forget any files. When I tried automating this with GitHub Actions, I had to store PyPI tokens as secrets and worry about token rotation.

Then I found uv. The entire publishing workflow shrinks to two commands: uv build and uv publish. No separate tools to install. No complex configuration. And with trusted publishers, I don’t even need API tokens anymore.

The Old Way: Too Many Moving Parts

Before uv, publishing a Python package meant juggling several tools and configuration files.

First, I needed a setup.py file with all my package metadata:

setup.py
from setuptools import setup, find_packages
setup(
name="my-package",
version="1.0.0",
packages=find_packages(),
install_requires=[
"requests>=2.28.0",
],
)

Then I needed MANIFEST.in to control which files got included:

MANIFEST.in
include README.md
include LICENSE
recursive-include src *.py
recursive-exclude * __pycache__
recursive-exclude * *.py[co]

To build and publish, I ran multiple commands:

build-and-publish.sh
python -m pip install build twine
python -m build
twine check dist/*
twine upload dist/*

Each step could fail. twine check might find issues with my package metadata. The upload could fail if I forgot to bump the version. And I had to manually manage my PyPI API token, storing it securely and rotating it periodically.

When I added GitHub Actions automation, I had to configure secrets and manage environments. The workflow file alone was 40+ lines, and I still had to handle token management.

uv’s Two-Command Publishing

uv simplifies everything to two commands. Here’s the complete workflow:

publish-with-uv.sh
uv build
uv publish

That’s it. uv build creates both a wheel and a source distribution. uv publish uploads them to PyPI. No separate tools to install, no manual file management.

Let me show you how I set this up from scratch.

Setting Up pyproject.toml

First, I need a proper pyproject.toml file. This replaces setup.py and MANIFEST.in with a single configuration file:

pyproject.toml
[project]
name = "my-awesome-package"
version = "1.0.0"
description = "An awesome Python package"
readme = "README.md"
license = { text = "MIT" }
authors = [
{ name = "Your Name", email = "[email protected]" }
]
requires-python = ">=3.9"
dependencies = [
"requests>=2.28.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.0.0",
"ruff>=0.1.0",
]
[project.scripts]
my-cli = "my_package.cli:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

The [build-system] section tells uv to use hatchling as the build backend. uv supports any PEP 517 compliant backend, but hatchling works well for most projects.

My project structure looks like this:

project-structure.txt
my-awesome-package/
├── src/
│ └── my_package/
│ ├── __init__.py
│ └── cli.py
├── tests/
│ └── test_main.py
├── pyproject.toml
└── README.md

With this setup, I’m ready to build and publish.

Building and Testing Locally

Before publishing to PyPI, I always test the build locally:

build-locally.sh
uv build

This creates a dist/ directory with two files:

dist-contents.txt
dist/
├── my_awesome_package-1.0.0-py3-none-any.whl
└── my_awesome_package-1.0.0.tar.gz

The wheel file is the binary distribution. The tar.gz is the source distribution. Both get uploaded to PyPI.

To smoke test the wheel, I install it in an isolated environment:

test-wheel.sh
uv run --isolated --no-project \
--with dist/my_awesome_package-1.0.0-py3-none-any.whl \
python -c "import my_package; print(my_package.__version__)"

If this works, my package imports correctly and has the right version. This catches common issues like missing __init__.py files or broken imports.

I also test on TestPyPI before the real publish. TestPyPI is a separate instance of PyPI for testing:

publish-testpypi.sh
uv publish --index-url https://test.pypi.org/legacy/

This requires a TestPyPI account and token, but it’s worth the extra step to catch issues before publishing to the main PyPI.

Automating with GitHub Actions

The real power comes from automating the entire process with GitHub Actions. When I push a git tag, GitHub automatically builds and publishes my package.

Here’s my complete workflow file:

.github/workflows/publish.yml
name: Publish
on:
push:
tags:
- 'v*'
jobs:
publish:
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/my-awesome-package
permissions:
id-token: write
contents: read
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Build
run: uv build
- name: Publish
run: uv publish

This workflow triggers when I push a tag starting with v (like v1.0.0). It checks out the code, installs uv, builds the package, and publishes to PyPI.

The key part is the permissions section:

permissions.yml
permissions:
id-token: write
contents: read

The id-token: write permission enables OIDC token generation for trusted publishing. This means I don’t need to store any PyPI tokens as GitHub secrets.

Setting Up Trusted Publishers

Trusted publishers eliminate the need for API tokens entirely. Instead of storing a token in GitHub secrets, I configure PyPI to trust my GitHub workflow.

Here’s how I set it up:

  1. Go to my PyPI project page: https://pypi.org/manage/project/my-awesome-package/settings/publishing/
  2. Add a new trusted publisher with these settings:
    • PyPI Project Name: my-awesome-package
    • Owner: my GitHub username or organization
    • Repository name: my-awesome-package
    • Workflow name: publish.yml
    • Environment name: pypi

The environment name in the workflow must match the environment configured on PyPI. I also add protection rules to the GitHub environment:

  • Required reviewers for approval before publishing
  • Only allow publishes from the main branch

This setup is more secure than using API tokens. Tokens can be leaked or stolen. Trusted publishers use OIDC tokens that are only valid for the specific workflow run.

The Release Process

With everything configured, releasing a new version takes three commands:

release.sh
# Update version in pyproject.toml
# Then commit and tag
git add pyproject.toml
git commit -m "Release v1.1.0"
git tag v1.1.0
git push origin main --tags

Pushing the tag triggers the GitHub Actions workflow. Within a minute or two, my new version appears on PyPI.

I can watch the progress in the GitHub Actions tab. If something fails, I get an email notification. Common issues include:

  • Version already exists on PyPI (forgot to bump version)
  • Build failures (syntax errors or missing files)
  • Trusted publisher not configured correctly

Dynamic Versioning with Git Tags

Hardcoding the version in pyproject.toml works, but I prefer dynamic versioning. With hatchling, I can derive the version from git tags:

pyproject.toml with dynamic version
[project]
name = "my-awesome-package"
dynamic = ["version"]
[build-system]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"
[tool.hatch.version]
source = "vcs"
[tool.hatch.build.hooks.vcs]
version-file = "src/my_package/_version.py"

I add hatch-vcs to my dependencies, and now the version comes from git tags. When I tag v1.2.3, the package version becomes 1.2.3. No manual version updates needed.

Security Best Practices

Publishing packages requires attention to security. Here’s what I do:

Use trusted publishers. No tokens means no leaked tokens. Trusted publishers use OIDC, which is more secure and easier to manage.

Enable 2FA on PyPI. This prevents unauthorized access to my PyPI account, even if someone gets my password.

Never commit secrets. I check my .gitignore to ensure .env files and other secrets never get committed:

.gitignore
.env
.env.*
*.pem
secrets/

Review distribution contents. Before publishing, I inspect the built wheel:

inspect-wheel.sh
unzip -l dist/my_awesome_package-1.0.0-py3-none-any.whl

This shows exactly what files will be published. I make sure no sensitive files slip through.

Use GitHub environments with protection rules. I require approval for publishes to the pypi environment. This gives me a chance to review the changes before they go live.

Common Mistakes I Made

I’ve made every mistake in the book. Here are the ones that hurt the most:

Publishing without testing. I once published a package that installed fine but crashed on import. Now I always smoke test the wheel before publishing.

Forgetting to bump the version. PyPI rejects uploads if the version already exists. I’ve pushed tags, waited for the workflow, and then realized I never updated the version. Dynamic versioning fixed this.

Including sensitive files. Early on, I accidentally published my .env file. Someone opened an issue pointing out my API keys were public. I had to rotate all my keys and yank the package. Now I always inspect the wheel contents.

Not using trusted publishers. I used to store PyPI tokens in GitHub secrets. When a repository got archived, I forgot to revoke the token. Trusted publishers are scoped to specific workflows, so they can’t be abused outside that context.

Testing the Complete Workflow

Let me walk through a complete release from start to finish.

First, I make my changes and run tests:

development.sh
uv run pytest
uv run ruff check .

When tests pass, I update the version (or let dynamic versioning handle it), commit, and tag:

prepare-release.sh
git add .
git commit -m "Add new feature"
git tag v1.0.1

I push the tag:

push-tag.sh
git push origin main --tags

GitHub Actions picks up the tag and starts the workflow. I see the build running in the Actions tab. A minute later, I get an email: my package is live on PyPI.

I verify it works:

verify-publish.sh
uv pip install my-awesome-package==1.0.1
my-cli --version

Done. From code change to published package in under five minutes.

Summary

In this post, I showed you how to publish Python packages to PyPI using uv. The traditional approach required multiple tools (setuptools, wheel, twine) and manual configuration. uv simplifies this to two commands: uv build and uv publish.

I covered setting up pyproject.toml, building and testing locally, and automating the entire process with GitHub Actions. The key security improvement is using trusted publishers instead of API tokens. This eliminates token management and reduces the risk of credential leaks.

The release process is now simple: update the code, tag the release, and push. GitHub Actions handles the rest. With dynamic versioning from git tags, I don’t even need to manually update versions.

If you’re still using setup.py and twine, give uv a try. The time you save on configuration alone is worth it.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments