# How to Publish to TestPyPI with uv


[PyPI](https://pydevtools.com/handbook/explanation/what-is-pypi.md) 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](https://test.pypi.org) 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](https://pydevtools.com/handbook/reference/uv.md) for the build, upload, and verification steps. The workflow slots into the [trusted-publishing setup](https://pydevtools.com/handbook/how-to/how-to-publish-to-pypi-with-trusted-publishing.md) for CI or runs entirely from a local shell with an API token.

## Prerequisites

* [uv](https://pydevtools.com/handbook/reference/uv.md) 0.5 or newer ([installation guide](https://pydevtools.com/handbook/how-to/how-to-install-uv.md))
* A [TestPyPI account](https://test.pypi.org/account/register/) (separate from your PyPI account; the databases are independent). Enable 2FA on registration.
* A Python package with a [`pyproject.toml`](https://pydevtools.com/handbook/reference/pyproject.toml.md)

## Register TestPyPI as a uv index

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

```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](https://test.pypi.org) and open [Account settings, API tokens](https://test.pypi.org/manage/account/#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:

```bash
export UV_PUBLISH_TOKEN=pypi-AgENdGVzdC5weXBp...
```
```powershell
$env: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](https://pydevtools.com/handbook/explanation/why-use-trusted-publishing-for-pypi.md) 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/](https://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:

```yaml
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/`:

```console
$ 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:

```console
$ 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:

```console
$ 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:

```console
$ uv build
$ uv publish
```

For a CI-driven release, the [trusted publishing how-to](https://pydevtools.com/handbook/how-to/how-to-publish-to-pypi-with-trusted-publishing.md) 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`](https://github.com/hynek/build-and-inspect-python-package) action implements this directly, and [astral-sh/trusted-publishing-examples](https://github.com/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`:

```console
$ 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](https://packaging.python.org/en/latest/guides/using-testpypi/). 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/](https://test.pypi.org/manage/account/publishing/) separately.

## Learn more

* [uv publishing guide](https://docs.astral.sh/uv/guides/package/)
* [uv indexes configuration](https://docs.astral.sh/uv/concepts/indexes/)
* [TestPyPI usage guide](https://packaging.python.org/en/latest/guides/using-testpypi/) from the Python Packaging Authority
* [Publishing your first Python package to PyPI](https://pydevtools.com/handbook/tutorial/publishing-your-first-python-package-to-pypi.md) walks through a first-time publish end to end
* [How to publish to PyPI with trusted publishing](https://pydevtools.com/handbook/how-to/how-to-publish-to-pypi-with-trusted-publishing.md) covers the full CI flow for the real index
* [How to publish Python packages with digital attestations](https://pydevtools.com/handbook/how-to/how-to-publish-python-packages-with-digital-attestations.md) adds PEP 740 publisher-identity attestations to signed uploads
* [Why use trusted publishing for PyPI?](https://pydevtools.com/handbook/explanation/why-use-trusted-publishing-for-pypi.md) explains why short-lived OIDC credentials beat long-lived tokens
