Recently, I reached a point where I needed to publish a few Python packages: one was an API client for a backend I built, and other was a CLI tool that leverages that API.
I went through a bunch of tutorials, but each one seem to be missing something — some used different package managers, others skipped the CI/CD part, and a few were already outdated.
After figuring out a workflow that finally worked for me, I realized it might save others time and frustration. So in this article, I’m sharing everything I wish I had found in one place — a clean, working setup to build and publish your Python package using GitHub Actions.
What a Python Package Is
A Python package is a piece of Python code that is bundled and published to a package registry — like PyPI.org, GitHub Packages, or others.
It allows software engineers to share reusable code with others, without needing to copy and paste files manually. Instead, anyone can install it using a simple pip install
command.
Why Do I Might Want to Publish One
So, why would you even want to publish a Python package?
In my case, I needed to give other engineers an easy way to interact with our product’s API. Instead of having them make raw HTTP requests, I built a Python package that wraps the API and provides type definitions — making it more stable, convenient, and developer-friendly.
I also created a CLI tool that communicates with the same product. Packaging it as a Python module makes it easy to distribute and install via pip
. It’s especially useful for automating tasks directly from the terminal.
There are many other reason to share your code as a Python package — from internal tools to open source libraries — but I’ll focus on just these examples now.
What We’ll Cover?
Here is a step-by-step breakdown of what we’ll do in this guide:
Start from a simple CLI tool boilerplate
We’ll begin with a tiny Python CLI tool called yesterday, which tells the user what day was yesterday. The tool is already set up as a basic boilerplate.Initialize the project with uv
We’ll set upuv
as our project manager and prepare the package for building and distribution.Publish to Test PyPI
We’ll walk through publishing the package to Test PyPI to verify everything works.Set up automatic versioning
We’ll configure automatic versioning to make managing releases easier and more consistent.Automate publishing with GitHub Actions
Finally, we’ll create a GitHub Action that automates the package publishing process on push.
Step 1: Boilerplate
🔗 Code for this step (completed): steps/1-boilerplate
I’ve prepared a tiny Python CLI tool that will serve as our starting point.
If you’d like to follow along, make sure to fork the repository first. Since we’ll be setting up CI/CD pipelines later, you’ll need control over the repo to run GitHub Actions.
Once you’ve cloned the repo, you can test that everything works by running the following command from the project root:
$ python yesterday_cli/main.py
Yesterday was Saturday
Step 2: Adding uv
to the project
🔗 Code for this step (completed): steps/2-add-uv
Now we’ll prepare the project configuration for our Python package using uv
.
Install uv
Follow the installation instructions here
Make sure you have version 0.4.15
or higher to avoid compatibility issues.
You can check your version with:
$ uv version
uv 0.6.14 (a4cec56dc 2025-04-09)
And upgrade if needed:
$ uv self update
Initialize the project
From the project root, run:
$ uv init
This will create the following files:
-
pyproject.toml
— The main configuration file for your package. -
main.py
— A placeholder file (you can safely delete it). -
.python-version
— Specifies the Python version used in this project.
💡 Tip: You can run uv init --bare instead to prevent main.py from creation.
Update pyproject.toml
1/ Change the project name to avoid conflicts
Since we’re going to publish this package to the public Python registry, the name must be unique. Update the [project] section like this:
[project]
-name = "yesterday-cli" # TODO: change the name of the package
+name = "yesterday-cli-ikhrustalev"
2/ Add scripts
and build-system
sections
To make you CLI available with the yesterday
command, add this block to the end of your pyproject.toml
:
[project.scripts]
yesterday = "yesterday_cli.main:cli"
[build-system]
requires = ["setuptools", "setuptools_scm[toml]"]
build-backend = "setuptools.build_meta"
Test your changes
Now you can run:
$ uv run yesterday
If everything is set up correctly, you should see the CLI output.
This works thanks to the [project.scripts]
section, which exposes your entry point.
Step 3: Publishable config
🔗 Code for this step (completed): steps/3-publish-config
Before we can publish the Python package, we need to make a few more updates to the pyproject.toml
file.
1/ Add authors
The Python registry requires package metadata, including the author’s name and email. Add the following block inside the [project]
section:
[project]
...
authors = [{ name = "<Your Name>", email = "<your email>" }]
2/ Configure which packages to include
To control what files are included when building the package, add the following block to the end of your pyproject.toml
file:
[tool.setuptools.packages.find]
include = ["yesterday_cli"]
exclude = [".venv", "tests/*", "*/tests/*"]
This ensures only the relevant code gets bundled, and avoids including your virtual environment, tests, or other noise.
Test your changes
Now run the following command from the project root:
$ uv build
If everything works correctly, it will create two new directories:
-
dist/
— contains your packaged .tar.gz and .whl files. -
<your_package_name>.egg-info
— contains metadata for the package.
💡 Tip: You don’t need to commit these directories to the Git. It is a good idea to add them to your .gitignore file.
Step 3.1: Manually publish to Test PyPI
Now it is time to publish our package to Test PyPI — a Python registry specifically meant for testing the publishing process.
1/ Register a Test PyPI Account
If you don’t already have an account, go to: https://test.pypi.org/account/register/
Fill out the registration form to create your account.
2/ Create an API token
Next, visit Account settings, scroll down to the API tokens section, and click Add API token.
- Give the token a descriptive name.
- Choose Entire account as the scope.
- Click Create token.
💡 Tip: In a real project, it’s a good idea to limit the token scope to a specific package. But for simplicity, we’re using the Entire account scope in this guide.
After creation, a token will appear on screen. Copy it and store it in a secure place — you won’t be able to see it again!
In the terminal, store the token in an environment variable:
$ TEST_PYPI_TOKEN=pypi-***
3/ Build and publish the package
Navigate to your project root, and run:
$ uv build
You should see output like this:
Successfully built dist/<package_name>-0.1.0.tar.gz
Successfully built dist/<package_name>-0.1.0-py3-none-any.whl
Then publish to Test PyPI:
$ uv publish --publish-url https://test.pypi.org/legacy/ --token "$TEST_PYPI_TOKEN"
Example output:
Publishing 2 files https://test.pypi.org/legacy/
Uploading <package_name>-0.1.0-py3-none-any.whl (2.6KiB)
Uploading <package_name>-0.1.0.tar.gz (2.3KiB)
⚠️ 403 Error? You may be trying to publish a package name that already exists. PyPI doesn’t allow overwriting packages owned by other users — try using a unique name.
4/ Install and test the package
Your package should now be visible on your Test PyPI Projects page.
Click the View button next to your package. You’ll see its details including installation instructions.
Run the install command:
$ pip install -i https://test.pypi.org/simple/ <package_name>
If successful:
Successfully installed <package_name>-cli-0.1.0
And finally, verify it works:
$ yesterday -h
You should see:
usage: yesterday [-h] [-d] [-q]
Tell you what day was yesterday.
options:
-h, --help show this help message and exit
-d, --date print yesterday's full date
-q, --quiet just output the date, no text
Step 4: Automatic versioning
🔗 Code for this step (completed): steps/4-automatic-versioning
In our current setup, we have to manually update the version in pyproject.toml
. That might be fine for rarely updated libraries or solo project, but in real-world workflows with frequent changes and multiple contributors, manual versioning becomes painful and error-prone.
To fix this, we’ll set up automatic versioning using setuptools_scm
. This approach will:
- Assign a unique version to every test build (with a
dev...
suffix). Use a Git tag (vX.Y.Z
) as the version source for production releases.
1/ Instruct the build system how to resolve the version
Add the following block to the end of your pyproject.toml
:
[tool.setuptools_scm]
tag_regex = "^v(?P<version>.*)$" # 1
write_to = "yesterday_cli/_version.py" # 2
What this does:
-
tag_regex
tells the build system to extract the version from Git tags that matchvX.Y.Z
. -
write_to
tells it to generate a_version.py
file with the resolved version.
2/ Make the version dynamic
Replace the hardcoded version in pyproject.toml with this:
[project]
...
-version = "0.1.0"
+dynamic = ["version"]
This tells the build system that the version will be determined automatically — no need to hardcode it anymore.
3/ Test your changes
Now run:
$ uv build
If everything goes well, you should see a new file: yesterday_cli/_version.py
.
It will contains something like:
__version__ = version = '0.1.dev3+g464fb74.d20250414'
4/ Re-export the version in init.py
It is a good practice to expose the package version at the top level.
In yesterday_cli/__init__.py
, add:
from ._version import __version__
__all__ = ["__version__"]
Now __version__
will be available when someone does:
import yesterday_cli
print(yesterday_cli.__version__)
5/ Exclude the generated file from Git
Since _version.py is auto-generated, we don’t want to commit it.
Add these lines to the end of your .gitignore
:
# Auto-generated version file
yesterday_cli/_version.py
Step 5: Creating the publishing pipeline
🔗 Code for this step (completed): steps/5-github-action
Now comes the most exciting part — writing a CI/CD pipeline to automatically publish the package!
Update pyproject.toml
To make sure our package can be uploaded to PyPI and Test PyPI, we need to disable the local version part (the part after the +
sign in versions like 0.1.dev3+g464fb74
).
PyPI and Test PyPI do not allow packages with a local version segment to be uploaded.
To fix this, update your [tool.setuptools_scm]
section by adding:
[tool.setuptools_scm]
...
+local_scheme = "no-local-version"
This tells setuptools_scm
to omit any local version metadata when building the final package.
Prepare a GitHub Action
Create a new file at .github/workflows/publish_lib.yml
with the following contents:
name: Publish to PyPI
on: # 1
push:
branches: [main]
tags: ["v[0-9]+.[0-9]+.[0-9]+"]
permissions: # 2
attestations: write
contents: write
id-token: write
jobs:
build: # 3
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: hynek/build-and-inspect-python-package@v2
with:
attest-build-provenance-github: 'true'
publish-test-pypi: # 4
name: Publish to Test PyPI
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/download-artifact@v4
with:
name: Packages
path: dist
- name: Publish to Test PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
repository-url: https://test.pypi.org/legacy/
verbose: true
publish-pypi: # 5
name: Publish to PyPI
runs-on: ubuntu-latest
needs: build
if: startsWith(github.ref, 'refs/tags/v')
steps:
- uses: actions/download-artifact@v4
with:
name: Packages
path: dist
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
verbose: true
- name: Create GitHub Release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: Release ${{ github.ref }}
body: Release ${{ github.ref }}
We’ll walk through each part of the workflow below:
1/ Workflow triggers
on:
push:
branches: [main]
tags: ["v[0-9]+.[0-9]+.[0-9]+"]
This tells GitHub to run the workflow:
- On every push to
main
branch. - On every push of a Git tag that matches pattern
vX.Y.Z
.
2/ Workflow permissions
permissions:
attestations: write
contents: write
id-token: write
This block grants the necessary permissions for:
- Creating provenance attestations (to verify the build’s origin).
- Create GitHub releases.
- Using an OIDC token for passwordless authentication with PyPI.
3/ Build step
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: hynek/build-and-inspect-python-package@v2
with:
attest-build-provenance-github: 'true'
Here, the library is built from the source code.
We use hynek/build-and-inspect-python-package@v2
to
- Build the package.
- Generate provenance metadata for security.
- Upload the build as a GitHub Actions artifact (default name:
Packages
)
⚠️ It is important to set
fetch-depth: 0
in your checkout step, so thatsetuptools_scm
can extract the version from Git tags correctly.
4/ Publish to Test PyPI
publish-test-pypi:
name: Publish to Test PyPI
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/download-artifact@v4
with:
name: Packages
path: dist
- name: Publish to Test PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
repository-url: https://test.pypi.org/legacy/
verbose: true
This step runs only if the build step succeeds.
It:
- Downloads the built artifacts.
- Publishes the to Test PyPI (using
pypa/gh-action-pypi-publish@release/v1
).
5/ Publish to PyPI and create GitHub release
publish-pypi:
name: Publish to PyPI
runs-on: ubuntu-latest
needs: build
if: startsWith(github.ref, 'refs/tags/v')
steps:
- uses: actions/download-artifact@v4
with:
name: Packages
path: dist
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
verbose: true
- name: Create GitHub Release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: Release ${{ github.ref }}
body: Release ${{ github.ref }}
This step is similar to publishing to Test PyPI, but with some extras:
- It only runs when a proper release tag (
vX.Y.Z
) is pushed. - It uploads the package to PyPI (the real registry).
- Finally, it creates a Github release for the pushed version.
Configure trusted publisher
Now it’s time to configure Trusted Publishers for PyPI and Test PyPI.
This allows our CI/CD pipeline to automatically publish packages storing API tokens manually.
Configure Trusted Publisher for Test PyPI
- Visit https://test.pypi.org/manage/account/publishing/
- Make sure GitHub tab is active.
- Fill out the form:
-
PyPI Project Name: Name of your package (from
pyproject.toml
). - Owner: Your GitHub username of organization name.
- Repository name: The name of your GitHub repository.
-
Workflow name: The name of your workflow file (should be
publish_lib.yml
). - Environment name: Leave blank (if you’re following this tutorial).
- Click Add.
Repeat for Production PyPI
Now repeat the same at: https://pypi.org/manage/account/publishing/
This ensures both Test PyPI and PyPI recognize your GitHub workflow as a trusted publisher.
Trigger the GitHub Workflow
Now that everything is configured, push any change to your default branch (main
or master
). Even an empty commit will trigger the publishing workflow.
When you navigate to your GitHub repository and click on the Actions tab, you should see the workflow running.
Look for the workflow labeled “Publish to PyPI”.
GitHub Workflow for PyPI (Production)
Now let’s trigger the production publishing process.
From the project root, run:
$ git tag v0.1.1 HEAD
$ git push --tags
This will:
- Create a new Git tag
v0.1.1
. - Push it to GitHub.
- Trigger the production pipeline.
Navigate back to your GitHub repository’s Action tab.
You should now see a new run triggered by the tag.
If everything goes well, inside the workflow you should see:
Outro
Congratulations! You should now have your Python package successfully published to both Test PyPI and PyPI.
In this tutorial, we accomplished:
- Started with a small CLI tool.
- Initialized the project with
pyproject.toml
usinguv
. - Prepared a publishing configuration with automatic versioning.
- Wrote a GitHub Actions workflow for CI/CD.
- Set up Trusted Publisher configuration.
- Verified that everything works end-to-end.
What are you building?
Are you considering creating your own Python package?
Share your ideas in the comments — I’d love to hear what you’re working on!
I’m Ilya Khrustalev. For over 20 years, I’ve helped companies build, launch, and scale their products, and level up their engineering teams.
Feel free to connect with me on:
Top comments (0)