How to build and publish a Python Package to PyPI with uv & GitHub actions

By Vinzenz Halhammer April 19, 2025

In this article we will continue where we stopped in the previous article. This is the perfect place to get started if you want to publish your own python package and make it installable via pip for everyone.

We use the continuous integration (CI) workflow with GitHub actions from the previous article. We then build on that and add the steps to build and publish our package to PyPI. Here it is important that we use TestPyPI, which is a test environment for PyPI, to test our package before we publish it to the real PyPI. We will not publish to the real PyPI index to not clutter it with test packages.

So let us start by reviewing the structure of our repository and the files we need to create. We will then define a simple example package with some functions and tests. Finally, we will set up the GitHub actions workflow to build and publish our package to TestPyPI.

Repository structure

We start off with defining the structure of ou repository with all the needed files. I will explain in a minute what each file does and what the contents are.

python-package-example/
├── .github/
│   └── workflows/
│       └── publish.yml
├── python-package-example/                   
│   ├── __init__.py
│   └── util.py
├── tests/
│   └── test_util.py
├── pyproject.toml
├── README.md
├── LICENSE

The root folder is just the name of the repository python-package-example. Then we have all the subfolder and files:

Name Function
.github/workflows/ Folder where the yml file with the GitHub actions configuration is stored
python-package-example Folder where the main code and content of your package/application is stored
tests/ Folder where the tests of your functions from examples/ are stored
pyproject.toml python configuration file for requirements, build and tests

Define pyproject.toml file

Let us first create the configuration of our project in the pyproject.toml file. The pyproject.toml file was introduced in PEP 518 and is now the standard way to define a python project. It contains all the information about your project, such as the name, version, dependencies, and build system. The whole project configuration is then controlled in one file instead of having muliple files like setup.py, requirements.txt, pytest.ini etc.

We will also use the python package manager uv in our project setup. With uv we can cover all our needs for package management, building, testing and publishingin one tool. Maybe I will write a separate article about uv in the future.

But now let us have a look at the content of pyproject.toml file.

pyproject.toml
                    [build-system]
