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_TOKEN is automatic and lets GoReleaser create the GitHub release on the current repo
  • HOMEBREW_TAP_TOKEN is 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.