Publish Python SDKs to PyPI with uv: From Packaging to Automated Trusted Publishing

A step-by-step guide to publishing a Python SDK to PyPI using uv for building, TestPyPI for validation, and GitHub Actions with Trusted Publishing for automated releases, covering pyproject.toml configuration, versioning pitfalls, mirror sync delays, and OIDC-based credentialless uploads.

Tech Ocean
Tech Ocean
Tech Ocean
Publish Python SDKs to PyPI with uv: From Packaging to Automated Trusted Publishing

1. Scaffold a Packagable Project with uv

Use uv init --lib mini-sdk to generate a library project with a src/ layout. This layout places code under src/mini_sdk/, ensuring tests run against the installed package rather than source files, catching missing files early. The scaffold includes py.typed (an empty file per PEP 561) so type checkers like mypy and Pyright recognize inline type hints.

2. Complete pyproject.toml Metadata

The generated pyproject.toml is minimal; expand it with:

[project]
name = "mini-sdk"
version = "0.1.0"
description = "A tiny SDK for demonstrating how to publish to PyPI"
readme = "README.md"
license = "MIT"
license-files = ["LICENSE"]
authors = [{ name = "Your Name", email = "[email protected]" }]
requires-python = ">=3.9"
dependencies = ["httpx>=0.27"]
classifiers = [
  "Programming Language :: Python :: 3",
  "Operating System :: OS Independent",
]

[project.urls]
Homepage = "https://github.com/your-name/mini-sdk"
Issues = "https://github.com/your-name/mini-sdk/issues"

Key details: name is the pip install name; PyPI treats -, _, . as equivalent and blocks confusingly similar names (e.g., minisdk rejected if mini-sdk exists). readme points to a Markdown file rendered on the project page. dependencies uses lower bounds ( >=) to avoid version conflicts in downstream projects. license + license-files follows PEP 639; the old license = { text = "MIT" } and License classifiers are deprecated. requires-python defaults to the Python version used during uv init (e.g., >=3.12). The author changed it to >=3.9 after a test install on Python 3.10 failed with

ERROR: Package 'mini-sdk' requires a different Python: 3.10.17 not in '>=3.12'

. authors pulls from local git config; replace if you don't want personal email public.

3. Build: Source Distribution and Wheel

Run uv build to produce two artifacts in dist/:

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

= source distribution (sdist), rebuilt on user's machine. .whl = pre-built wheel; py3-none-any means pure Python, any platform, any Python 3. Pip prefers wheels.

uv builds the wheel from the sdist, so files missing from the sdist are also missing from the wheel.

Inspecting the wheel confirms py.typed and LICENSE are included.

The METADATA file inside the wheel reflects pyproject.toml fields:

Metadata-Version: 2.4
Name: mini-sdk
Version: 0.1.0
Summary: A tiny SDK for demonstrating how to publish to PyPI
Author-email: Your Name <[email protected]>
License-Expression: MIT
License-File: LICENSE
Requires-Dist: httpx>=0.27
Requires-Python: >=3.9
Description-Content-Type: text/markdown

4. Build Backend Comparison

The [build-system] table specifies the backend. uv init uses uv_build. The author tested hatchling and setuptools with both new ( license = "MIT") and old (License classifier) license declarations: uv_build: warns about deprecated classifier but builds. hatchling: builds silently. setuptools: errors with

