A release used to need four tools: something to bump the version, build to make the wheel and sdist, twine to check and upload them, and a virtual environment manager to hold it all together. uv now covers the whole path itself: uv version edits the version, uv build produces distributions with uv's own fast build backend, and uv publish uploads them — with trusted publishing support in CI. For a CLI project that already uses uv for dependencies, that means one tool, one lock file and one set of commands from git tag to pipx install. This guide walks through each step, the checks worth running between them, and a small release script that strings them together. It belongs to the uv topic; the general PyPI process is in publishing a Python CLI to PyPI.
Prerequisites
- uv 0.8 or later, and a CLI project with
[project.scripts]defined — see writing pyproject.toml metadata for a CLI. - A PyPI account with a trusted publisher configured for the project, or an API token for a first manual upload.
The release path
Each step has one job and one command, and each produces something you can inspect before moving on: a changed version line, two files in dist/, a successful install in a clean environment, and finally a release on PyPI.
The recipe
1. Choose a build backend
uv init --package writes this by default:
[build-system]
requires = ["uv_build>=0.8,<0.9"]
build-backend = "uv_build"
uv_build is fast and strict: it expects the conventional src/<package_name>/ layout, includes package data inside the package directory, and needs almost no configuration. If your project needs build-time customisation — version from git tags, compiled extensions, custom file selection — Hatchling or setuptools remain good choices, and uv build drives them just as well. Choosing a build backend for a Python CLI compares the options. The upper bound on uv_build is deliberate: build behaviour should change when you choose, not when a new release appears.
2. Bump the version
uv version # print: mytool 1.5.2
uv version --bump minor # 1.5.2 -> 1.6.0, rewrites pyproject.toml, re-locks
uv version --bump patch --bump rc --dry-run # preview 1.5.3rc1 without writing
uv version 2.0.0 # set an explicit version
uv version --bump understands PEP 440 segments — major, minor, patch, plus alpha, beta, rc, post, dev and stable to drop a pre-release suffix — and can combine them, so pre-releases are one command. It updates [project] version in pyproject.toml and the project's entry in uv.lock together. If you derive versions from git tags instead, skip this step; the backend reads the tag at build time, as in deriving versions from git tags with hatch-vcs.
3. Build
rm -rf dist
uv build # dist/mytool-1.6.0.tar.gz and dist/mytool-1.6.0-py3-none-any.whl
uv build --no-sources # build as a downstream user would, ignoring [tool.uv.sources]
uv build creates an isolated build environment, builds the sdist, then builds the wheel from the sdist — which proves the sdist is complete. --no-sources matters if you use [tool.uv.sources] to point a dependency at a local path or git branch during development: published metadata must not depend on those overrides, and building without them catches that before users do.
4. Check the wheel before it leaves
Two quick checks catch most packaging mistakes. First, look inside the wheel:
unzip -l dist/mytool-1.6.0-py3-none-any.whl | grep -v dist-info
unzip -p dist/mytool-1.6.0-py3-none-any.whl 'mytool-1.6.0.dist-info/entry_points.txt'
Second, install it into a throwaway environment, exactly as a user would, and run the command:
uv run --isolated --no-project --with dist/mytool-1.6.0-py3-none-any.whl -- mytool --version
uvx --from dist/mytool-1.6.0-py3-none-any.whl mytool --help
Both commands create a temporary environment containing only the wheel and its declared dependencies. A missing dependency, a broken entry point or a data file left out of the package fails here, minutes before it would have failed for every user. Smoke-testing the built wheel in CI automates the same idea.
5. Publish
From CI with trusted publishing, no credentials are needed:
uv publish # uploads everything in dist/
In GitHub Actions the job needs permissions: id-token: write; uv publish detects the environment and exchanges the OIDC token for a short-lived upload credential. For a first manual upload, before a trusted publisher exists, pass a token through the environment rather than the command line: UV_PUBLISH_TOKEN=pypi-… uv publish. To rehearse, publish to TestPyPI with uv publish --publish-url https://test.pypi.org/legacy/ and install from there.
If a publish is interrupted halfway, run it again with --check-url https://pypi.org/simple/ so uv skips files that already exist instead of failing on them.
A release script
Wrapping the steps in a script makes releases repeatable and keeps the order right:
#!/usr/bin/env bash
# scripts/release.sh — usage: scripts/release.sh minor|patch|major|rc
set -euo pipefail
bump=${1:?usage: release.sh minor|patch|major|rc}
git diff --quiet || { echo "working tree is dirty" >&2; exit 1; }
uv lock --check
uv run --locked pytest -q
uv version --bump "$bump"
version=$(uv version --short)
rm -rf dist && uv build --no-sources
uvx --from "dist/mytool-${version}-py3-none-any.whl" mytool --version | grep -q "$version"
git commit -am "Release ${version}" && git tag "v${version}"
echo "Tagged v${version}. Push with: git push --follow-tags"
The script stops at tagging on purpose: pushing the tag triggers the CI workflow that publishes with trusted publishing, so no upload credential ever lives on a laptop. Automating releases from git tags builds that workflow.
When something goes wrong
Releases fail in a small number of recurring ways, and each has a quick fix:
- "File already exists" on upload. PyPI never lets you replace a file, even after deleting it. Bump to a new version (a
.post1for a packaging-only fix) and publish again; if the upload was interrupted, re-run with--check-urlso existing files are skipped. - The wheel lacks a data file.
uv_buildincludes files inside the package directory; files elsewhere need moving into the package or backend configuration. The packaging test below catches this before a release. - The installed command is not found. The
[project.scripts]entry is missing, misspelled, or points at a function that does not exist;unzip -p … entry_points.txtshows what was actually declared. - A dependency resolves locally but not for users. A
[tool.uv.sources]override was masking a missing or wrong requirement;uv build --no-sourcesand the isolated smoke test reproduce what users see. - Wrong version in the wheel. The tree was not clean, or a stale
dist/was published. Alwaysrm -rf distbefore building, and check the version in the smoke test.
If a broken release does reach PyPI, yank it from the project's management page rather than deleting it: yanked releases stay downloadable for anyone who pinned them but are skipped by resolvers, which is what the update checks guide also relies on.
UX considerations
The "users" of a release process are maintainers, often months apart:
- One command per release. A script or
just release minorbeats a checklist in a wiki page nobody reads. - Fail before changing anything. Check for a clean tree, a current lock and passing tests before bumping the version, so a failed release leaves nothing to undo.
- Show the version in the smoke test. Checking that
mytool --versionprints the new number catches the classic mistake of building from stale files. - Keep credentials off laptops. Tags trigger CI; CI publishes. A token on a developer machine is the credential most likely to leak.
- Write the changelog before tagging, ideally generated as in automating changelogs with conventional commits.
Testing the behaviour
A test can guard the packaging itself: build the wheel and assert on its contents. This runs in a second or two and catches missing data files and wrong entry points in ordinary test runs:
# tests/test_packaging.py
import subprocess
import zipfile
from pathlib import Path
import pytest
@pytest.fixture(scope="session")
def wheel(tmp_path_factory) -> Path:
out = tmp_path_factory.mktemp("dist")
subprocess.run(["uv", "build", "--wheel", "--out-dir", str(out)], check=True,
capture_output=True)
return next(out.glob("*.whl"))
def test_entry_point_is_declared(wheel):
with zipfile.ZipFile(wheel) as zf:
name = next(n for n in zf.namelist() if n.endswith("entry_points.txt"))
entry_points = zf.read(name).decode()
assert "[console_scripts]" in entry_points
assert "mytool = mytool" in entry_points
def test_no_tests_or_caches_in_wheel(wheel):
with zipfile.ZipFile(wheel) as zf:
names = zf.namelist()
assert not [n for n in names if n.startswith("tests/") or "__pycache__" in n]
Mark it with a custom marker (for example @pytest.mark.packaging) if you want to run it only in CI. The installed-command smoke test from step 4 belongs in CI as well, on every platform you support.
Conclusion
With uv, a release is five commands: choose uv_build (or another backend), bump with uv version --bump, build with uv build --no-sources, smoke-test the wheel with uvx --from, and publish with uv publish from CI using trusted publishing. Wrap the local part in a script that refuses to start from a dirty tree, let a tag push trigger the publish, and test the wheel's contents in your suite so packaging mistakes surface long before a release.
Frequently asked questions
Can I still use twine?
Yes — uv build produces standard files that twine check and twine upload accept. uv publish simply removes the extra tool. twine check remains a useful extra validation of the README rendering on PyPI if you like it.
Does uv build work for projects that are not managed by uv?
It does. uv build builds any project with a [build-system] table, using whatever backend it declares, so you can adopt it for builds before moving dependency management.
How do I publish to a private index?
Declare the index in pyproject.toml with a publish-url, then run uv publish --index internal, with credentials in environment variables. Using private package indexes with uv covers the configuration and the dependency-confusion protections that come with it.
Why build the wheel from the sdist?
Because users who install from the sdist — on platforms without a matching wheel, or in distributions that rebuild from source — get whatever the sdist contains. Building the wheel from it proves nothing was left out of the source archive.