Distribute CLI Tools with Homebrew Using GoReleaser
Sharing a CLI tool should be as simple as brew install thing. This post walks through the full pipeline: build a Go TUI with Bubbletea, automate releases with GoReleaser, and distribute via a custom Homebrew tap.
The Tool
A minimal Bubbletea app that renders a navigable list. Nothing fancy; the point is the distribution pipeline, not the application.
type Model struct {
Items []string
Cursor int
Quitting bool
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c":
m.Quitting = true
return m, tea.Quit
case "up", "k":
if m.Cursor > 0 {
m.Cursor--
}
case "down", "j":
if m.Cursor < len(m.Items)-1 {
m.Cursor++
}
}
}
return m, nil
}
Standard Bubbletea pattern: a model with Init, Update, and View. Tests cover cursor bounds, quit behaviour, and view rendering.
GoReleaser Config
GoReleaser handles cross-compilation, archiving, checksums, changelog generation, GitHub releases, and pushing the Homebrew formula, all from a single config.
version: 2
project_name: pig
builds:
- main: .
binary: pig
goos: [darwin, linux]
goarch: [amd64, arm64]
archives:
- format: tar.gz
name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
brews:
- repository:
owner: zaminda
name: homebrew-tap
token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
name: pig
homepage: https://github.com/zaminda/pig
description: Simple TUI list picker
The brews section tells GoReleaser to generate a Homebrew formula and push it to a separate repository. This is the key piece. Without it, you’d need to manually write and update the formula for every release.
GitHub Actions Workflow
A workflow triggered on version tags runs tests and then GoReleaser:
name: Release
on:
push:
tags: ["v*"]
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- run: go test ./...
- uses: goreleaser/goreleaser-action@v6
with:
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
Tag a commit, push the tag, and the pipeline handles the rest.
The Tap Repository
Homebrew taps follow a naming convention: a repo named homebrew-tap under your GitHub account maps to yourname/tap in brew commands.
Create an empty public repo called homebrew-tap. GoReleaser pushes the formula file there automatically on each release. No manual steps required after initial setup.
One catch: the default GITHUB_TOKEN in Actions can only write to its own repository. To push a formula to a different repo, you need a personal access token with write access to the tap repo, stored as HOMEBREW_TAP_GITHUB_TOKEN in the source repo’s secrets.
Name Collisions
One thing to watch out for: name collisions with existing Homebrew formulae. I named this tool pig, which collides with Apache Pig, a Hadoop-era Java tool for MapReduce. Running pig launched Java log output about Hadoop configurations instead of a TUI.
Check for existing formulae before picking a name:
brew search <name>
The Release Flow
git tag v0.1.0
git push origin v0.1.0
# GitHub Actions runs tests + GoReleaser
# GoReleaser builds binaries, creates release, pushes formula
Users install with:
brew tap zaminda/tap
brew install pig
A single tap can host multiple tools. Each project’s GoReleaser config points to the same homebrew-tap repo, and each release adds or updates its own formula file.
Summary
The moving parts:
- Source repo: Go code, GoReleaser config, GitHub Actions workflow
- Tap repo:
homebrew-tap, receives formula files from GoReleaser - PAT secret:
HOMEBREW_TAP_GITHUB_TOKEN, allows cross-repo push - Tag push: triggers the entire pipeline
Once wired up, shipping a new version is just git tag && git push.