The distribution loop
You built a CLI tool in Go. It works. You want your friends or colleagues to use it without asking them to install Go, download a zip file, and figure out permissions. The friction of manual distribution kills adoption. You need a pipeline that builds binaries for every platform, signs them, uploads them, and lets users install with a single command.
GoReleaser automates the build matrix and release upload. Homebrew provides the installation experience. Together they bridge the gap between a GitHub repository and a user's terminal.
How the pieces fit
GoReleaser is a command-line tool that reads a configuration file and produces release artifacts. It handles cross-compilation, naming, archiving, and uploading to GitHub releases. You run it once per release, and it generates binaries for Linux, macOS, and Windows across multiple architectures.
Homebrew is a package manager for macOS and Linux. A "tap" is a Git repository that contains formulas. A formula is a Ruby script that tells Homebrew how to download, verify, and install your software. When you create a tap, users can run brew install username/tap/mycli to get your tool. The tap lives alongside the official Homebrew core, giving you full control over updates.
The workflow follows a clear path. You push a tag to Git. GoReleaser detects the tag, builds the binaries, and uploads them to GitHub. You update the formula in your tap with the new version and checksum. Users run brew install and get the binary.
Configuration that drives the build
Here's the configuration file that defines your release pipeline. GoReleaser looks for .goreleaser.yml in the project root. The file describes what to build, how to archive it, and where to upload it.
# .goreleaser.yml
builds:
- id: mycli
main: ./cmd/mycli
# cross-compile for the platforms your users actually run
goos: [linux, darwin, windows]
goarch: [amd64, arm64]
# disable CGO to produce statically linked binaries
env:
- CGO_ENABLED=0
archives:
- format: tar.gz
# template ensures the filename matches what Homebrew expects later
name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
release:
# start as draft so you can verify artifacts before going live
draft: true
The builds section tells GoReleaser which entry point to compile and which targets to produce. The goos and goarch lists create a matrix. With three operating systems and two architectures, GoReleaser runs six builds. The archives section wraps each binary in a tar.gz file. The name_template uses Go template syntax to generate consistent filenames. The release section configures the GitHub upload. Setting draft: true lets you review the artifacts before publishing.
Go binaries are statically linked by default when CGO is disabled. This means the binary contains everything it needs to run. No shared libraries. No runtime dependencies. You can copy the file to any machine with the same OS and architecture, and it just works. GoReleaser sets CGO_ENABLED=0 automatically for cross-compilation, but declaring it explicitly in the config makes the intent clear.
Convention aside: GoReleaser reads the module path from go.mod to determine the project name. Keep your module path clean and consistent. If you rename the module, the binary name changes, which breaks existing formulas and user expectations.
Running the release
Here's the command that triggers the pipeline. You run this locally for testing or in CI for production releases.
# clean removes previous artifacts to prevent stale files
goreleaser release --clean
The tool reads the configuration, checks out the current Git tag, and validates the tag format. It iterates over every combination of OS and architecture. For each target, it invokes the Go compiler, produces the binary, wraps it in an archive, and uploads everything to the GitHub release associated with the tag. The --clean flag removes the dist directory before starting, ensuring you don't accidentally ship artifacts from a previous run.
GoReleaser requires a valid Git tag to proceed. The tag must follow semantic versioning. If you push a tag like v1.0 without the patch version, GoReleaser rejects the run with tag does not match semver pattern. Use tags like v1.0.0 or v1.0.1. The v prefix is standard in Go projects and GoReleaser expects it.
Convention aside: Run goreleaser check before releasing. This command validates your configuration without building anything. It catches typos in templates, missing fields, and invalid syntax. It's faster than running a full release and fixing errors later.
The Homebrew formula
Here's the formula that lets users install your tool via Homebrew. The formula lives in a Git repository called a tap. The file name matches the formula name in lowercase.
# mycli.rb
class Mycli < Formula
desc "A fast CLI tool written in Go"
homepage "https://github.com/example/mycli"
# url must match the archive name template in .goreleaser.yml
url "https://github.com/example/mycli/releases/download/v1.0.0/mycli-1.0.0-darwin-amd64.tar.gz"
# Homebrew verifies this hash; a mismatch blocks installation
sha256 "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
license "MIT"
version "1.0.0"
def install
# extracts the binary and links it into the user's PATH
bin.install "mycli"
end
end
The formula is a Ruby script that describes your package. The url field points to the archive GoReleaser uploaded. The sha256 field contains the cryptographic hash of that archive. Homebrew downloads the file, computes the hash, and compares it to the value in the formula. If they match, installation proceeds. If they differ, Homebrew aborts with ChecksumMismatch. This verification protects users from corrupted downloads or tampered releases.
The install method defines how to place the binary. bin.install "mycli" extracts the binary from the archive and links it into the Homebrew bin directory. Homebrew manages the symlinks so the user can run mycli from any terminal. The formula name, class name, and binary name should all align. The file is mycli.rb, the class is Mycli, and the binary inside the archive is mycli.
Convention aside: Homebrew formulas follow strict naming conventions. The class name must be capitalized. The file name must be lowercase. The formula name usually matches the binary name. If your binary is named my-cli, the formula should be my-cli.rb and the class MyCli. Deviating from these conventions causes lint errors and breaks automation tools.
Updating the formula
Every release requires updating the formula. You need a new version number, a new URL, and a new SHA256 hash. The hash changes because the binary changes. Even a single byte difference produces a completely different hash.
You can update the formula manually by editing the file, computing the hash, and pushing the changes. Or you can use Homebrew's helper command to generate a pull request.
# generates a PR with updated URL, version, and checksum
brew bump-formula-pr --url="https://github.com/example/mycli/releases/download/v1.0.1/mycli-1.0.1-darwin-amd64.tar.gz" example/tap/mycli
The brew bump-formula-pr command downloads the new archive, computes the hash, updates the formula, and opens a pull request against your tap repository. This reduces manual errors and keeps the update process consistent. You still need to merge the PR and push the changes, but the tool handles the tedious parts.
Convention aside: Keep your tap repository separate from your source code repository. The tap contains only formula files and metadata. Mixing source code and formulas creates confusion and complicates access control. A dedicated tap repo makes it easy to manage collaborators and automate updates.
Common pitfalls
The biggest friction point is architecture coverage. macOS on Apple Silicon requires arm64 binaries. If you only build amd64, users with M1 or M2 chips get an incompatible binary. GoReleaser handles the matrix, but you must list arm64 in the configuration. Check your goarch list and ensure it includes both amd64 and arm64.
Windows binaries need the .exe extension. GoReleaser adds this automatically when building for Windows. The archive should contain mycli.exe, not mycli. If the extension is missing, Windows users can't run the binary directly. Verify the archive contents after a test release.
Draft releases can cause confusion. If you set draft: true in the configuration, the release stays hidden until you publish it. Users can't download the artifacts, and the formula URL returns a 404 error. Remember to publish the release on GitHub after GoReleaser finishes uploading. Or set draft: false if you want releases to go live immediately.
Homebrew rejects formulas with invalid checksums. If you copy the wrong hash or compute the hash from a different file, installation fails. Always compute the hash from the exact archive that the URL points to. You can verify the hash locally with shasum -a 256 mycli-1.0.0-darwin-amd64.tar.gz.
Convention aside: Use go mod tidy before releasing. This command removes unused dependencies and adds missing ones. A clean module file ensures the build is reproducible and the binary size is minimal. Stale dependencies can introduce security vulnerabilities or build failures.
When to use what
Use GoReleaser when you need cross-platform binaries, consistent naming, and automated uploads to GitHub releases.
Use a Homebrew tap when you want macOS and Linux users to install your tool with a single command and benefit from dependency tracking.
Use go install when your audience is primarily Go developers who already have the toolchain installed and prefer version control via the module proxy.
Use manual zip distribution only for internal tools or one-off scripts where the overhead of a release pipeline isn't justified.