You Shipped 0.10.0. Your Users Installed 0.9.0.

You Shipped 0.10.0. Your Users Installed 0.9.0.
The thesis
Shipping and distributing are not the same thing, and the gap between them is expensive.
Shipping is a verifiable fact: the tag exists, the binary is uploaded, the
workflow went green. Distributing is what actually reaches somebody when they
type brew install — and the release doesn’t decide that. A formula, a script
or a package decides it, living somewhere else, possibly lying for weeks
without anything failing.
kn is a Rust CLI I maintain, and on August 1st I shipped 0.10.0. For
sixteen days, until 0.11.0 on the 17th, brew install kobogithub/knowledge/kn kept handing people 0.9.0. No red workflow, no
error, no alert. The release was perfect. Everything downstream of it was not.
This post is about how it got fixed, and about why distribution needs a CI of its own.
Three ways to lie without failing
When I looked, it wasn’t one problem. It was three, and all three had the same shape.
1. The tap was two releases behind. The Homebrew formula lives in a separate repository, and updating it was a step written in the release document. A written step is a step somebody has to remember, and at 0.10.0 I didn’t.
2. The release notes never contained their checksums. The template built
them inside a quoted heredoc so the backticks in the prose would survive
verbatim. Side effect: $(cat checksums.txt) survived verbatim too. Every
release through v0.10.0 published this:
## Checksums
```
$(cat checksums.txt)
```
It’s still there, in v0.10.0, as a monument.
A checksum that verifies nothing is worse than publishing none: it promises a guarantee it isn’t delivering.
3. The .rpm recipe was pinned at Version: 0.1.0 while the project
shipped 0.10.0. Nine minor versions stale, having produced no current package
in a long time.
All three share a diagnosis. Nothing broke, because nothing was running that could break: a step documented instead of automated, and an artifact nobody exercised.
Documentation does not execute.
Before automating, cut
The intuitive reaction to the third one is to fix the .rpm. I did the
opposite: I deleted it, along with the .deb, along with Windows, along with
Intel Macs.
The release built three artifacts and nobody installed two of them. The RPM recipe being nine minors behind wasn’t an oversight — it was the evidence. Had it been in use, somebody would have said something at the first stale version, not the ninth. Keeping it alive meant paying the cost of a channel that existed only in the README.
kn now supports macOS on Apple Silicon and nothing else. That’s a
breaking change, and it goes in the minor position because the project is below
1.0 and that’s the pre-1.0 convention. The previous version stays downloadable
for anyone on another platform.
A distribution channel nobody exercises isn’t supported: it’s declared. You find out the difference the day somebody uses it.
The tap that updates itself
With a single artifact, the work became making it impossible for the formula to fall behind. It’s one more job in the release workflow, and the interesting decisions aren’t that it exists — they’re how it’s built.
The checksums come from the bytes that were published. The job downloads the artifact from the release URL and hashes it right there. It never reads a value stored in the repository, because a hand-maintained checksum is exactly the drift this job exists to remove.
curl -fsSL -o binary.tar.gz "${base}/releases/download/${VERSION}/kn-macos-arm64.tar.gz"
curl -fsSL -o source.tar.gz "${base}/archive/refs/tags/${VERSION}.tar.gz"
needs: [create-release] is the gate. A job with needs runs only when
everything it needs succeeded, so a failed build leaves the tap untouched,
still pointing at the last version that works. A formula pointing at a release
that doesn’t exist is worse than a stale one: the stale one installs.
It’s idempotent. Re-running the release for an existing tag must not produce a half-applied edit or an empty commit. If the rendered formula already matches what’s published, the job leaves without touching anything.
And it counts its substitutions. The render is a re.subn over the
repository’s formula, which acts as a template. A regex that stopped matching
doesn’t fail on its own: it returns the text untouched and carries on, which is
precisely how you publish a formula with the old version and the new checksum.
if (n1, n2, n3) != (1, 1, 2):
sys.exit(f"unexpected substitution counts: url={n1} resource={n2} sha256={n3}")
Then it verifies. The last step reads the raw published formula from the tap and fails if it doesn’t serve the version just released. It doesn’t trust that the push did what it thought.
Why there’s also a weekly check
With that verification at the end of the release you’d think it’s enough. It isn’t, and the reason is the most useful part of all this.
An assertion inside the pipeline can only observe the moment it runs. It can’t see a push that gets reverted afterwards. It can’t see a token expiring three months later, when nobody’s watching. It can’t see a formula hand-edited with good intentions. That entire class of divergence appears after the job said yes.
So there’s a second workflow, deliberately independent, running on a Monday cron, answering one question: does the tap publish the newest version this repository released?
Two decisions there matter more than the check itself.
It files an issue instead of trusting email. A failing scheduled workflow depends on the repository owner having Actions notifications enabled — an account setting the workflow cannot see or guarantee. An issue shows up in the repository whether notifications are on or not. And it reuses the open issue instead of filing a new one every Monday.
The issue body says what not to do:
Do not fix this with a manual copy into the tap — that is the failure
mode the automation replaced. Fix the pipeline.
Without that line, the obvious three-in-the-afternoon fix is to copy the formula by hand. It works, it closes the issue, and it reintroduces exactly the problem.
What only shows up when somebody actually installs it
The rest of what I learned doesn’t come from designing the pipeline. It comes from the thing being installed on a machine that isn’t mine.
The formula can’t declare its version. brew audit --strict rejects a
version line as redundant when the URL already carries the tag. Non-obvious
consequence: to know which version the tap publishes you have to parse the
URL. Both workflows do, which makes that URL’s format load-bearing.
Homebrew sandboxes $HOME during install. The formula can’t write to
~/.kn/ even if it wants to, so that step lives in a caveats block with the
commands ready to paste. It takes a while to figure out, because it doesn’t
fail — nothing simply happens.
Rival installs are a classic and nobody sees them. If somebody used the
curl installer they have a kn in ~/.local/bin, and if they later run brew install they have another in the Homebrew prefix. Which one runs depends on
PATH order, and the symptom is a bewilderingly old kn --version. The
formula checks the known paths and, if it finds another copy, names both, says
which one will win, and gives the command to remove the spare.
The formula’s test doesn’t run the diagnostic command. It used to, and it was broken for anyone without the optional tools installed: the command exits non-zero when any are missing, and Homebrew’s test environment lacks even Rust and Cargo. It now asserts on what the formula installs — that the binary answers, that the skills and agents landed — which is the only thing a formula test has authority over.
A test should assert on what its unit produces, not on what the machine running it happens to have.
And the Rosetta probe, my favourite. Under a translated shell (arch -x86_64), an Apple Silicon Mac reports x86_64. An installer that refuses
anything that isn’t arm64 would refuse precisely the machines it targets. The
probe checks sysctl.proc_translated and resolves the case:
if [[ "$ARCH" == "x86_64" ]]; then
rosetta_flag=$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)
if [[ "$rosetta_flag" == "1" ]]; then
ARCH="arm64" # Apple Silicon under translation
fi
fi
The platform check also runs before any network request, so an unsupported machine gets a message explaining what’s happening instead of a 404 on an artifact that isn’t built.
Where it stands today
Since 0.11.0 the tap hasn’t fallen behind, and the three weekly check runs all went green. The release notes carry real checksums. It’s all in the open: kobogithub/knowledge and the tap at kobogithub/homebrew-knowledge.
What I take away
- Distribution needs a CI of its own. The pipeline that builds the binary and the one that guarantees people receive it are two things, and the second usually doesn’t exist.
- Derive checksums from the published bytes. Any value maintained by hand in two places will diverge; the only question is when.
- Count your substitutions. A regex that stopped matching won’t tell you: it hands back the text untouched and the pipeline stays green.
- A check that runs inside the pipeline can’t see what happens afterwards. If an invariant matters, verify it with something that runs at a different time and for a different reason.
- Prefer one artifact that gets installed over three that get declared. An unused channel doesn’t maintain itself: it rots quietly, and you find out the day somebody relies on it.
None of this is about Rust, or about Homebrew. It’s that between “I shipped it” and “they have it” there’s a lot of system, and that system breaks too. It just breaks quietly.
