Shipping a CLI to Homebrew, PyPI and crates.io

Writing the tool is the first half. Getting it onto someone else's machine involves signed binaries, a Homebrew tap, trusted publishing, and a TLS bug that only exists once you freeze Python into an executable.

I shipped two command-line tools this year, one in Python and one in Rust, and pushed them out through every channel I could: a Homebrew tap, PyPI, crates.io, Docker, and plain binaries on a release page. The code was the easy half. Distribution is where the genuinely surprising bugs live.

The install command decides whether anyone tries it

The single highest-leverage change I made to either project was to the README, not the source. A tool that installs with one line people already trust gets tried. A tool that opens with 'clone the repo, create a virtualenv, install the requirements' does not, no matter how good it is, because you are asking for five minutes of trust before you have earned any.

So the Homebrew line moved to the top of both READMEs and everything else moved below it. Two commands, tap then install, and the reader is running the thing. Publishing to the language registries afterwards is worth doing, but it serves a different person: the one who has already decided they want it.

Every extra step between reading about your tool and running it costs you a meaningful share of the people who were curious.

The TLS bug that only appears in a frozen binary

The Python tool is a network diagnostic, so it spends its life making HTTPS requests. It worked perfectly from source. Then I built it into a standalone binary so people would not need Python installed, and certificate verification started failing against perfectly ordinary modern sites.

The cause is worth knowing if you ever freeze a Python app. Python's TLS stack finds its trust store through the certifi package, and when you bundle everything into a single executable, the code comes along but the certificate bundle's location no longer resolves the way it did on your machine. From source you never see this, because the file is sitting where the library expects. Frozen, the tool has no idea what a trustworthy certificate authority is.

The fix was explicitly pointing the runtime at the bundled certifi CA file rather than hoping the ambient environment provided one. Ten minutes of work behind an hour of confusion, and the reason I am writing it down is that it is invisible in every environment you develop in and breaks in every environment your users have.

Parsing other people's command output is a trap

The same tool shells out to traceroute, and it crashed on macOS while working on Linux. Two separate problems in one, which is normal for this kind of code: the round-trip-time field parsed into a crash, and the destination it reported was simply the wrong host.

Command-line utilities are not APIs. Their output format varies by platform, by version, and sometimes by locale, and none of it is a contract. If you must parse it, parse defensively, test on every platform you claim to support, and treat a missing or oddly shaped field as expected rather than exceptional. The wrong-destination bug is the more instructive of the two, because it did not crash. It confidently reported something false, which is the worse failure mode for a diagnostic tool.

Trusted publishing is worth the twenty minutes

For PyPI I set up trusted publishing, where the registry verifies a short-lived identity from the CI provider instead of you storing a long-lived API token in your repository secrets. There is no token to leak, none to rotate, and none to accidentally paste somewhere public.

It takes about twenty minutes to configure once and then you never think about it again. Given how many supply-chain incidents start with a stolen publish token, this is one of the best security-per-effort trades available to a small project.

A Homebrew tap is less work than it sounds

You do not need to get into Homebrew core to have a good install story. A personal tap is just a repository with a formula in it, and users tap it once. From there you can ship prebuilt bottles so installation is a download rather than a compile, which matters a great deal for a Rust project where building from source could mean several minutes of waiting.

One caution from the download data: bottle pulls are the metric most likely to be your own testing. Mine were all for the exact platform I develop on, which is a hint about who was really installing the tool rather than evidence anyone else was.

Defaults are a product decision

The change that most improved the Python tool had nothing to do with packaging. Originally the interesting output, a full trace and a graded scorecard, sat behind flags. Running the bare command gave you something conservative and dull, and a first-time user would form their entire impression of the tool from that.

I inverted it: the full report became the default, and the shortcuts became flags for people in a hurry. Same code, but the bare command now shows the thing that makes the tool worth having. If your best feature needs a flag, most people will never see it, and the honest question is whether you built a default for yourself or for the person running it the first time.

Nobody reads your help text before their first run. The bare command is your entire pitch.

The checklist I would reuse

None of this is difficult. All of it is the difference between a repository and something a stranger can actually use, and it is consistently the part I have underestimated.