Distributing a Go CLI with GoReleaser
Hey everyone!
You finished writing a CLI in Go. It works on your machine. Then someone asks: “where do I download it?”
If the answer is “clone the repo and run go install”, you lost half your audience right there. Users who do not know Go do not know what to do with that. Users who do know Go do not want to do it for every new tool they install.
In this post you will build a real CLI, configure GoReleaser and have a pipeline that generates binaries for Linux, macOS and Windows automatically on every GitHub tag. With one extra step, it also publishes to Homebrew.
The CLI we are going to ship
We will build chk, a tool that checks the status of HTTP endpoints and shows status code and latency. Simple enough that the post does not get lost in the tool itself, useful enough that you would actually want to install it.
1
2
3
$ chk https://api.github.com https://google.com
200 45ms https://api.github.com
200 112ms https://google.com
Setting up the project
1
2
3
mkdir chk && cd chk
go mod init github.com/yourusername/chk
go get github.com/spf13/cobra@latest
Project structure:
1
2
3
4
5
6
chk/
main.go
cmd/
root.go
go.mod
go.sum
Building the CLI with Cobra
1
2
3
4
5
6
7
8
9
10
11
// main.go
package main
import "github.com/yourusername/chk/cmd"
var version = "dev"
func main() {
cmd.SetVersion(version)
cmd.Execute()
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// cmd/root.go
package cmd
import (
"fmt"
"net/http"
"os"
"time"
"github.com/spf13/cobra"
)
var (
timeout int
Version string
)
var rootCmd = &cobra.Command{
Use: "chk [urls...]",
Short: "Checks the status of HTTP endpoints",
Args: cobra.MinimumNArgs(1),
Run: run,
}
func Execute() {
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}
func SetVersion(v string) {
Version = v
rootCmd.Version = v
}
func init() {
rootCmd.Flags().IntVarP(&timeout, "timeout", "t", 5, "timeout in seconds")
}
func run(cmd *cobra.Command, args []string) {
client := &http.Client{
Timeout: time.Duration(timeout) * time.Second,
}
for _, url := range args {
start := time.Now()
resp, err := client.Get(url)
elapsed := time.Since(start)
if err != nil {
fmt.Printf("ERR %-8s %s\n", elapsed.Round(time.Millisecond), url)
continue
}
resp.Body.Close()
fmt.Printf("%-3d %-8s %s\n", resp.StatusCode, elapsed.Round(time.Millisecond), url)
}
}
Test it locally:
1
2
go run . https://api.github.com
# 200 44ms https://api.github.com
The problem with manual releases
Without GoReleaser, shipping a Go CLI for three platforms looks like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
GOOS=linux GOARCH=amd64 go build -o chk-linux-amd64 .
GOOS=darwin GOARCH=amd64 go build -o chk-darwin-amd64 .
GOOS=darwin GOARCH=arm64 go build -o chk-darwin-arm64 .
GOOS=windows GOARCH=amd64 go build -o chk-windows-amd64.exe .
tar -czf chk-linux-amd64.tar.gz chk-linux-amd64
tar -czf chk-darwin-amd64.tar.gz chk-darwin-amd64
# ...
# create the GitHub release
# upload each file
# write the changelog manually
# update the Homebrew formula
That is before any automation. With GoReleaser, the same result comes from a single git tag.
Installing GoReleaser
1
go install github.com/goreleaser/goreleaser/v2@latest
Generate the initial config:
1
goreleaser init
Configuring .goreleaser.yaml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# .goreleaser.yaml
version: 2
before:
hooks:
- go mod tidy
builds:
- env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
ldflags:
- -s -w -X main.version=
archives:
- formats:
- tar.gz
name_template: "__"
format_overrides:
- goos: windows
formats:
- zip
checksum:
name_template: "checksums.txt"
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
- "^ci:"
Test the local build without publishing:
1
goreleaser build --snapshot --clean
The output lands in dist/:
1
2
3
4
5
dist/
chk_linux_amd64/chk
chk_darwin_amd64/chk
chk_darwin_arm64/chk
chk_windows_amd64/chk.exe
GitHub Actions for automated releases
Create .github/workflows/release.yml:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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: stable
- uses: goreleaser/goreleaser-action@v6
with:
version: latest
args: release --clean
env:
GITHUB_TOKEN: $
The GITHUB_TOKEN is available automatically in GitHub Actions. You do not need to create any secrets manually.
Publishing to Homebrew
For macOS and Linux users to install with brew install, you need a separate formula repository.
Create a public GitHub repository called homebrew-tap.
Add the section to .goreleaser.yaml:
1
2
3
4
5
6
7
8
9
10
brews:
- name: chk
repository:
owner: yourusername
name: homebrew-tap
homepage: "https://github.com/yourusername/chk"
description: "Checks the status of HTTP endpoints"
commit_author:
name: goreleaserbot
email: bot@goreleaser.com
For GoReleaser to write to that repository, create a Personal Access Token with repo permission and add it as a secret:
1
2
3
Settings > Secrets and variables > Actions > New repository secret
Name: HOMEBREW_TAP_TOKEN
Value: <your token>
Update the workflow to pass the token:
1
2
3
env:
GITHUB_TOKEN: $
HOMEBREW_TAP_TOKEN: $
And reference it in .goreleaser.yaml:
1
2
3
brews:
- repository:
token: ""
Cutting the first release
With everything configured, the entire release comes down to two commands:
1
2
git tag v0.1.0
git push origin v0.1.0
GitHub Actions triggers, GoReleaser runs and within a few minutes you have:
- binaries for Linux, macOS (amd64 + arm64) and Windows
checksums.txtfor integrity verification- GitHub Release created automatically with a changelog generated from commits
- Homebrew formula updated in the
homebrew-taprepository
macOS and Linux users install it like this:
1
brew install yourusername/tap/chk
Everyone else downloads the binary from GitHub Releases and drops it in their PATH.
What you get for free
With this setup, every git tag produces:
- builds for 6 OS and architecture combinations (linux/amd64, linux/arm64, darwin/amd64, darwin/arm64, windows/amd64, windows/arm64)
- compressed archives (.tar.gz for Unix, .zip for Windows)
checksums.txtwith SHA256 hash for each file- GitHub Release with a changelog based on commits since the last tag
- Homebrew formula updated automatically
The changelog is generated from commits. If you follow Conventional Commits (feat:, fix:, docs:), it groups them automatically by category.
Conclusion
GoReleaser removes the manual release work that nobody wants to do. The initial setup takes less than an hour and from that point every new version ships with a git tag.
For a Go CLI you actually want to distribute, this pipeline is the reasonable minimum. macOS users install via Homebrew. Linux users grab the binary directly. And you do not need to touch any of it after the initial setup.
