<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ilya Khrustalev</title>
    <description>The latest articles on DEV Community by Ilya Khrustalev (@ikhrustalev).</description>
    <link>https://dev.to/ikhrustalev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1245979%2F0ada689f-6268-425b-847b-9e843669bd7a.jpeg</url>
      <title>DEV Community: Ilya Khrustalev</title>
      <link>https://dev.to/ikhrustalev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ikhrustalev"/>
    <language>en</language>
    <item>
      <title>Publish Your Python Package to PyPI Using GitHub Actions in 2025</title>
      <dc:creator>Ilya Khrustalev</dc:creator>
      <pubDate>Thu, 17 Apr 2025 21:00:00 +0000</pubDate>
      <link>https://dev.to/ikhrustalev/publish-your-python-package-to-pypi-using-github-actions-in-2025-5dj4</link>
      <guid>https://dev.to/ikhrustalev/publish-your-python-package-to-pypi-using-github-actions-in-2025-5dj4</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx12ay2ei3o9hk9h14hmg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx12ay2ei3o9hk9h14hmg.png" alt="Image description" width="700" height="335"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h1&gt;
  
  
  What a Python Package Is
&lt;/h1&gt;

&lt;p&gt;A Python package is a piece of Python code that is bundled and published to a package registry — like &lt;a href="https://pypi.org/" rel="noopener noreferrer"&gt;PyPI.org&lt;/a&gt;, GitHub Packages, or others.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;pip install&lt;/code&gt; command.&lt;/p&gt;

&lt;h1&gt;
  
  
  Why Do I Might Want to Publish One
&lt;/h1&gt;

&lt;p&gt;So, why would you even want to publish a Python package?&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;pip&lt;/code&gt;. It’s especially useful for automating tasks directly from the terminal.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h1&gt;
  
  
  What We’ll Cover?
&lt;/h1&gt;

&lt;p&gt;Here is a step-by-step breakdown of what we’ll do in this guide:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Start from a simple CLI tool boilerplate&lt;br&gt;
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.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Initialize the project with uv&lt;br&gt;
We’ll set up &lt;code&gt;uv&lt;/code&gt; as our project manager and prepare the package for building and distribution.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Publish to Test PyPI&lt;br&gt;
We’ll walk through publishing the package to &lt;a href="https://test.pypi.org/" rel="noopener noreferrer"&gt;Test PyPI&lt;/a&gt; to verify everything works.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Set up automatic versioning&lt;br&gt;
We’ll configure automatic versioning to make managing releases easier and more consistent.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Automate publishing with GitHub Actions&lt;br&gt;
Finally, we’ll create a GitHub Action that automates the package publishing process on push.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Step 1: Boilerplate
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;🔗 Code for this step (completed): &lt;a href="https://github.com/rznzippy/yesterday_cli/tree/steps/1-boilerplate" rel="noopener noreferrer"&gt;steps/1-boilerplate&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I’ve prepared a tiny Python CLI tool that will serve as our starting point.&lt;/p&gt;

&lt;p&gt;If you’d like to follow along, make sure to &lt;strong&gt;fork the repository&lt;/strong&gt; first. Since we’ll be setting up CI/CD pipelines later, you’ll need control over the repo to run GitHub Actions.&lt;/p&gt;

&lt;p&gt;Once you’ve cloned the repo, you can test that everything works by running the following command from the project root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;python yesterday_cli/main.py
Yesterday was Saturday
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h1&gt;
  
  
  Step 2: Adding &lt;code&gt;uv&lt;/code&gt; to the project
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;🔗 Code for this step (completed): &lt;a href="https://github.com/rznzippy/yesterday_cli/tree/steps/2-add-uv" rel="noopener noreferrer"&gt;steps/2-add-uv&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Now we’ll prepare the project configuration for our Python package using &lt;code&gt;uv&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install &lt;code&gt;uv&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Follow the installation instructions &lt;a href="https://docs.astral.sh/uv/getting-started/installation/" rel="noopener noreferrer"&gt;here&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Make sure you have version &lt;code&gt;0.4.15&lt;/code&gt; or &lt;strong&gt;higher&lt;/strong&gt; to avoid compatibility issues.&lt;/p&gt;

