Skip to content

How to Publish to TestPyPI with uv

PyPI versions are write-once. Upload 0.1.0 with a broken README or a missing dependency, and the only fix is a new version number. TestPyPI is a parallel index wired to the same tooling where the cost of a bad upload is zero. Upload there first and promote to PyPI only after the rehearsal works end to end.

This guide uses uv for the build, upload, and verification steps. The workflow slots into the trusted-publishing setup for CI or runs entirely from a local shell with an API token.

Prerequisites

Register TestPyPI as a uv index

Add a [[tool.uv.index]] block to the package’s pyproject.toml:

[[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
publish-url = "https://test.pypi.org/legacy/"
explicit = true

Each field has a specific job:

  • url is the read endpoint uv queries when resolving packages from TestPyPI.
  • publish-url is the write endpoint uv publish posts distributions to. The legacy in the URL is historical; this is the current upload endpoint.
  • explicit = true stops the index from being consulted during normal dependency resolution. Without this, uv add requests would look on TestPyPI too, and pick up whoever happens to own the requests name there.

Commit this block. It is safe to check in because no secrets live in it.

Authenticate to TestPyPI

Pick one of two paths depending on where uv publish runs.

Option A: Export an API token for local uploads

  1. Log in to test.pypi.org and open Account settings, API tokens.
  2. Create a token scoped to “Entire account” (per-project scopes only exist after the first upload).
  3. Export it in your shell:
export UV_PUBLISH_TOKEN=pypi-AgENdGVzdC5weXBp...

uv publish reads UV_PUBLISH_TOKEN automatically, so no CLI flag is needed. Keep the token out of .pypirc and out of shell history by avoiding inline --token arguments.

Option B: Wire up trusted publishing for GitHub Actions

Trusted publishing works on TestPyPI the same way it works on PyPI, but the configuration lives on a different host.

  1. Go to test.pypi.org/manage/account/publishing/ and scroll to Create a new pending publisher.
  2. Enter the package name exactly as it appears in pyproject.toml, along with the GitHub owner, repository, workflow filename, and optional environment.
  3. Save. A pending publisher converts to a normal trusted publisher the first time a matching workflow uploads to it.

In the GitHub Actions workflow, uv publish --index testpypi is the only command change from the PyPI flow:

name: Publish to TestPyPI

on:
  workflow_dispatch:

jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
    steps:
      - uses: actions/checkout@v6
      - uses: astral-sh/setup-uv@v7
      - run: uv build
      - run: uv publish --index testpypi --trusted-publishing always

Two pieces of configuration carry the OIDC handshake:

  • permissions: id-token: write is required. Without it, GitHub Actions cannot mint the OIDC token uv needs to exchange for an upload credential.
  • --trusted-publishing always forces uv to attempt the OIDC exchange rather than silently fall back to looking for a token. During initial setup, always produces clearer error messages than the default automatic. Once the publisher is confirmed working, dropping the flag is fine.

The workflow filename in .github/workflows/ must match the filename you entered on the pending-publisher form exactly. TestPyPI rejects uploads from any other workflow in the same repo.

Build the distributions

Build the wheel and sdist into dist/:

$ uv build
Building source distribution (uv build backend)...
Building wheel from source distribution (uv build backend)...
Successfully built dist/your_package-0.1.0.tar.gz
Successfully built dist/your_package-0.1.0-py3-none-any.whl

Upload to TestPyPI

With the token exported or the CI workflow running, upload the dist/ contents:

$ uv publish --index testpypi

--index testpypi tells uv publish to look up the index by name in pyproject.toml and use its publish-url. The command works identically in CI and locally.

Tip

Run uv publish --index testpypi --dry-run first. It reports which files would go to which URL without uploading anything, which catches a wrong dist/ or a misconfigured publish-url before the real run. The dry run does not validate your token; the first upload does.

Check the package page at https://test.pypi.org/project/your_package/. The project page is where metadata problems the build skipped will surface: a README that renders as raw text (missing readme field or wrong content type), a mangled description, the wrong license classifier, a missing project URL. Catching them on TestPyPI costs nothing; catching them on PyPI means shipping a new version.

Install from TestPyPI to verify

A successful upload does not mean a successful install. Pull the package from TestPyPI into a throwaway environment and exercise it:

$ uv run --with your_package \
    --refresh-package your_package \
    --default-index https://pypi.org/simple/ \
    --index https://test.pypi.org/simple/ \
    --no-project \
    -- python -c "import your_package; print(your_package.__version__)"

What each flag does:

  • --refresh-package your_package bypasses the uv cache for this one package, so a version already cached locally doesn’t mask a broken upload.
  • --default-index https://pypi.org/simple/ keeps normal dependency resolution pointed at real PyPI, so transitive dependencies (requests, numpy, or anything else you declare) install from the real index. TestPyPI is unreliable for dependency resolution; many packages aren’t present there or exist at stale versions.
  • --index https://test.pypi.org/simple/ adds TestPyPI on top so the package under test resolves from the index where you just uploaded it.
  • --no-project runs outside any pyproject.toml in the current directory, so this works from the same checkout you built in.

If the import succeeds, the upload round-trips. Expand the python -c payload to smoke-test whatever entry point or public API the package ships.

Promote to PyPI

When the TestPyPI rehearsal works, publish to real PyPI. Because the version cannot be reused on PyPI either, bump version in pyproject.toml, rebuild, and publish without the --index flag. Omitting --index tells uv publish to upload to PyPI:

$ uv build
$ uv publish

For a CI-driven release, the trusted publishing how-to covers the PyPI side end to end. The only differences from the TestPyPI workflow on this page are the publisher configuration URL and the absence of --index testpypi. One pattern worth copying is to push every main commit to TestPyPI and only push tags to PyPI; Hynek Schlawack’s build-and-inspect-python-package action implements this directly, and astral-sh/trusted-publishing-examples has a minimal end-to-end reference.

Decide when TestPyPI is worth it

TestPyPI pays off most for releases that are expensive to undo:

  • First upload of a package. Name squatting is permanent on PyPI; TestPyPI lets you verify the name, metadata, and install behavior before claiming it.
  • Major version bumps. A broken 1.0.0 means shipping 1.0.1 as the first real 1.x release.
  • Publishing workflow changes. New build backend, new CI, new attestations, new trusted publisher: rehearse against TestPyPI before the live one.

For routine point releases of a stable package with an unchanged build, TestPyPI is usually overhead. Ship straight to PyPI and lean on --dry-run:

$ uv publish --dry-run

--dry-run confirms uv can see the distributions and can check the target index for duplicate files, without actually pushing. It doesn’t verify install behavior, so pair it with uv build && uv pip install dist/*.whl in a scratch environment to cover that side.

Avoid these gotchas

Versions are write-once. TestPyPI behaves like PyPI: once 0.1.0 is uploaded, you cannot delete it, overwrite it, or reuse the version number. Rehearse with release candidates like 0.1.0rc1 and 0.1.0rc2 so you don’t burn the real version number during testing.

Names can collide across indexes. A name free on PyPI might be taken on TestPyPI by someone else’s rehearsal (or vice versa). Check both pypi.org/project/your-package and test.pypi.org/project/your-package before picking a package name.

Uploads aren’t archival. TestPyPI operators may delete data, and the database is periodically pruned. Don’t treat TestPyPI as a distribution channel or point end users at it.

Dependencies fail without a fallback index. If you omit --default-index https://pypi.org/simple/ during verification, uv will try to resolve every dependency on TestPyPI and most will fail to resolve. The dual-index pattern from the verification command handles this.

Trusted publisher config is per-host. A PyPI trusted publisher does not cover TestPyPI uploads. Configure the pending publisher on test.pypi.org/manage/account/publishing/ separately.

Learn more

Last updated on

Please submit corrections and feedback...