New in 2026: Master Python for AI, Data Science

Python

Building, Publishing & Integrating uv into CI/CD Pipelines (2026 Guide)

Building, Publishing & Integrating uv into CI/CD Pipelines

You have written a library you are proud of. It solves a real problem, the tests pass, and colleagues have asked how to install it. You could point them to a GitHub repo and a README — or you could publish it to PyPI in five minutes and let pip install your-package do the work. The same infrastructure that makes uv fast for managing dependencies also makes it the fastest way to build and publish Python packages. No more juggling setup.py, build, twine, and pip — uv handles the entire pipeline from source to index.

In this tutorial, you will learn to:

  • Configure pyproject.toml correctly for building and publishing Python packages
  • Build distributable archives (sdist and wheel) with a single uv build command
  • Set up TestPyPI for safe publication testing before touching the real index
  • Publish to PyPI with token authentication and CI-friendly Trusted Publishing
  • Integrate uv into GitHub Actions with proper caching for fast CI runs
  • Build lean, production-ready Docker images with uv sync --no-dev
  • Wire a complete lint → format → test → build → publish pipeline

Prerequisites

This tutorial builds on the uv getting started guide. You should know how to create a project with uv init, add dependencies, and use uv sync. Familiarity with pyproject.toml structure and a basic understanding of Git and GitHub Actions is assumed.

Configuring pyproject.toml for Publishing

Every publishable Python package needs a pyproject.toml that tells build tools three things: who you are, what your package is, and how to build it. The [project] table provides the metadata that appears on your PyPI page; the [build-system] table tells uv what tool to use when assembling the distribution archive.

The Minimum Publishable pyproject.toml

A library or application that you want to publish needs at minimum these fields:

[project]
name = "my-package"
version = "0.1.0"
description = "A short description of what this package does"
readme = "README.md"
requires-python = ">=3.12"
license = { text = "MIT" }
authors = [
    { name = "Your Name", email = "[email protected]" }
]
dependencies = [
    "requests>=2.28",
]

