Shipping a CLI with brew and scoop — all at once via GoReleaser
Part 4 of 5 — series: Building a publishing tool, and shipping it. Last time was designing a multilingual site. See the series index. This time: the practical side of shipping with brew and scoop.
Once the CLI exists, the next question is “how do I ship it?” go install works, but being installable with Homebrew or Scoop is much easier on the people using it.
That said, building by hand for mac, Windows, and Linux, then hand-writing a Homebrew formula and a Scoop manifest… isn’t something you keep doing every release. So I had GoReleaser do it all in one go — including the one place macOS tripped me up (signing).
Let GoReleaser do it all at once
From a single config file, GoReleaser does all of this:
- build binaries for mac, Windows, and Linux × amd64, arm64
- generate the Homebrew formula and the Scoop manifest
- push them to the public repositories (the tap / bucket)
A release is one command after cutting a git tag.
git tag v0.1.0
GITHUB_TOKEN=$(gh auth token) goreleaser release --clean
To try it before publishing, snapshot mode builds without pushing.
goreleaser release --snapshot --clean # builds into dist/; never publishes
Make the git tag the single source of the version
A quietly important thing is version agreement. If the version the binary reports, the download URL, and the package version drift apart, it leads to accidents.
In GoReleaser, you bake the git tag into the binary via ldflags. Make the tag the one source, and everything lines up.
builds:
- ldflags:
- -s -w -X your/module/internal/cli.Version={{.Version}}
Now the version crofty version prints, and the version of what’s distributed, both come from the same tag.
The macOS wall: dropping the cask for a formula
Shipping to macOS tripped me up plainly. At first I tried a Homebrew cask — but the unsigned binary got caught by Gatekeeper every time (the “Apple could not verify…” popup).
The cause was the quarantine attribute. A cask puts a quarantine on what it downloads. Unsigned, that snags on Gatekeeper at first launch. Clearing the warning needs an Apple Developer ID signature and notarization — a heavy procedure to ship on your own.
So I dropped the cask and shipped a formula instead. A formula doesn’t quarantine its download, so even an unsigned binary runs with no warning.
| cask | formula (chosen) | |
|---|---|---|
| quarantine on download | applied | not applied |
| first launch of an unsigned binary | Gatekeeper blocks it | just runs |
| condition to clear the warning | Apple signature + notarization | none |
On the GoReleaser side, you just point brews: at the tap (the repo that holds the formula).
brews:
- name: crofty
repository:
owner: <your-github>
name: homebrew-crofty
dependencies:
- name: hugo
Windows is Scoop — and declare the dependency
On Windows, Scoop is the Homebrew equivalent. GoReleaser builds the manifest and pushes it to the bucket repo.
Another important thing is declaring the dependency. crofty uses Hugo inside, so when it’s installed, Hugo should come along too. Homebrew declares it with dependencies, Scoop with depends.
scoops:
- name: crofty
repository:
owner: <your-github>
name: scoop-crofty
depends:
- main/hugo-extended
Now a single brew install / scoop install brings the runtime along.
In short
Shipping a personal OSS settled like this:
- GoReleaser does it all at once — build every OS, generate and push the manifests, off one tag
- the git tag is the single source of the version — baked in via ldflags
- macOS gets a formula — a cask would snag the unsigned binary in a popup
- declare dependencies in the manifest — the runtime comes with it
Signing is still heavy for an individual, but this much gets you to “cut a tag and it ships.”
← Previous: Designing a multilingual site | Next: Designing a CLI an AI drives →