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:

  1. Source repo: Go code, GoReleaser config, GitHub Actions workflow
  2. Tap repo: homebrew-tap, receives formula files from GoReleaser
  3. PAT secret: HOMEBREW_TAP_GITHUB_TOKEN, allows cross-repo push
  4. Tag push: triggers the entire pipeline

Once wired up, shipping a new version is just git tag && git push.