Skip to content
mengfung.com
writing

Shipping a Rust CLI to Homebrew, Debian and RHEL

How dottyenv gets from a git tag to a binary that runs on RHEL 8, and how CI proves it.

 ·  4 min read

Writing dottyenv (what it is and why) was the fun part. Getting it installed on other people’s machines took about as much thought. The goal was that nobody needs a Rust toolchain: macOS gets Homebrew, Ubuntu and Debian get a .deb, RHEL and Fedora get an .rpm, and every binary and package comes from one release workflow triggered by pushing a tag.

Most of that is a solved problem with good tools. A few parts were not obvious, and those are what this post is about.

Four targets, one archive layout

The release workflow builds four binaries: Apple Silicon and Intel on a macOS runner, x86_64 and arm64 Linux on an Ubuntu runner. Each one is packaged into a tarball by the same small shell script, so the macOS and Linux jobs cannot drift apart. The Homebrew formula depends on the binary sitting at the root of the archive, and a script shared by both jobs is the cheapest way to keep that true.

The workflow also has a manual trigger, which runs the whole build matrix without publishing anything. That is how you test a release pipeline without cutting a release.

The glibc floor

This is the one that bites. A Rust binary built on ubuntu-latest links against that machine’s glibc, and it will refuse to start on anything older. RHEL 8 ships glibc 2.28, so a binary built the obvious way does not run there.

The fix is cargo-zigbuild, which uses Zig as the linker and lets you name the glibc version in the target:

cargo zigbuild --release --locked --target x86_64-unknown-linux-gnu.2.28

I did not want to just trust that, so CI reads the finished binary’s dynamic symbols and fails the build if any of them needs a glibc newer than 2.28:

floor=$(readelf -W --dyn-syms "$bin" \
        | grep -o 'GLIBC_[0-9]\+\.[0-9]\+' | sort -uV | tail -1)
highest=${floor#GLIBC_}
if [ -n "$highest" ] && [ "$(printf '%s\n2.28\n' "$highest" | sort -V | tail -1)" != "2.28" ]; then
  echo "::error::binary requires glibc $highest, above the 2.28 floor"
  exit 1
fi

sort -V sorts version numbers properly, so if 2.28 is not the larger of the two, the binary needs something newer than RHEL 8 has.

Packages that reuse the binary

cargo-deb and cargo-generate-rpm build the packages. Two flags matter. --no-build makes cargo-deb package the binary zigbuild already made. Without it, it rebuilds with the normal toolchain and you lose the glibc floor you just checked. And --no-strip, because the host’s strip cannot handle an aarch64 binary.

Install the packages before you release them

A glibc check tells you the binary should run on RHEL 8. It does not tell you the package installs, that the binary lands on the PATH, or that it behaves the same once it gets there. So before anything is published, a job installs the packages in real containers for each distribution I claim to support:

  • ubuntu:20.04, the oldest Ubuntu I claim
  • debian:12
  • almalinux:8, a RHEL 8 rebuild with glibc 2.28, which is the real test
  • fedora:latest

In each one it installs the package, then runs the actual workflow: init, check, and scan on a throwaway project. It also checks that the exit codes survive packaging, by deleting the schema and asserting that check exits 3 rather than 1. “Runs on RHEL 8” is then measured, not inferred.

The first version of that job also asserted that the README was installed under /usr/share/doc. It failed on Ubuntu, and not because of the package: because the official Ubuntu container images ship a dpkg config that excludes /usr/share/doc/* at install time. The README was in the package. The image just throws it away. That is the image’s policy, not something to fix, so the assertion went, and an earlier step that lists the package contents already proves the README is in the archive.

Checksums and the Homebrew tap

Every release ships a SHA256SUMS file. The workflow builds it from a find listing of the dist directory rather than a glob, so adding a new package format later cannot quietly leave it out.

Homebrew installs from my own tap, mfung/tap. The formula downloads the prebuilt tarball for the right platform, so there is no compile step, and generates shell completions from the binary itself. Updating it for a release means copying four URL and checksum pairs, which come straight from that SHA256SUMS file instead of by hand.

The formula’s test block is my favourite part. Plenty of formulas check the version string and stop. This one runs the real thing:

test do
  assert_match "dottyenv #{version}", shell_output("#{bin}/dottyenv --version")

  (testpath/".env").write("DATABASE_URL=postgres://localhost/app\n")
  system bin/"dottyenv", "init", "--quiet"
  assert_path_exists testpath/"dottyenv.toml"
  system bin/"dottyenv", "check"

  # A missing schema must exit 3, not 1.
  rm testpath/"dottyenv.toml"
  shell_output("#{bin}/dottyenv check 2>&1", 3)
end

shell_output raises when the exit code is not the one you pass, so the last line is the assertion.

What I would tell myself at the start

Decide the oldest system you support before you write the release workflow, then test on that exact system instead of reasoning about it. Most of the work above is a way of turning “it should run on RHEL 8” into a job that fails when it does not.