1139 words, 6 min read

Homebrew is the package manager that macOS developers reach for first. If you want your CLI tool to feel like a first-class citizen on macOS — installable with a single command, updatable with brew upgrade, and auto-completed by the shell — a Homebrew tap is the right solution. This post covers how to set one up for a Go CLI, including the formula, CI validation, and the update workflow.

What is a tap?

A Homebrew tap is just a GitHub repository named homebrew-<something>. Once a user runs brew tap org/something, Homebrew knows to look for formulas in that repo. From that point on, brew install org/something/mycli works exactly like installing any official Homebrew package.

The naming convention is the only magic: no registration, no approval process. Anyone can host a tap.

The formula

Formulas live in a Formula/ directory and are written in Ruby using Homebrew's DSL. For a pre-built Go binary, the formula is remarkably simple:

class MyCli < Formula
desc "CLI interface for My App"
homepage "https://example.com"
url "https://cdn.example.com/mycli/5c7b6ef47a682ffe.../mycli-macos.tar.gz"
version "1.47.0"
sha256 "48e146382b0f527549328da4bed9d90f2d584e1b05a8f94846cf47c3c66a9343"
license "MIT"
def install
bin.install "mycli"
end
test do
system "#{bin}/mycli", "--version"
end
end

There are only four things that change on each release:

  • url — points to the new tarball on the CDN
  • version — the semver string shown by brew info
  • sha256 — the checksum Homebrew verifies before unpacking

The install block copies the binary into the Homebrew prefix (/opt/homebrew/bin/ on Apple Silicon, /usr/local/bin/ on Intel). The test block is a smoke test Homebrew runs after install — here, just confirming the binary exits cleanly when asked for its version.

Building a universal macOS binary

The URL points to a .tar.gz containing a single binary. Rather than shipping separate arm64 and x86_64 tarballs and maintaining two formula entries, we use lipo to merge them into one universal binary:

define build-binary-mac
@echo "Building for macos"
GOOS=darwin GOARCH=arm64 go build -o mycli-arm .
GOOS=darwin GOARCH=amd64 go build -o mycli-x86 .
lipo -create -output mycli mycli-x86 mycli-arm
tar czf ./mycli-macos.tar.gz mycli
rm -f mycli-x86 mycli-arm mycli
endef

GOOS=darwin GOARCH=arm64 go build and GOOS=darwin GOARCH=amd64 go build produce two binaries. lipo -create stitches them into a Fat Binary that macOS automatically runs under the native architecture. The result: one tarball, one formula URL, works on both Apple Silicon and Intel Macs without any user-facing complexity.

The CDN URL strategy

The binary is uploaded to object storage (DigitalOcean Spaces in our case, but S3 or any public CDN works equally well). The URL path includes the git commit SHA:

https://cdn.example.com/mycli/{git-sha}/mycli-macos.tar.gz

Embedding the commit SHA rather than the version tag means:

  • Uploads are naturally immutable — the same SHA always points to the same build
  • Multiple release candidates for the same version cannot collide
  • The create-index.py script (which generates a index.json listing all build artifacts) uses the same SHA, keeping the release artefacts coherent

Updating the formula

When a new version ships:

  1. Build and upload the tarball: make build-all produces mycli-macos.tar.gz; CI uploads it to the CDN under the new commit SHA.
  2. Compute the SHA256 of the tarball: sha256sum mycli-macos.tar.gz
  3. Update three lines in the formula: url, version, sha256
  4. Open a PR against the tap repository

The tap CI then validates the change before it merges (more on that below).

CI: validating formulas with brew test-bot

The tap repo includes a GitHub Actions workflow that runs on every push and pull request:

name: brew test-bot
on:
push:
branches: [main]
pull_request:
jobs:
test-bot:
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: Homebrew/actions/setup-homebrew@main
- run: brew test-bot --only-cleanup-before
- run: brew test-bot --only-setup
- run: brew test-bot --only-tap-syntax
- run: brew test-bot --only-formulae
if: github.event_name == 'pull_request'
- name: Upload bottles as artifact
if: always() && github.event_name == 'pull_request'
uses: actions/upload-artifact@main
with:
name: bottles
path: '*.bottle.*'

brew test-bot is Homebrew's own CI harness. The steps do progressively more:

Step What it checks
--only-tap-syntax Ruby syntax, formula naming conventions, brew audit rules
--only-formulae Actually installs the formula and runs the test do block

Running on both ubuntu-latest and macos-latest catches issues specific to either platform — important because Homebrew has first-class Linux support (Linuxbrew) and many teams use it in Docker-based CI.

CI: Merging PRs with brew pr-pull

The second workflow handles the merge step:

name: brew pr-pull
on:
pull_request_target:
types: [labeled]
jobs:
pr-pull:
if: contains(github.event.pull_request.labels.*.name, 'pr-pull')
runs-on: ubuntu-22.04
steps:
- uses: Homebrew/actions/setup-homebrew@main
- uses: Homebrew/actions/git-user-config@main
- name: Pull bottles
env:
HOMEBREW_GITHUB_API_TOKEN: ${{ github.token }}
PULL_REQUEST: ${{ github.event.pull_request.number }}
run: |
brew pr-pull --debug --tap="$GITHUB_REPOSITORY" "$PULL_REQUEST"
- uses: Homebrew/actions/git-try-push@main
with:
token: ${{ github.token }}
branch: main
- name: Delete branch
if: github.event.pull_request.head.repo.fork == false
run: git push --delete origin "$BRANCH"

Instead of merging PRs normally, a maintainer applies the pr-pull label. brew pr-pull then:

  1. Downloads any pre-built bottles attached to the PR as GitHub Actions artifacts
  2. Commits the bottle checksums into the formula
  3. Pushes directly to main and deletes the PR branch

This mirrors the workflow used by the official Homebrew/homebrew-core tap and means the merge is always handled by Homebrew's own tooling rather than GitHub's merge button.

For a simple pre-built binary formula (no compilation step, no bottle needed), this is mostly ceremony — but it is good practice because it keeps the tap workflow consistent with the broader Homebrew ecosystem and makes it easy to add bottles later if the build-from-source path is ever needed.

The user experience

After all of this, the user-facing story is clean:

# One-time setup
brew tap myorg/mycli
# Install
brew install mycli
# Upgrade when a new version ships
brew upgrade mycli

And because Homebrew auto-discovers shell completions in share/zsh/site-functions/, share/bash-completion/, and similar paths, completions are picked up automatically if the binary generates them into those locations at install time.

Summary

Component What it does
homebrew-mycli repo The tap — a public GitHub repo Homebrew reads formulas from
Formula/mycli.rb Declares the URL, version, checksum, install path, and smoke test
Universal binary (lipo) One tarball covers both Apple Silicon and Intel in a single formula entry
CDN + commit SHA URL Immutable, content-addressed artifact storage
brew test-bot CI Validates formula syntax and install on every PR
brew pr-pull CI Homebrew-native merge flow that handles bottle attachment

The tap repository itself is under 20 lines of real content. Most of the work is a one-time setup; after that, each release is a three-line diff to the formula and a label click to merge.