requires = ["setuptools>=42", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "python-package-example-vh"
version = "0.0.5"
authors = [
    { name = "Vinzenz Halhammer", email = "[email protected]" }
]
description = "A minimal example how to build and publish a python package"
readme = "README.md"
requires-python = ">=3.7"
classifiers = [
    "Programming Language :: Python :: 3",
    "License :: OSI Approved :: MIT License",
    "Operating System :: OS Independent",
]

[project.urls]
"Homepage" = "https://github.com/vinzenzhalhammer/python-package-example"

[tool.setuptools.packages.find]
include = ["python_package_example", "python_package_example.*"]

[project.optional-dependencies]
test = [
    "pytest",
    "pytest-cov"
]

[tool.pytest.ini_options]
addopts = "--cov=examples --cov-report=xml --cov-report=term"
testpaths = "tests"
pythonpath = "."

We now see why the pyproject.toml file is so convenient. We specify our build tools, the project metadata, the dependencies and the pytest settings all in one file. In our case we only have optional dependencies for testing, but you can also define dependencies for the main environment using the keyword dependencies in the [project] section.

For details on the example functions, pytest setup and continuous integration workflow you can visit my previous article here.

Setting up TestPyPI

The first step is to create an account on TestPyPI. This is a test environment for PyPI, where you can publish your packages without cluttering the real PyPI index.

After you created your account, you can register your project as trusted publisher. This is recommended for publishing via GitHub actions. You can find the instructions here. Once you have registered your project we can proceed to the next step.

Setting up the build workflow

We will setup the GitHub actions workflow to build and publish our package to TestPyPI. We do it in a similar fashion as in the previous article, but we will add a few more steps to build and publish our package. In addition we utilize the action provided from astral to set up the uv environment and build our package.

.github/workflows/publish.yml
                    name: Publish to TestPyPI

on:
    push:
    tags:
        - 'v*'
    branches:
        - main
    pull_request:

permissions:
    id-token: write
    contents: read

jobs:
    test:
    runs-on: ubuntu-latest
    strategy:
        matrix:
        python-version:
            - "3.11"
            - "3.12"

    steps:
    - uses: actions/checkout@v4

    - name: Install uv and set the python version
        uses: astral-sh/setup-uv@v5
        with:
        python-version: ${{ matrix.python-version }}

    - name: Install the project
        run: uv sync --all-extras --dev

    - name: Run tests
        run: uv run pytest tests

    - name: Upload coverage reports to Codecov
        uses: codecov/codecov-action@v5
        with:
        token: ${{ secrets.CODECOV_TOKEN }}
        slug: vinzenzhalhammer/python-package-example

    build-and-publish:
    runs-on: ubuntu-latest
    needs: test
    environment: pypi
    if: github.ref_type == 'tag'

    steps:
    - uses: actions/checkout@v4

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

    - name: "Set up Python"
        uses: actions/setup-python@v5
        with:
        python-version-file: "pyproject.toml"

    - name: Build the package
        run: uv build

    - name: Publish to TestPyPI
        uses: pypa/gh-action-pypi-publish@release/v1
        with:
        repository-url: https://test.pypi.org/legacy/

A few explanations to the whole flow. The first job is the test job which runs on every push to the main branch and every pull request. It checks out the code, installs the uv environment, installs the project and runs the tests. The second job is the build-and-publish job which only runs on tags. It checks out the code, installs the uv environment, sets up the python version, builds the package and publishes it to TestPyPI. As we already covered the test job in the previous article, I will not go into detail here. The only thing to mention is that we use the uv sync command to install the project and all its dependencies. The --all-extras flag installs all optional dependencies, which in our case is the test dependencies.

Let us first describe the triggers for the workflow. We want to run the workflow on every push to the main branch and every pull request. In addition we want to run the workflow on every tag that starts with a v, e.g. v0.0.1. This is done in the on section of the workflow. Additionally we add the tag requirement to the build-and-publish job, so that it only runs on tags. This is done in the if section of the job.

The permissions section defines the permissions for the workflow. In our case we need to write the id-token and read the contents of the repository. This is needed to publish the package to TestPyPI.

Now we are ready to cover the build-and-publish job. We first check out the code and install the uv environment. Then we set up the python version using the actions/setup-python@v5 action. We use the python-version-file input to set the python version from the pyproject.toml file. This is needed to build the package with the correct python version. Then we build the package using the uv build command. This will create a dist folder with the package files in it. Finally we publish the package to TestPyPI using the pypa/gh-action-pypi-publish@release/v1 action. We use the repository-url input to set the URL for TestPyPI. This is needed to publish the package to TestPyPI instead of the real PyPI.

Now we are ready to publish our package to TestPyPI. We can do this by creating a tag in our repository. This can be done by running the following command in the terminal:

cmd
                        git tag v0.0.5
git push origin v0.0.5

This will create a git tag v0.0.5 and push it to the remote repository. This will then trigger the build workflow and publish the package to TestPyPI. You can then check the package on TestPyPI by visiting this link.

Finishing touches

Now that your package is published to TestPyPI, you can add some nice touches to your package like release notes and a build passing badge to your README file to signal that your package is working and up to date.

But first we can install the package from TestPyPI, you can do this with the following command:

cmd
                        pip install -i https://test.pypi.org/simple/ python-package-example-vh

Replace python-package-example-vh with your package name if it differs. This will install the package from TestPyPI.

Now you can import your package and use it in your python code!

Creating Releases with Release Notes on GitHub

To create a release with release notes on GitHub, follow these steps:

  1. Go to your repository on GitHub.
  2. Click on the Releases section.
  3. Click Draft a new release.
  4. Enter the tag version (e.g., v0.0.5) and select the branch (e.g., main).
  5. Add a title and description for the release. Use the description to include release notes, such as new features, bug fixes, or changes.
  6. Click Publish release.

If you do it this way, your releases will be shown on your package GitHub page and your nice release notes will be shown on the GitHub releases page. This is a great way to keep track of changes and updates to your package.

Adding a Build Passing Badge to the README

To add a build passing badge to your README file, follow these steps:

  1. Go to your repository on GitHub.
  2. Click on the Actions tab.
  3. Locate the workflow you want to add a badge for (e.g., Publish to TestPyPI).
  4. Copy the URL of the workflow and append /badge.svg to it. For example:
    markdown
                                    ![Build Status](https://github.com/vinzenzhalhammer/python-package-example/actions/workflows/publish.yml/badge.svg)

Commit and push the changes to your repository. The badge will now display the status of your workflow on the README file like this:

Build badge

Now you have a published Python package on TestPyPI! You can apply this knowledge to your own Python projects, share your work with the community, and contribute to the ecosystem. This is a great opportunity to showcase your ideas and gather valuable feedback. Happy coding!