setuptools.errors.InvalidConfigError: License classifiers have been superseded by license expressions (see https://peps.python.org/pep-0639/). Please remove: License :: OSI Approved :: MIT License

.

PyPI accepts the classifier, but removing it avoids backend-specific failures.

5. Local Verification Before Upload

Check metadata: uvx twine check dist/* validates README rendering. It passed for both artifacts but did not catch the mixed license declarations.

Check installation: Create a clean venv with the minimum supported Python version, install the wheel, and import:

uv venv --seed -p 3.10 /tmp/clean
/tmp/clean/bin/pip install dist/mini_sdk-0.1.0-py3-none-any.whl
/tmp/clean/bin/python -c "import mini_sdk; print(mini_sdk.__version__)"

Output: 0.1.0. This verifies dependencies, file inclusion, and Python version constraints.

6. Test Publish to TestPyPI

TestPyPI ( test.pypi.org) is a separate sandbox with periodic data cleanup. Register an account, enable 2FA, create an API token (prefix pypi-).

Configure the test index in pyproject.toml:

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

prevents accidental dependency resolution from TestPyPI.

Upload with token in env var:

export UV_PUBLISH_TOKEN=pypi-xxxxxxxx
uv publish --index testpypi

Install from TestPyPI (dependencies may be missing, so fall back to PyPI):

pip install -i https://test.pypi.org/simple/ \
  --extra-index-url https://pypi.org/simple/ \
  mini-sdk==0.1.0

Authentication errors observed:

No credentials: Missing credentials for https://test.pypi.org/legacy/ (preceded by ignorable Trusted publishing failed).

Username/password:

403 Username/Password authentication is no longer supported. Migrate to API Tokens or Trusted Publishers instead.

7. Publish to Production PyPI

Same flow: register on pypi.org, enable 2FA (mandatory since 2024-01-01), create token. Upload:

export UV_PUBLISH_TOKEN=pypi-xxxxxxxx
uv publish

Common pitfalls on subsequent releases:

Old artifacts accumulate in dist/ . uv build does not clean; uv publish uploads everything. Use uv build --clear to reset dist/ each time.

Version numbers are immutable. PyPI: "PyPI does not allow for a filename to be reused, even once a project has been deleted and recreated." Re-uploading same version yields 400 File already exists; deleting and re-uploading yields

This filename was previously used by a file that has since been deleted. Use a different version.

Fix: bump version ( uv version --bump patch) and rebuild.

Yank, don't delete. Use the project's release management page to yank a version. Yanked versions are skipped by pip unless pinned with ==, preventing breakage.

Domestic mirror sync delay. The author measured 10 new packages across Tsinghua, Tencent Cloud, and Aliyun mirrors. Tsinghua synced in minutes (advertised 5-min interval), Tencent in 10-20 minutes, Aliyun took 6+ hours for one package. To verify immediately, install from official source: pip install -i https://pypi.org/simple mini-sdk==0.1.1.

8. Automate with GitHub Actions & Trusted Publishing

Trusted Publishing uses OIDC: PyPI trusts a specific GitHub repo/workflow/environment. The workflow requests a short-lived OIDC token from GitHub, exchanges it for a PyPI upload token (valid 15 min). No long-lived secrets stored.

PyPI side: Add a pending publisher (or manage existing project) with: repository owner, repo name, workflow filename (e.g., publish.yml), and GitHub environment name (optional but recommended for required approvals). Pending publishers expire after 30 days of inactivity and don't reserve the package name.

GitHub workflow ( .github/workflows/publish.yml ):

name: publish

on:
  release:
    types: [published]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: astral-sh/[email protected]
      - run: uv build
      - uses: actions/upload-artifact@v7
        with:
          name: dist
          path: dist/

  publish:
    needs: build
    runs-on: ubuntu-latest
    environment: pypi
    permissions:
      id-token: write
    steps:
      - uses: actions/download-artifact@v8
        with:
          name: dist
          path: dist/
      - uses: pypa/gh-action-pypi-publish@release/v1

Design notes:

Separate build and publish jobs; artifact transfer isolates build environment from upload credentials (PyPA recommendation). permissions: id-token: write only on publish job — required for OIDC token request. environment: pypi must match PyPI configuration; can enforce manual approval in GitHub Environments settings.

PyPA action ( pypa/gh-action-pypi-publish@release/v1) uploads PEP 740 attestations by default. setup-uv v8+ requires full version tags (e.g., @v10.2.0), not major-only @v10.

Alternative: replace last step with uv publish (auto-detects Trusted Publishing in CI) but loses attestations. Trusted Publishing cannot be used in reusable workflows.

Trigger: create a GitHub Release. For pre-release validation, add a TestPyPI job with repository-url: https://test.pypi.org/legacy/ and a separate environment.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

version managementGitHub ActionsPython packagingPyPIuvTrusted Publishingsoftware distributionTestPyPI
Tech Ocean
Written by

Tech Ocean

Focused on AI programming, sharing ready-to-use development efficiency solutions.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.