27. September 2026
Every tool I build for myself needs the same last mile: binaries for my machines, a Homebrew formula, something bx can run. That’s the same work each time, and I kept writing it again for each tool. The fix wasn’t a smarter workflow. It was admitting that the release process is a tool with a user of its own, and that user is me.
In Building Your Own Tools I argued that software with one known user gets to delete its configuration layer. There’s no need for options when you know exactly who’s running it. This post is about what I missed in that argument. Once there are several of those tools, the work around them starts repeating, and each copy grows its own small configuration layer. Nobody designs it. It just builds up.
The example is release-kit, a small private repository that is now the standard release pipeline for my Rust command-line tools. It’s a shared GitHub Actions release workflow, a release script the workflow calls, and a couple of scripts for moving a repo onto them. Four tools moved onto it the week it shipped:
.forge architecture description into diagrams and a documentation siterelease-kit v2 is four commits, all made on 19 September 2026 over about three hours. The first one built the whole pipeline; the other three were a rewrite of the adoption script and two bug fixes.
It’s worth being specific about “a release”, because that’s where the repetition hides. For each of these tools, a release has to produce four things.
The first is prebuilt binaries for macOS on Apple Silicon and Intel, and for Linux on arm64 and x86_64. Windows or musl builds are added per repo when needed.
The second is archives laid out the way bx expects. bx is my tool for running a release binary straight from GitHub Releases without installing it: point it at a repository and it fetches, unpacks and runs the binary. It only unpacks .tar.gz and .zip. That leads to a trap: cargo-dist’s default .tar.xz looks like a match to bx, but it gets cached without being extracted. That’s one of the reasons every repo is on the kit.
The third is a Homebrew formula, and it lives in the tool’s own repository rather than in a separate tap repo. So the same brew tap command, pointed at the tool’s repository, works for every tool the same way.
The fourth, optionally, is a crates.io publish.
None of these are hard on their own. The trouble is that each one has a couple of gotchas, and the gotchas are the same in every repo.
The central decision in v2 is the first rule the kit states: the tag is the version. Tags are CalVer, vYYYY.M.N. Push one and the workflow does everything else.
That sounds obvious, so here’s what it replaced. v1 required the tag to equal the version in Cargo.toml, pushed the formula straight to the default branch, and had no Intel macOS build. It failed on every tag cut by auto-managed, the scheduled maintenance that runs across my GitHub repositories, which I described in a control plane for a personal GitHub account. Among other things it cuts a monthly maintenance tag, vYYYY.M.D. That tag knows nothing about what’s in Cargo.toml. So the automation making the most releases was the one v1 couldn’t handle.
v2 turns the dependency around. During the build, the release script writes the tag’s version into the Cargo.toml that owns the binaries. That’s either the workspace-level package settings at the workspace root or the owning package. It fails loudly if it finds neither. When the build is done, the same step runs again on the default branch, and the bump lands in the repo through the same pull request as the formula. Nothing needs bumping before you tag. auto-managed’s monthly maintenance tags and tags I cut by hand with a small tagging script both release.
One CalVer detail shows how much of this is Cargo trivia rather than design. The month isn’t zero-padded. A leading zero is invalid semver, and Cargo package versions must be valid semver, so 2026.07.0 is rejected but 2026.7.0 is fine. The tag check accepts a four-digit year, a one- or two-digit month, and any number after that. It doesn’t care whether the last number is a day or a counter. It only has to go up.
The part I’d defend hardest is where the per-repo variation lives. The workflow and the release script are byte-identical in every repo. Everything that differs between tools is in one small file of unquoted KEY=VALUE lines, ten keys at most: which binaries to ship (the first one names the archive), the cargo package that owns them in a workspace, the directory holding the Cargo.toml to build, any release build features, any extra targets such as Windows or musl, whether to publish to crates.io, and four fields for the Homebrew formula — its name, class, licence and description.
That list is a small inventory of how my tools differ. forge keeps its crate in a subdirectory. brake builds its release with the mcp feature. Some repos are workspaces where the package name isn’t the binary name. That’s the whole list. Everything else I used to express per repo, in YAML someone had tweaked, turned out to be the same for every tool.
This is where I have to qualify the earlier post. release-kit doesn’t delete its configuration layer. It shrinks it to a declared list of differences. The config layer I was arguing against in that post is the one that exists because you don’t know who the user is. The differences file exists because I do know, and I know exactly where the four tools differ. It’s not a set of options but an inventory, and it doesn’t grow unless one of the tools grows a real difference.
Anything a repo ships beyond binaries, like editor extensions, MCP bundles or container images, goes in a separate extras workflow in that repo, triggered after the release workflow succeeds. The shared pipeline doesn’t have an escape hatch. The extra work goes in a separate file that runs afterwards, so the byte-identical files stay that way.
The release script reads the differences file itself rather than loading it into the workflow’s environment, so comments and odd characters in values are safe. That’s a small point, but it’s the kind of thing that ends up solved slightly differently in each copy when there are four copies.
The workflow runs on any version tag, or by hand with a tag to re-run a release. It has five jobs, and the order matters.
plan validates the tag against the CalVer pattern, reads the differences file, and writes out the build matrix. It also makes one decision based on whether the repo is private. Public repos build Linux arm64 on GitHub’s free ubuntu-24.04-arm runner, and private repos cross-compile it on ubuntu-latest because the free arm runner isn’t available to them.
ensure github release creates the GitHub Release if it doesn’t already exist, because auto-managed may have created one when it cut the tag.
build runs one matrix job per target. Each stamps the version and hands the build to taiki-e/upload-rust-binary-action, which uploads a .tar.gz named for the first binary, the tag and the target, with every binary at the archive root. Fail-fast is off, so one broken target doesn’t cancel the others.
checksums + formula downloads every archive, publishes a checksums file, renders the formula from those sums, bumps Cargo.toml, and gets both changes onto the default branch. It fails if any of the four formula targets is missing from the checksums, so the formula can’t point at an archive that doesn’t exist.
publish to crates.io runs only if the repo opts in, and it runs last, because it’s the one step that can’t be undone. Before publishing, it asks crates.io whether that version already exists and skips it if so.
Most steps are written to be run twice. The release job checks whether the release exists. The formula step exits cleanly when there’s nothing to commit. The crate step checks before publishing. That matters because re-running a tag is the normal way to recover from a runner problem, and the workflow is designed around that.
The two bug-fix commits after the initial one are the actual argument of this post. Both were bugs in the release process itself, not in any of the tools.
Intel macOS on a native runner. The first version built x86_64-apple-darwin on an Apple Silicon runner. That fails for any tool that links a -sys crate (openssl-sys via git2 and libssh2, for example), because the runner only has arm64 libraries. The fix, a few lines, points that target at GitHub’s macos-15-intel runner. Which tools link git2 is a fact about the tools, but the fix belongs to the pipeline. It’s also the build v1 never had. At its 2026.9.8 release, forge’s install instructions still told Intel Mac users to build from source.
The release PR must not force-push or delete branches. My repositories have a ruleset that rejects the bot’s direct push to the default branch. The ruleset asks for no approvals or checks, so opening and merging a pull request satisfies it rather than working around it. The bug was in re-runs. A ruleset covering all branches also forbids non-fast-forward pushes and deletions, so a second attempt couldn’t reuse the release branch by force-pushing it. Now each attempt uses a fresh branch, with the run’s ID appended if the plain name is taken. Deleting the branch after merge is tried first and dropped if the ruleset refuses. The job fails only if the PR really didn’t merge.
Neither fix has anything to do with what brake or tropism does. Each would have been found in whichever repo hit it first, fixed there, and then rediscovered in the others. The Intel failure only shows up in a tool that links a -sys crate. The ruleset failure only shows up on a re-run. That’s the pattern the kit exists to stop: failures that depend on what a tool links and whether a release has been retried, rather than on the tool’s own code.
Moving a repo onto the kit is also a tool. A stamping script (with a shell wrapper kept for older instructions, because macOS ships bash 3.2) writes the differences file, the workflow and the release script into a repo. It’s idempotent and does a dry run by default. It fills in what it can from cargo metadata, like binary targets, the owning package and the licence, and it falls back to the GitHub repo description for the formula description. It keeps existing values, converts v1’s single-binary setting into v2’s list, and warns when more than one package builds binaries. If there’s an existing release workflow that isn’t v2, it’s backed up so custom jobs can be moved into the extras workflow rather than lost.
An adoption script goes one step further, and it’s the shape I’d reuse. Given a repo directory, it refuses a dirty tree, pulls the default branch, stamps, commits with a fixed message, rebases to absorb bots that commit to the default branch, pushes, computes the next CalVer after the latest tag, and pushes that tag. The kit’s second commit rewrote it for v2. With nothing to bump in Cargo.toml any more, the script got shorter.
Because the commit message is fixed, adoption is easy to see across repos. brake, forge and symgraph each have the same adoption commit, and each has a matching commit moving the Intel build onto the native runner. [unverified: the identical adoption and Intel-runner commits in brake, forge and symgraph, and tropism’s adoption commit] All four then cut releases in the same window: forge v2026.9.8 on 19 September, and brake v2026.9.2, symgraph v2026.9.2 and tropism v2026.9.1 and v2026.9.2 around the same time. [unverified: the brake, symgraph and tropism tag names and dates]
The accurate version of “fixed once instead of four times” is this. The fix was written once, in the kit, and applied four times by re-stamping. Byte-identical files don’t sync themselves. What they do is make applying a fix a copy rather than a merge. There’s no local variation to reconcile, so re-stamping is mechanical and the resulting diff is the same in every repo. That’s a real saving, but it isn’t propagation, and the next thing I’d want is something that notices a repo whose release workflow has drifted from the kit’s.
Here’s the argument, stated as plainly as the evidence allows. With one tool, its release workflow is part of the tool. You write it once and it’s finished. With four, the release process becomes a separate piece of software that four repositories depend on. It has its own bugs, like the Intel build and the re-run, and its own user, who is me, cutting a tag and expecting a formula. Treating it as four copies of glue means every bug in it gets found four times, and each copy drifts according to whatever its tool happened to need.
The mistake is the one from the earlier post, one level up. A per-repo release workflow is a configuration layer. Every tweak someone made in one YAML file is an option nobody decided on. Pulling it into one place didn’t remove the variation. It forced me to write the real variation down as ten keys and make everything else identical.
How far does this go? One kit, four tools and a week of adoption suggests it’s worth doing somewhere past two or three tools. It doesn’t prove where the line is. I didn’t count how long the old per-repo fixes took, so I can’t say what the kit saved in hours. What I can say is that both bugs fixed on 19 September were ones no individual tool would have caught in its own tests, and that the second time one of them turns up, it’s a re-stamp.
The cost is a new dependency, and it’s a strange one. Nothing imports release-kit, and its files are copied rather than referenced. A tool that stops matching the kit’s assumptions, say one that needs a target the kit has no runner for, gets a loud failure from the plan job rather than a way around it. That’s the right default for software with one user. When the kit is wrong, I’m the one who has to fix it, and I’d rather find out at plan than three jobs later.