If you already use uv for local development, publishing to PyPI is almost boring. The interesting part is everything around it: release automation, PyPI propagation delays, and getting a Homebrew formula to feel as polished as the PyPI package.
I recently wired this up for czk, a small Python CLI that wraps czkawka_cli for duplicate media workflows. The package itself was straightforward. The release pipeline was not.
This post is the setup I wish I had started with.
What you need before you start #
Before you wire up the workflow, there are a few one-time pieces to put in place:
- A Python project that already builds cleanly with
uv build. - A PyPI project name reserved and ready to publish.
- Trusted publishing configured in PyPI for your GitHub repository.
- A Homebrew tap repository, usually something like
yourname/homebrew-tap. - A GitHub token stored as a repo secret with permission to push to that tap repo.
For the token, I used a fine-grained personal access token with access to the tap repository and stored it as HOMEBREW_TAP_TOKEN.
For PyPI, I would strongly recommend trusted publishing instead of API tokens if your setup supports it. It keeps the workflow simpler and avoids another secret you need to manage. If you have not set that up before, PyPI's guide for creating a project through OIDC is the right place to start.
Once those pieces are ready, the rest is mostly plumbing. The nice part is that most of the workflow can stay the same from project to project.
The shape of the project #
For uv, the important pieces are still the usual ones:
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "czk-tool"
version = "1.0.10"
requires-python = ">=3.14"
dependencies = ["rich>=13.7.0"]
[project.scripts]
czk = "czk_tool.cli:main"
There are two separate products to publish:
- The Python package on PyPI.
- A Homebrew formula in a custom tap.
PyPI gives me uv tool install czk-tool. Homebrew gives me brew install manojkarthick/tap/czk, plus extra system dependencies like czkawka and duckdb.
Publishing to PyPI with uv #
The PyPI side is refreshingly small:
uv build
uv publish
In GitHub Actions, that becomes a release job triggered by a GitHub release and using trusted publishing via OIDC. The exact workflow I used for czk lives here: publish.yml.
permissions:
id-token: write
That means I do not need to store a long-lived PyPI API token in GitHub secrets.
The only extra guard worth adding is an existence check before uv publish. If the version is already on PyPI, skip the publish step and keep going. This is especially helpful when rerunning a failed release job.
if curl -fsS -o /dev/null "https://pypi.org/pypi/czk-tool/${VERSION}/json"; then
echo "PYPI_VERSION_EXISTS=true" >> "$GITHUB_ENV"
else
echo "PYPI_VERSION_EXISTS=false" >> "$GITHUB_ENV"
fi
Then gate publish with:
if: env.PYPI_VERSION_EXISTS != 'true'
That one change saves a lot of pointless version bumps.
Homebrew via a custom tap #
For Homebrew, I am using a custom tap: manojkarthick/homebrew-tap.
At a high level, the release workflow just needs to:
- Build the Python package.
- Publish to PyPI if needed.
- Generate
Formula/czk.rb. - Push that formula into the tap repo.
The formula is based on the source distribution URL and sha256 from the exact PyPI version being released, plus dependency resource blocks generated by homebrew-pypi-poet.
Generating the formula #
I ended up with a tracked template file and a small render step.
Template:
class Czk < Formula
include Language::Python::Virtualenv
desc "CLI wrapper around czkawka_cli for duplicate media workflows"
homepage "https://github.com/manojkarthick/czk"
url ""
sha256 ""
version ""
license "MIT"
depends_on "python@3.14"
depends_on "czkawka"
depends_on "duckdb"
def install
virtualenv_install_with_resources
bin.install_symlink libexec/"bin/czk"
end
test do
assert_match "", shell_output("#{bin}/czk --version")
end
end
The key command for the sdist metadata is:
SDIST_URL=$(printf '%s' "$PYPI_JSON" | jq -r '.urls[] | select(.packagetype == "sdist") | .url')
SDIST_SHA256=$(printf '%s' "$PYPI_JSON" | jq -r '.urls[] | select(.packagetype == "sdist") | .digests.sha256')
The two Homebrew-specific gotchas I hit #
Warning: The simple part is not the part that breaks. The hard failures were not
uv buildoruv publish. They were around formula generation and install ergonomics.
1. homebrew-pypi-poet and setuptools #
homebrew-pypi-poet still imports pkg_resources. Modern setuptools removed that in 82+, so poet crashes unless you pin below that. This is tracked upstream in homebrew-pypi-poet issue #76.
What ended up working for me was:
uv pip install --python poet-venv 'setuptools<82' homebrew-pypi-poet ...
2. PyPI propagation lag #
PyPI can show a version in the UI or through version-specific JSON before every installer path resolves it cleanly.
My first attempt installed czk-tool==<new version> from PyPI inside the same release job so poet could inspect it. That was flaky. The job would sometimes publish successfully and then immediately fail because the just-published version was not yet resolvable.
The fix was simple: do not install the package from PyPI at that point.
Instead, install the wheel we just built locally:
uv pip install --python poet-venv \
'setuptools<82' \
homebrew-pypi-poet \
"dist/czk_tool-${VERSION}-py3-none-any.whl"
That removes the release pipeline's dependency on PyPI propagation for formula resource generation. I still fetch the published sdist URL and sha from PyPI, but the dependency graph comes from the local wheel.
The which czk bug #
This one was sneaky.
The formula installed successfully and brew info czk looked fine, but which czk returned nothing. The binary existed under libexec/bin/czk, but nothing was linked into Homebrew's main bin.
The fix was to make the formula explicit:
def install
virtualenv_install_with_resources
bin.install_symlink libexec/"bin/czk"
end
Without that line, the package installed but was not actually on the user's shell PATH.
What to change for your own package #
If you copy this setup for another project, most of the structure can stay exactly the same. The parts you actually need to swap out are pretty small:
- the PyPI package name, like
czk-tool - the CLI script name, like
czk - the Homebrew formula name, like
Czk - the tap repository name
- any system dependencies your CLI needs outside Python
For example, czk needed these extra Homebrew dependencies:
depends_on "czkawka"
depends_on "duckdb"
A different project might not need any non-Python dependencies at all.
You should also update the formula template so the executable symlink matches your script name:
bin.install_symlink libexec/"bin/czk"
If your command is called mytool, that line should point at libexec/"bin/mytool" instead.
Everything else is mostly reusable: the PyPI existence check, the local wheel install for poet, the jq extraction for the sdist metadata, and the general tap update flow.
One last practical detail: if you rerun the workflow and the generated formula has not changed, the tap commit step can fail with nothing to commit. It is worth adding a small guard there if you expect to rerun releases often.
A practical release flow #
If I were setting this up again, I would keep the release flow this simple:
- Build with
uv build. - Publish with
uv publish, unless that exact version already exists on PyPI. - Fetch the version-specific PyPI metadata for the sdist URL and sha256.
- Install
homebrew-pypi-poetplus the locally built wheel into a dedicated virtualenv. - Generate resource blocks with
poet czk-tool. - Render the formula from a template.
- Push the formula into a custom tap.
Final checklist #
pyproject.tomlhas correct[project]metadata and[project.scripts]uv buildproduces both wheel and sdistuv publishuses trusted publishing- Reruns do not require a version bump if the PyPI version already exists
- Homebrew formula uses the PyPI sdist URL and sha256
- Resource stanzas come from
homebrew-pypi-poet - System dependencies are added manually
- The CLI executable is explicitly symlinked into Homebrew's
bin
Closing thought #
uv makes the Python packaging part feel modern and surprisingly pleasant. The rough edges are mostly in the spaces between systems: PyPI propagation, formula generation, and Homebrew linking behavior.
Once those are handled, the workflow is pretty smooth. For czk, the final developer experience is exactly what I wanted:
uv tool install czk-tool
# or
brew install manojkarthick/tap/czk
That is a good place to end up.