&lt;p&gt;You can check your version with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;uv version
uv 0.6.14 &lt;span class="o"&gt;(&lt;/span&gt;a4cec56dc 2025-04-09&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And upgrade if needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;uv self update
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Initialize the project
&lt;/h2&gt;

&lt;p&gt;From the project root, run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;uv init
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This will create the following files:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;pyproject.toml&lt;/code&gt; — The main configuration file for your package.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;main.py&lt;/code&gt; — A placeholder file (you can safely delete it).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.python-version&lt;/code&gt; — Specifies the Python version used in this project.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 Tip: You can run uv init --bare instead to prevent main.py from creation.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Update &lt;code&gt;pyproject.toml&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1/ Change the project name to avoid conflicts&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Since we’re going to publish this package to the public Python registry, the name must be unique. Update the [project] section like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;[project]
&lt;span class="gd"&gt;-name = "yesterday-cli" # TODO: change the name of the package
&lt;/span&gt;&lt;span class="gi"&gt;+name = "yesterday-cli-ikhrustalev"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2/ Add &lt;code&gt;scripts&lt;/code&gt; and &lt;code&gt;build-system&lt;/code&gt; sections&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;To make you CLI available with the &lt;code&gt;yesterday&lt;/code&gt; command, add this block to the end of your &lt;code&gt;pyproject.toml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[project.scripts]&lt;/span&gt;
&lt;span class="py"&gt;yesterday&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"yesterday_cli.main:cli"&lt;/span&gt;

&lt;span class="nn"&gt;[build-system]&lt;/span&gt;
&lt;span class="py"&gt;requires&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"setuptools"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"setuptools_scm[toml]"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="py"&gt;build-backend&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"setuptools.build_meta"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Test your changes
&lt;/h2&gt;

&lt;p&gt;Now you can run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;uv run yesterday
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If everything is set up correctly, you should see the CLI output.&lt;/p&gt;

&lt;p&gt;This works thanks to the &lt;code&gt;[project.scripts]&lt;/code&gt; section, which exposes your entry point.&lt;/p&gt;

&lt;h1&gt;
  
  
  Step 3: Publishable config
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;🔗 Code for this step (completed): &lt;a href="https://github.com/rznzippy/yesterday_cli/tree/steps/3-publish-config" rel="noopener noreferrer"&gt;steps/3-publish-config&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Before we can publish the Python package, we need to make a few more updates to the &lt;code&gt;pyproject.toml&lt;/code&gt; file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1/ Add authors&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The Python registry requires package metadata, including the author’s name and email. Add the following block inside the &lt;code&gt;[project]&lt;/code&gt; section:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[project]&lt;/span&gt;
&lt;span class="err"&gt;...&lt;/span&gt;
&lt;span class="py"&gt;authors&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;{&lt;/span&gt; &lt;span class="py"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"&amp;lt;Your Name&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;email&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"&amp;lt;your email&amp;gt;"&lt;/span&gt; &lt;span class="err"&gt;}&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2/ Configure which packages to include&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;To control what files are included when building the package, add the following block to the end of your &lt;code&gt;pyproject.toml&lt;/code&gt; file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[tool.setuptools.packages.find]&lt;/span&gt;
&lt;span class="py"&gt;include&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"yesterday_cli"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="py"&gt;exclude&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;".venv"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"tests/*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"*/tests/*"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This ensures only the relevant code gets bundled, and avoids including your virtual environment, tests, or other noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test your changes
&lt;/h2&gt;

&lt;p&gt;Now run the following command from the project root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;uv build
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If everything works correctly, it will create two new directories:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;dist/&lt;/code&gt; — contains your packaged .tar.gz and .whl files.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;your_package_name&amp;gt;.egg-info&lt;/code&gt; — contains metadata for the package.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 Tip: You don’t need to commit these directories to the Git. It is a good idea to add them to your .gitignore file.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h1&gt;
  
  
  Step 3.1: Manually publish to Test PyPI
&lt;/h1&gt;

&lt;p&gt;Now it is time to publish our package to Test PyPI — a Python registry specifically meant for testing the publishing process.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1/ Register a Test PyPI Account&lt;/strong&gt;&lt;br&gt;
If you don’t already have an account, go to: &lt;a href="https://test.pypi.org/account/register/" rel="noopener noreferrer"&gt;https://test.pypi.org/account/register/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Fill out the registration form to create your account.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2/ Create an API token&lt;/strong&gt;&lt;br&gt;
Next, visit &lt;a href="https://test.pypi.org/manage/account/" rel="noopener noreferrer"&gt;Account settings&lt;/a&gt;, scroll down to the &lt;strong&gt;API tokens&lt;/strong&gt; section, and click Add API token.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Give the token a descriptive name.&lt;/li&gt;
&lt;li&gt;Choose &lt;strong&gt;Entire account&lt;/strong&gt; as the scope.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Create token&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2aax7tl23tkgqoy1mskx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2aax7tl23tkgqoy1mskx.png" alt="Image description" width="800" height="513"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 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.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;After creation, a token will appear on screen. &lt;strong&gt;Copy it and store it in a secure place — you won’t be able to see it again!&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In the terminal, store the token in an environment variable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ TEST_PYPI_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;pypi-&lt;span class="k"&gt;***&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3/ Build and publish the package&lt;/strong&gt;&lt;br&gt;
Navigate to your project root, and run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;uv build
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see output like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Successfully built dist/&amp;lt;package_name&amp;gt;-0.1.0.tar.gz
Successfully built dist/&amp;lt;package_name&amp;gt;-0.1.0-py3-none-any.whl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then publish to Test PyPI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;uv publish &lt;span class="nt"&gt;--publish-url&lt;/span&gt; https://test.pypi.org/legacy/ &lt;span class="nt"&gt;--token&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TEST_PYPI_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Publishing 2 files https://test.pypi.org/legacy/
Uploading &amp;lt;package_name&amp;gt;-0.1.0-py3-none-any.whl &lt;span class="o"&gt;(&lt;/span&gt;2.6KiB&lt;span class="o"&gt;)&lt;/span&gt;
Uploading &amp;lt;package_name&amp;gt;-0.1.0.tar.gz &lt;span class="o"&gt;(&lt;/span&gt;2.3KiB&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;⚠️ 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.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;4/ Install and test the package&lt;/strong&gt;&lt;br&gt;
Your package should now be visible on your &lt;a href="https://test.pypi.org/manage/projects/" rel="noopener noreferrer"&gt;Test PyPI Projects page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpm59mctrp43qtnebvpxb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpm59mctrp43qtnebvpxb.png" alt="Image description" width="800" height="220"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Click the &lt;strong&gt;View&lt;/strong&gt; button next to your package. You’ll see its details including installation instructions.&lt;/p&gt;

&lt;p&gt;Run the install command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; https://test.pypi.org/simple/ &amp;lt;package_name&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If successful:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Successfully installed &amp;lt;package_name&amp;gt;-cli-0.1.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And finally, verify it works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;yesterday &lt;span class="nt"&gt;-h&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;usage: yesterday &lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;-h&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;-q&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;

Tell you what day was yesterday.

options:
  &lt;span class="nt"&gt;-h&lt;/span&gt;, &lt;span class="nt"&gt;--help&lt;/span&gt;   show this &lt;span class="nb"&gt;help &lt;/span&gt;message and &lt;span class="nb"&gt;exit&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt;, &lt;span class="nt"&gt;--date&lt;/span&gt;   print yesterday&lt;span class="s1"&gt;'s full date
  -q, --quiet  just output the date, no text
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h1&gt;
  
  
  Step 4: Automatic versioning
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;🔗 Code for this step (completed): &lt;a href="https://github.com/rznzippy/yesterday_cli/tree/steps/4-automatic-versioning" rel="noopener noreferrer"&gt;steps/4-automatic-versioning&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In our current setup, we have to manually update the version in &lt;code&gt;pyproject.toml&lt;/code&gt;. That might be fine for rarely updated libraries or solo project, but in real-world workflows with frequent changes and multiple contributors, &lt;strong&gt;manual versioning becomes painful&lt;/strong&gt; and error-prone.&lt;/p&gt;

&lt;p&gt;To fix this, we’ll set up &lt;strong&gt;automatic versioning&lt;/strong&gt; using &lt;code&gt;setuptools_scm&lt;/code&gt;. This approach will:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Assign a unique version to every test build (with a &lt;code&gt;dev...&lt;/code&gt; suffix).
Use a Git tag (&lt;code&gt;vX.Y.Z&lt;/code&gt;) as the version source for production releases.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;1/ Instruct the build system how to resolve the version&lt;/strong&gt;&lt;br&gt;
Add the following block to the end of your &lt;code&gt;pyproject.toml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[tool.setuptools_scm]&lt;/span&gt;
&lt;span class="py"&gt;tag_regex&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"^v(?P&amp;lt;version&amp;gt;.*)$"&lt;/span&gt;        &lt;span class="c"&gt;# 1&lt;/span&gt;
&lt;span class="py"&gt;write_to&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"yesterday_cli/_version.py"&lt;/span&gt;  &lt;span class="c"&gt;# 2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What this does:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;tag_regex&lt;/code&gt; tells the build system to extract the version from Git tags that match &lt;code&gt;vX.Y.Z&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;write_to&lt;/code&gt; tells it to generate a &lt;code&gt;_version.py&lt;/code&gt; file with the resolved version.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;2/ Make the version dynamic&lt;/strong&gt;&lt;br&gt;
Replace the hardcoded version in pyproject.toml with this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;[project]
...
&lt;span class="gd"&gt;-version = "0.1.0"
&lt;/span&gt;&lt;span class="gi"&gt;+dynamic = ["version"]
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This tells the build system that the version will be determined automatically — no need to hardcode it anymore.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3/ Test your changes&lt;/strong&gt;&lt;br&gt;
Now run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;uv build
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If everything goes well, you should see a new file: &lt;code&gt;yesterday_cli/_version.py&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It will contains something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;__version__&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;0.1.dev3+g464fb74.d20250414&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4/ Re-export the version in &lt;strong&gt;init&lt;/strong&gt;.py&lt;/strong&gt;&lt;br&gt;
It is a good practice to expose the package version at the top level.&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;yesterday_cli/__init__.py&lt;/code&gt;, add:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;._version&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;__version__&lt;/span&gt;


&lt;span class="n"&gt;__all__&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__version__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;__version__&lt;/code&gt; will be available when someone does:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;yesterday_cli&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;yesterday_cli&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;__version__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;5/ Exclude the generated file from Git&lt;/strong&gt;&lt;br&gt;
Since _version.py is auto-generated, we don’t want to commit it.&lt;/p&gt;

&lt;p&gt;Add these lines to the end of your &lt;code&gt;.gitignore&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="c"&gt;# Auto-generated version file&lt;/span&gt;
&lt;span class="err"&gt;yesterday_cli/_version.py&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h1&gt;
  
  
  Step 5: Creating the publishing pipeline
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;🔗 Code for this step (completed): &lt;a href="https://github.com/rznzippy/yesterday_cli/tree/steps/5-github-action" rel="noopener noreferrer"&gt;steps/5-github-action&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Now comes the most exciting part — writing a &lt;strong&gt;CI/CD pipeline&lt;/strong&gt; to automatically publish the package!&lt;/p&gt;

&lt;h2&gt;
  
  
  Update &lt;code&gt;pyproject.toml&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;To make sure our package can be uploaded to PyPI and Test PyPI, we need to &lt;strong&gt;disable the local version part&lt;/strong&gt; (the part after the &lt;code&gt;+&lt;/code&gt; sign in versions like &lt;code&gt;0.1.dev3+g464fb74&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;PyPI and Test PyPI do &lt;strong&gt;not&lt;/strong&gt; allow packages with a local version segment to be uploaded.&lt;/p&gt;

&lt;p&gt;To fix this, update your &lt;code&gt;[tool.setuptools_scm]&lt;/code&gt; section by adding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;[tool.setuptools_scm]
...
&lt;span class="gi"&gt;+local_scheme = "no-local-version"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This tells &lt;code&gt;setuptools_scm&lt;/code&gt; to omit any local version metadata when building the final package.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prepare a GitHub Action
&lt;/h2&gt;

&lt;p&gt;Create a new file at &lt;code&gt;.github/workflows/publish_lib.yml&lt;/code&gt; with the following contents:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Publish to PyPI&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;                     &lt;span class="c1"&gt;# 1&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v[0-9]+.[0-9]+.[0-9]+"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;            &lt;span class="c1"&gt;# 2&lt;/span&gt;
  &lt;span class="na"&gt;attestations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
  &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;                &lt;span class="c1"&gt;# 3&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hynek/build-and-inspect-python-package@v2&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;attest-build-provenance-github&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;true'&lt;/span&gt;

  &lt;span class="na"&gt;publish-test-pypi&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;    &lt;span class="c1"&gt;# 4&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Publish to Test PyPI&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/download-artifact@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Packages&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dist&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Publish to Test PyPI&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pypa/gh-action-pypi-publish@release/v1&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;repository-url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://test.pypi.org/legacy/&lt;/span&gt;
          &lt;span class="na"&gt;verbose&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

  &lt;span class="na"&gt;publish-pypi&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;         &lt;span class="c1"&gt;# 5&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Publish to PyPI&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;startsWith(github.ref, 'refs/tags/v')&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/download-artifact@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Packages&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dist&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Publish to PyPI&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pypa/gh-action-pypi-publish@release/v1&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;verbose&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Create GitHub Release&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/create-release@v1&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;GITHUB_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;tag_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.ref }}&lt;/span&gt;
          &lt;span class="na"&gt;release_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Release ${{ github.ref }}&lt;/span&gt;
          &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Release ${{ github.ref }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We’ll walk through each part of the workflow below:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1/ Workflow triggers&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v[0-9]+.[0-9]+.[0-9]+"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This tells GitHub to run the workflow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On every push to &lt;code&gt;main&lt;/code&gt; branch.&lt;/li&gt;
&lt;li&gt;On every push of a Git tag that matches pattern &lt;code&gt;vX.Y.Z&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;2/ Workflow permissions&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;attestations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
  &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This block grants the necessary permissions for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Creating provenance attestations (to verify the build’s origin).&lt;/li&gt;
&lt;li&gt;Create GitHub releases.&lt;/li&gt;
&lt;li&gt;Using an OIDC token for passwordless authentication with PyPI.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;3/ Build step&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hynek/build-and-inspect-python-package@v2&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;attest-build-provenance-github&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;true'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here, the library is built from the source code.&lt;/p&gt;

&lt;p&gt;We use &lt;code&gt;hynek/build-and-inspect-python-package@v2&lt;/code&gt; to&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Build the package.&lt;/li&gt;
&lt;li&gt;Generate provenance metadata for security.&lt;/li&gt;
&lt;li&gt;Upload the build as a GitHub Actions artifact (default name: &lt;code&gt;Packages&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ It is important to set &lt;code&gt;fetch-depth: 0&lt;/code&gt; in your checkout step, so that &lt;code&gt;setuptools_scm&lt;/code&gt; can extract the version from Git tags correctly.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;4/ Publish to Test PyPI&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;publish-test-pypi&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Publish to Test PyPI&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/download-artifact@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Packages&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dist&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Publish to Test PyPI&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pypa/gh-action-pypi-publish@release/v1&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;repository-url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://test.pypi.org/legacy/&lt;/span&gt;
          &lt;span class="na"&gt;verbose&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This step runs &lt;strong&gt;only if the build step succeeds&lt;/strong&gt;.&lt;br&gt;
It:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Downloads the built artifacts.&lt;/li&gt;
&lt;li&gt;Publishes the to &lt;strong&gt;Test PyPI&lt;/strong&gt; (using &lt;code&gt;pypa/gh-action-pypi-publish@release/v1&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;5/ Publish to PyPI and create GitHub release&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;publish-pypi&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Publish to PyPI&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;startsWith(github.ref, 'refs/tags/v')&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/download-artifact@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Packages&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dist&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Publish to PyPI&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pypa/gh-action-pypi-publish@release/v1&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;verbose&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Create GitHub Release&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/create-release@v1&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;GITHUB_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;tag_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.ref }}&lt;/span&gt;
          &lt;span class="na"&gt;release_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Release ${{ github.ref }}&lt;/span&gt;
          &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Release ${{ github.ref }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This step is similar to publishing to Test PyPI, but with some extras:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It only runs when a proper release tag (&lt;code&gt;vX.Y.Z&lt;/code&gt;) is pushed.&lt;/li&gt;
&lt;li&gt;It uploads the package to &lt;strong&gt;PyPI&lt;/strong&gt; (the real registry).&lt;/li&gt;
&lt;li&gt;Finally, it creates a Github release for the pushed version.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Configure trusted publisher
&lt;/h2&gt;

&lt;p&gt;Now it’s time to configure &lt;strong&gt;Trusted Publishers&lt;/strong&gt; for PyPI and Test PyPI.&lt;/p&gt;

&lt;p&gt;This allows our CI/CD pipeline to automatically publish packages storing API tokens manually.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Configure Trusted Publisher for Test PyPI&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Visit &lt;a href="https://test.pypi.org/manage/account/publishing/" rel="noopener noreferrer"&gt;https://test.pypi.org/manage/account/publishing/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Make sure GitHub tab is active.&lt;/li&gt;
&lt;li&gt;Fill out the form:&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PyPI Project Name&lt;/strong&gt;: Name of your package (from &lt;code&gt;pyproject.toml&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Owner&lt;/strong&gt;: Your GitHub username of organization name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Repository name&lt;/strong&gt;: The name of your GitHub repository.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Workflow name&lt;/strong&gt;: The name of your workflow file (should be &lt;code&gt;publish_lib.yml&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Environment name&lt;/strong&gt;: Leave blank (if you’re following this tutorial).&lt;/li&gt;
&lt;li&gt;Click Add.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp5rv60u10h4tnr54ttbc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp5rv60u10h4tnr54ttbc.png" alt="Image description" width="800" height="883"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repeat for Production PyPI&lt;/strong&gt;&lt;br&gt;
Now repeat the same at: &lt;a href="https://pypi.org/manage/account/publishing/" rel="noopener noreferrer"&gt;https://pypi.org/manage/account/publishing/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This ensures both &lt;strong&gt;Test PyPI&lt;/strong&gt; and &lt;strong&gt;PyPI&lt;/strong&gt; recognize your GitHub workflow as a trusted publisher.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trigger the GitHub Workflow&lt;/strong&gt;&lt;br&gt;
Now that everything is configured, &lt;strong&gt;push any change&lt;/strong&gt; to your default branch (&lt;code&gt;main&lt;/code&gt; or &lt;code&gt;master&lt;/code&gt;). Even an empty commit will trigger the publishing workflow.&lt;/p&gt;

&lt;p&gt;When you navigate to your GitHub repository and click on the &lt;strong&gt;Actions&lt;/strong&gt; tab, you should see the workflow running.&lt;/p&gt;

&lt;p&gt;Look for the workflow labeled “&lt;strong&gt;Publish to PyPI&lt;/strong&gt;”.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnye95z060ytm5ywrew9u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnye95z060ytm5ywrew9u.png" alt="Image description" width="800" height="453"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub Workflow for PyPI (Production)&lt;/strong&gt;&lt;br&gt;
Now let’s trigger the &lt;strong&gt;production publishing&lt;/strong&gt; process.&lt;/p&gt;

&lt;p&gt;From the project root, run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;git tag v0.1.1 HEAD
&lt;span class="nv"&gt;$ &lt;/span&gt;git push &lt;span class="nt"&gt;--tags&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This will:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create a new Git tag &lt;code&gt;v0.1.1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Push it to GitHub.&lt;/li&gt;
&lt;li&gt;Trigger the production pipeline.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Navigate back to your GitHub repository’s &lt;strong&gt;Action&lt;/strong&gt; tab.&lt;/p&gt;

&lt;p&gt;You should now see a new run triggered by the tag.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fivhq3xr71o7hkh6fixjp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fivhq3xr71o7hkh6fixjp.png" alt="Image description" width="800" height="352"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If everything goes well, inside the workflow you should see:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4rc0mzvnvwjk64nalyfj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4rc0mzvnvwjk64nalyfj.png" alt="Image description" width="800" height="440"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Outro
&lt;/h1&gt;

&lt;p&gt;Congratulations! You should now have your Python package successfully published to both &lt;strong&gt;Test PyPI&lt;/strong&gt; and &lt;strong&gt;PyPI&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In this tutorial, we accomplished:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Started with a small CLI tool.&lt;/li&gt;
&lt;li&gt;Initialized the project with &lt;code&gt;pyproject.toml&lt;/code&gt; using &lt;code&gt;uv&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Prepared a publishing configuration with &lt;strong&gt;automatic versioning&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Wrote a &lt;strong&gt;GitHub Actions&lt;/strong&gt; workflow for CI/CD.&lt;/li&gt;
&lt;li&gt;Set up &lt;strong&gt;Trusted Publisher&lt;/strong&gt; configuration.&lt;/li&gt;
&lt;li&gt;Verified that everything works end-to-end.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What are you building?
&lt;/h2&gt;

&lt;p&gt;Are you considering creating your own Python package?&lt;/p&gt;

&lt;p&gt;Share your ideas in the comments — I’d love to hear what you’re working on!&lt;/p&gt;

&lt;p&gt;I’m &lt;strong&gt;Ilya Khrustalev&lt;/strong&gt;. For over 20 years, I’ve helped companies build, launch, and scale their products, and level up their engineering teams.&lt;/p&gt;

&lt;p&gt;Feel free to connect with me on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.linkedin.com/in/ikhrustalev/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://x.com/ilyakhrustalev" rel="noopener noreferrer"&gt;Twitter/X&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/rznzippy" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>cicd</category>
      <category>githubactions</category>
      <category>uv</category>
    </item>
  </channel>
</rss>
