Setting Up a Personal Homebrew Tap with GoReleaser
I maintain a few CLI tools - rampart, vanity, stargazers - and I wanted them all installable with brew install. Not through Homebrew core (my tools aren’t popular enough to justify that, and the review process is significant), but through a personal tap where I control the formulas and releases go live the moment I push a tag.
The setup is straightforward once you’ve done it once, but the first time involves a few moving pieces that aren’t obvious from any single set of docs. Here’s the complete picture.
What a Tap Is
A Homebrew tap is just a GitHub repo with a specific naming convention: homebrew-<name>. Mine is wdm0006/homebrew-tap. When someone runs:
brew install wdm0006/tap/vanity
Homebrew clones wdm0006/homebrew-tap, looks for Formula/vanity.rb, and installs from there. That’s the entire discovery mechanism. No registry, no approval process. If the repo and formula exist, it works.
Creating the Tap Repo
The repo needs a Formula/ directory with Ruby files for each tool:
homebrew-tap/
├── Formula/
│ ├── rampart.rb
│ ├── vanity.rb
│ └── stargazers.rb
└── README.md
You can create the repo manually, but you’ll rarely edit the formulas by hand. The goal is to have them generated automatically on each release.
Go Projects with GoReleaser
For Go-based CLIs, GoReleaser handles the entire release pipeline: cross-compilation, archive creation, checksum generation, GitHub release publishing, and Homebrew formula updates. The relevant piece of .goreleaser.yaml is the brews section:
version: 2
project_name: rampart
builds:
- main: ./cmd/rampart
binary: rampart
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
ldflags:
- -s -w -X main.version={{.Version}}
brews:
- repository:
owner: wdm0006
name: homebrew-tap
token: "{{ .Env.HOMEBREW_TAP_TOKEN }}"
directory: Formula
homepage: "https://github.com/wdm0006/rampart"
description: "Audit and enforce GitHub branch protection rules across repos"
license: "MIT"
dependencies:
- name: gh
test: |
system "#{bin}/rampart", "--help"
install: |
bin.install "rampart"
When GoReleaser runs, it builds the binaries, creates a GitHub release with the archives, then generates a formula that points at those archives (with SHA256 checksums) and pushes it to the tap repo. The HOMEBREW_TAP_TOKEN is a GitHub personal access token with write access to the tap repo.
The generated formula looks like this:
class Rampart < Formula
desc "Audit and enforce GitHub branch protection rules across repos"
homepage "https://github.com/wdm0006/rampart"
version "0.1.0"
license "MIT"
depends_on "gh"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/.../rampart_0.1.0_darwin_arm64.tar.gz"
sha256 "abc123..."
def install
bin.install "rampart"
end
end
# ... intel variant
end
on_linux do
# ... amd64 and arm64 variants
end
test do
system "#{bin}/rampart", "--help"
end
end
GoReleaser handles the platform detection boilerplate, SHA256 checksums, and URL generation. You never write this by hand.
The Release Workflow
The GitHub Actions workflow that triggers everything is minimal:
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/setup-go@v6
with:
go-version: '1.23'
- uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
Two tokens are involved:
GITHUB_TOKENis automatic and lets GoReleaser create the GitHub release on the current repoHOMEBREW_TAP_TOKENis a secret you set manually - it’s a PAT that lets GoReleaser push the formula update to the separate tap repo
The release flow becomes: git tag v0.1.0 && git push origin v0.1.0. GoReleaser builds for six platforms (macOS/Linux/Windows, amd64/arm64), publishes the release, and updates the Homebrew formula. The whole thing takes under a minute.
Python Projects
Go projects get the nicest GoReleaser integration, but you can put any language’s CLI in a tap. For Python tools like stargazers, the formula uses Homebrew’s virtualenv_install_with_resources to create an isolated Python environment with pinned dependencies:
class Stargazers < Formula
include Language::Python::Virtualenv
desc "CLI tool to fetch, analyze, and summarize GitHub stargazers"
homepage "https://github.com/wdm0006/stargazers"
url "https://github.com/wdm0006/stargazers/archive/refs/tags/v0.1.0.tar.gz"
sha256 "..."
license "MIT"
depends_on "python@3.12"
resource "click" do
url "https://files.pythonhosted.org/packages/.../click-8.1.8.tar.gz"
sha256 "..."
end
# ... other dependencies as resources
def install
virtualenv_install_with_resources
end
end
Each Python dependency becomes a resource block with a pinned URL and checksum. This is more manual than the Go path - you can generate it with brew update-python-resources or poet, but it still requires some attention when dependencies change. The tradeoff is that users get a clean install with no system Python pollution.
The Token Setup
The one piece that trips people up: the HOMEBREW_TAP_TOKEN. You need a GitHub PAT (classic or fine-grained) with write access to the tap repo’s contents. Then set it as a secret on each project repo that needs to update the tap:
gh secret set HOMEBREW_TAP_TOKEN --repo wdm0006/rampart
gh secret set HOMEBREW_TAP_TOKEN --repo wdm0006/vanity
If you use a fine-grained token, scope it to just the homebrew-tap repo with “Contents: Read and Write” permission. Classic tokens with repo scope work too but are broader than necessary.
End Result
After this setup, the install experience for any of my tools is:
brew install wdm0006/tap/rampart
brew install wdm0006/tap/vanity
brew install wdm0006/tap/stargazers
New releases happen automatically when I push a tag. The formula stays in sync with the latest version. Users get native binaries without worrying about Go toolchains or Python environments. And I never have to manually edit a Ruby file.
For anyone maintaining CLI tools that even a handful of people use, a personal tap is worth the hour of setup. It turns “clone the repo and build from source” into a one-liner, and the ongoing maintenance cost is essentially zero.
Stay in the loop
Get notified when I publish new posts. No spam, unsubscribe anytime.