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.
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/markdown4. 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 = trueprevents accidental dependency resolution from TestPyPI.
Upload with token in env var:
export UV_PUBLISH_TOKEN=pypi-xxxxxxxx
uv publish --index testpypiInstall 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.0Authentication 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 publishCommon 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/v1Design 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.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Tech Ocean
Focused on AI programming, sharing ready-to-use development efficiency solutions.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