[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"

The readme = "README.md" field tells the build system to include your README in the distribution archive — this is what PyPI displays as the project description. The [build-system] table is mandatory: requires lists the build dependencies (setuptools is the most widely supported), and build-backend names the Python object that implements the build process.

Defining Console Scripts with [project.scripts]

If your package provides command-line tools, use the [project.scripts] table to declare them. When users install your package, uv automatically generates wrapper scripts in their bin/ directory.

[project]
name = "my-cli-tool"
version = "0.1.0"
description = "A helpful CLI tool"
requires-python = ">=3.10"
dependencies = []

[project.scripts]
my-cli = "my_cli_tool.__main__:main"

Your package must have a __main__.py file (or module) that exposes a main() callable. When users run pip install my-cli-tool and then type my-cli in their terminal, uv has already generated the wrapper that calls my_cli_tool.__main__.main().

Note: The [project.scripts] table only works for packages that are installed — not for editable installs during development. Use uv run my-cli during development to test CLI tools without publishing.

Classifiers and Metadata for Production Packages

PyPI uses classifiers to filter your package by Python version, license, and development status. For a package that is production-ready, add these to your [project] table:

classifiers = [
    "Development Status :: 5 - Production/Stable",
    "Intended Audience :: Developers",
    "License :: OSI Approved :: MIT License",
    "Operating System :: OS Independent",
    "Programming Language :: Python :: 3",
    "Programming Language :: Python :: 3.10",
    "Programming Language :: Python :: 3.11",
    "Programming Language :: Python :: 3.12",
    "Topic :: Software Development :: Libraries :: Python Modules",
]

For a full list of valid classifiers, see the PyPI classifier list.

Building with uv build

Once your pyproject.toml is configured, building a distributable archive takes one command. uv build produces both formats that PyPI accepts: a source distribution (sdist) — a .tar.gz of your source tree — and a wheel (a .whl binary archive with pre-compiled files).

# Build both sdist and wheel (default behaviour)
uv build

# Build only the source distribution
uv build --sdist

# Build only the wheel
uv build --wheel

Both artifacts land in the dist/ directory at your project root:

dist/
├── my-package-0.1.0.tar.gz       # source distribution
└── my_package-0.1.0-py3-none-any.whl  # wheel (pure Python)

The wheel filename encodes the package name, version, Python ABI tag (py3), and platform tag (none-any for pure Python packages that run on any OS). If your package contains C extensions, the platform tag changes to linux_x86_64, macosx_11_0_arm64, or win_amd64 accordingly.

Tip: Always add dist/ to your .gitignore. These are build artifacts, not source files. The .gitignore generated by uv init already includes this entry.

Publishing to TestPyPI First

Before publishing to the real PyPI, use TestPyPI (test.pypi.org) as a staging ground. Packages published to TestPyPI do not appear in search results and are isolated from production — you can break things safely.

Setting Up TestPyPI Index in pyproject.toml

Add a named index to your pyproject.toml using the [[tool.uv.index]] table:

[[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
default = false

[project]
name = "my-package"
version = "0.1.0"
# ...rest of your [project] table

The default = false line means uv uses TestPyPI only when explicitly asked, not for every install. For your normal development workflow, packages still resolve from PyPI by default.

Creating a TestPyPI Account and Getting a Token

Visit test.pypi.org/account/register to create an account. Then go to your account settings → API tokens → Add API token. Give it a descriptive name like testpypi-my-package and copy the token — it looks like pypi-....

Store it as an environment variable in your terminal for testing:

export UV_PUBLISH_TOKEN="pypi-..."   # for real PyPI
export UV_TESTPYPI_TOKEN="pypi-..." # for TestPyPI

Publishing to TestPyPI

Publish to TestPyPI using the --index flag to target the named index:

# Build and publish to TestPyPI
uv build
uv publish --index testpypi --token "$UV_TESTPYPI_TOKEN"

Verify the package appears at https://test.pypi.org/project/my-package/. Then install it in a fresh virtual environment to confirm everything works end-to-end:

# Install from TestPyPI to verify
uv pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ my-package

Note: The --extra-index-url https://pypi.org/simple/ is important — it tells pip to fall back to PyPI for your package’s dependencies, since TestPyPI does not mirror every package on PyPI.

Publishing to PyPI

Once your package is verified on TestPyPI, publishing to the real PyPI follows the same pattern — but with real credentials. There are two authentication strategies: API tokens (simple, long-lived) and Trusted Publishing (secure, credential-free, recommended for CI).

Publishing with an API Token

Generate an API token at pypi.org/manage/account/tokens. Scope it to a specific project or your entire account. Then publish:

uv build
uv publish --token "$UV_PUBLISH_TOKEN"

You can also configure PyPI as a named index in pyproject.toml and use --index pypi for symmetry with TestPyPI:

[[tool.uv.index]]
name = "pypi"
url = "https://pypi.org/simple/"
default = true

Trusted Publishing (Recommended for CI)

API tokens are long-lived credentials — if they leak, anyone can publish as you. Trusted Publishing ties publication rights to your GitHub Actions workflow identity using OpenID Connect (OIDC), so there are no secrets to manage at all.

To set up Trusted Publishing for your package on PyPI:

  • Go to pypi.org/manage/account/publishing/
  • Click Add a new pending publisher
  • Fill in your GitHub repository URL, workflow filename (e.g., publish.yml), and environment name (e.g., production)
  • Save — PyPI now trusts any workflow run from that path

In your GitHub Actions workflow, uv publish automatically detects the OIDC token in GitHub Actions and uses Trusted Publishing — no --token flag needed:

- name: Publish to PyPI
  env:
    UV_PUBLISH_TOKEN: ${{ secrets.OIDC_TOKEN }}  # GitHub provides this automatically
  run: uv publish --index pypi

Security note: Trusted Publishing is supported for PyPI but not yet for TestPyPI. For TestPyPI publication in CI, you still need an API token stored as a GitHub Secret.

uv in GitHub Actions

The astral-sh/setup-uv GitHub Action is the official way to install uv in a workflow. It installs uv, caches it between runs, and optionally sets up a specific Python version.

Installing uv with setup-uv

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install uv
        uses: astral-sh/setup-uv@v6
        with:
          enable-cache: true  # cache uv itself between runs

      - name: Install Python
        run: uv python install 3.12

      - name: Install dependencies
        run: uv sync --frozen

      - name: Run tests
        run: uv run pytest

Caching the Virtual Environment for Speed

The enable-cache: true option in setup-uv caches the uv binary itself. To also cache the .venv dependency directory between runs, use the actions/cache step with the lockfile hash as the cache key:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install uv
        uses: astral-sh/setup-uv@v6
        with:
          enable-cache: true

      - name: Install Python
        run: uv python install 3.12

      - name: Cache virtual environment
        uses: actions/cache@v4
        with:
          path: .venv
          key: venv-${{ runner.os }}-${{ hashFiles('uv.lock') }}

      - name: Install dependencies
        run: uv sync --frozen

      - name: Run tests
        run: uv run pytest

The cache key uses uv.lock‘s hash, so the cache is automatically invalidated whenever a dependency changes. With --frozen, uv sync installs exactly what uv.lock specifies without attempting to update it — fast, reproducible, and cache-friendly.

uv + Ruff + pytest: A Complete Dev Pipeline

A production-quality Python project needs at least three automated checks before any code reaches production: linting, formatting, and testing. With uv, each step is a one-liner and they all run through the same uv run interface. For a complete walkthrough of setting up a Python project with uv from scratch — including initializing the repo, adding dependencies, and wiring GitHub Actions — see the uv project setup guide.

Adding Dev Dependencies

Dev dependencies — tools used during development but not shipped — go in the [dependency-groups.dev] table (PEP 735). These are never installed in production.

uv add --dev ruff pytest pytest-cov

This adds the following to your pyproject.toml:

[dependency-groups]
dev = [
    "ruff>=0.9.0",
    "pytest>=8.0",
    "pytest-cov>=6.0",
]

Wiring the Pipeline in GitHub Actions

name: CI

on: [push, pull_request]

jobs:
  quality-checks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v6
        with: { enable-cache: true }
      - run: uv sync --frozen --all-groups

      - name: Lint with Ruff
        run: uv run ruff check .

      - name: Format check with Ruff
        run: uv run ruff format --check .

      - name: Run tests with coverage
        run: uv run pytest --cov=src --cov-report=xml

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v4
        with:
          files: ./coverage.xml

Notice --all-groups — without it, uv sync installs only the main dependencies, skipping the dev group. For CI, --all-groups ensures linting and testing tools are available.

uv + Docker: Lean Production Images

For production deployments, your Docker image should contain only the code and runtime dependencies needed to run the application — not your entire dev environment. uv makes this straightforward with the --no-dev flag. For a deeper dive into Docker patterns for Python applications — including multi-stage builds, non-root users, and security hardening — see the Python production security guide which covers containerised deployments in detail.

Minimal Dockerfile

# syntax=docker/dockerfile:1
FROM python:3.12-slim AS base
# Install uv into the base image
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv

WORKDIR /app

# Copy lockfile and manifest, then sync dependencies
# --no-dev excludes dev-only packages (linters, pytest, etc.)
COPY uv.lock pyproject.toml ./
RUN uv sync --frozen --no-dev --no-install-project

# Copy application source
COPY . .

# Install the project itself into the venv
RUN uv sync --frozen --no-dev

The two-step uv sync pattern is deliberate. The first sync installs all dependencies from the lockfile into the shared venv. The second sync installs your local project package on top. Separating these means that dependency changes do not invalidate the Docker layer that copies your source code, preserving the build cache.

Why python:3.12-slim? This image has no uv and no pre-installed packages — you control everything. The slim variant keeps the image small (~150MB vs ~900MB for the full image). If you need the uv Python interpreter embedded, use ghcr.io/astral-sh/uv:latest as your base instead.

Multi-Stage Build for Even Smaller Images

For the smallest possible production image, use a multi-stage build that copies only the installed venv into a fresh runtime image:

# syntax=docker/dockerfile:1
FROM python:3.12-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
COPY uv.lock pyproject.toml ./
RUN uv sync --frozen --no-dev --no-install-project --fetch-python
COPY . .
RUN uv sync --frozen --no-dev

# Runtime stage — copies only the installed venv and source
FROM python:3.12-slim AS runtime
WORKDIR /app
COPY --from=builder /app/.venv /app/.venv
COPY --from=builder /app/src /app/src
ENV PATH="/app/.venv/bin:$PATH"
CMD ["python", "-m", "my_package"]

The runtime image is smaller because it contains only the final installed state — no build tools, no uv, no cache directories. The ENV PATH line ensures Python and any console scripts you defined in [project.scripts] are on the PATH.

Practical GitHub Actions Template

Here is a complete GitHub Actions workflow that combines everything in this article — lint → test → build → publish. It covers both the standard CI checks and a release trigger for publishing.

name: Release

on:
  push:
    tags:
      - "v*"      # trigger on git tags like v0.1.0
  pull_request: {}

env:
  UV_CACHE_DIR: .uv-cache

jobs:
  # ──────────────── Continuous Integration ───────────────────────────────────
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install uv
        uses: astral-sh/setup-uv@v6
        with:
          enable-cache: true
          cache-dependency-glob: "uv.lock"

      - name: Set up Python
        run: uv python install 3.12

      - name: Install all dependencies
        run: uv sync --frozen --all-groups

      - name: Lint
        run: uv run ruff check .

      - name: Format check
        run: uv run ruff format --check .

      - name: Type check
        run: uv run mypy src/

      - name: Test
        run: uv run pytest --cov=src --cov-report=xml

      - name: Build distribution
        run: uv build

      - name: Upload artifacts
        uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/

  # ──────────────── Publish to PyPI (on tagged release) ──────────────────────
  publish:
    needs: ci
    runs-on: ubuntu-latest
    if: startsWith(github.ref, 'refs/tags/v')
    environment:
      name: pypi
      url: https://pypi.org/project/my-package/
    permissions:
      id-token: write  # required for Trusted Publishing
    steps:
      - uses: actions/checkout@v4

      - name: Install uv
        uses: astral-sh/setup-uv@v6

      - name: Download build artifacts
        uses: actions/download-artifact@v4
        with:
          name: dist
          path: dist/

      - name: Publish to PyPI
        run: uv publish --index pypi

This template has two jobs: ci runs on every push and pull request, and publish runs only on version tags (v0.1.0, v1.2.3, etc.) after the CI checks pass. The needs: ci dependency ensures tests pass before any publication happens.

Common Mistakes / Gotchas

  • Publishing to the wrong index: Running uv publish without --index sends your package to PyPI by default, even if you intended TestPyPI. Always double-check the index flag when switching between environments.
  • Accidentally stripping test tools in production: uv sync --no-dev excludes the entire dev dependency group — including pytest, Ruff, and mypy. If your tests or type checks run as part of CI before the --no-dev build, this is fine. If you rely on dev tools in production containers, move them to the main dependencies list instead.
  • Trusted Publishing OIDC misconfiguration: The id-token: write permission is required in the GitHub Actions job for Trusted Publishing to work. Without it, uv publish silently falls back to unauthenticated upload — the job appears to succeed but PyPI rejects the package.
  • Forgetting dist/ in .gitignore: Build artifacts in dist/ are not source files. If you commit them, every future build will include stale artifacts. The .gitignore generated by uv init handles this automatically, but manually created .gitignore files often miss it.

Summary and Next Steps

uv is not just a faster pip — it is a complete end-to-end tool for the Python package lifecycle. From initial uv init through development (uv sync), linting and testing (via uv run), building (uv build), and finally publishing (uv publish), every step integrates with the same tool and the same pyproject.toml configuration.

  • Add [build-system] and console script declarations to your pyproject.toml as the first step toward publishing
  • Always test on TestPyPI before touching the real index — the workflow is identical, the stakes are not
  • Use Trusted Publishing for CI — no secrets, no rotation, no leakage
  • Cache your .venv in CI using the uv.lock hash as the key — it pays off immediately on the second run
  • Use --no-dev in production Docker images to keep them lean and attack-surface minimal

For a deeper dive into uv’s capabilities, see the official building and publishing guide on Astral Docs and the GitHub Actions integration guide.

Frequently Asked Questions

Can I publish a package without a GitHub repository?

Yes. PyPI does not require a GitHub link. However, Trusted Publishing (the secure, token-free CI publishing method) requires a GitHub Actions workflow. If you do not use GitHub, you can still publish using an API token — store it as a secret in your CI system (GitLab CI, Bitbucket Pipelines, etc.) and pass it to uv publish --token.

What is the difference between uv build and python -m build?

python -m build is the reference build frontend from the build package — it installs build dependencies into an isolated environment and calls the backend. uv build is native and significantly faster; it does not create an isolated environment, uses your existing venv, and handles both source distributions and wheels in one command. For publishing workflows, uv build is the recommended tool.

Can I use uv publish with a private package index?

Yes. Add your private index as a named [[tool.uv.index]] in pyproject.toml and use uv publish --index <name> to target it. Many teams use Artifactory, GitHub Package Registry, or a self-hosted PyPI proxy to cache and control access to packages in enterprise environments.

Related posts
Python

Pydantic Agent Basics: A Complete 2026 Tutorial

ProgrammingPython

Production-Ready MCP Servers — Security, Testing & Deployment

ProgrammingPython

Build Your First MCP Server with Python SDK — Fundamentals

ProgrammingPython

Connect FastAPI to MCP — Two Integration Patterns

Leave a Reply