A Go CLI tool is only useful if people can install and run it. The distribution story matters as much as the code. Go’s cross-compilation and static binary model make distribution significantly simpler than most languages — a single binary per platform, no runtime dependencies, no pip install or npm install.
This guide covers: injecting version information at build time, cross-compiling for multiple platforms, automating releases with goreleaser, and the distribution channels your users expect.
For CLI design see Go building CLI with Cobra and Go command-line parsing flags.
Version Information via ldflags
Hard-coding version strings in source code means updating the source on every release and rebuilding. The idiomatic Go approach: define variables in main, inject values at build time with -ldflags:
// cmd/version.go
package main
import (
"fmt"
"runtime"
)
// These are injected at build time via -ldflags
var (
version = "dev" // overridden by -X main.version=v1.2.3
commit = "none" // -X main.commit=$(git rev-parse --short HEAD)
buildDate = "unknown" // -X main.buildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)
)
func printVersion() {
fmt.Printf("myapp %s\n", version)
fmt.Printf(" commit: %s\n", commit)
fmt.Printf(" built: %s\n", buildDate)
fmt.Printf(" go version: %s\n", runtime.Version())
fmt.Printf(" os/arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)
}
Build with version injection:
VERSION=v1.2.3
COMMIT=$(git rev-parse --short HEAD)
DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
go build \
-ldflags "-X main.version=${VERSION} -X main.commit=${COMMIT} -X main.buildDate=${DATE}" \
-o myapp .
The -s -w flags strip debug symbols and DWARF info, reducing binary size by ~30%:
go build -ldflags "-s -w -X main.version=${VERSION}" -o myapp .
For production binaries, -trimpath removes absolute file paths from the binary (privacy + reproducibility):
go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o myapp .
Cross-Compilation
Go compiles to any supported platform by setting GOOS and GOARCH. No cross-compiler toolchain needed — the Go toolchain handles it:
# Linux AMD64
GOOS=linux GOARCH=amd64 go build -o dist/myapp-linux-amd64 .
# Linux ARM64 (Raspberry Pi, AWS Graviton)
GOOS=linux GOARCH=arm64 go build -o dist/myapp-linux-arm64 .
# macOS Intel
GOOS=darwin GOARCH=amd64 go build -o dist/myapp-darwin-amd64 .
# macOS Apple Silicon
GOOS=darwin GOARCH=arm64 go build -o dist/myapp-darwin-arm64 .
# Windows
GOOS=windows GOARCH=amd64 go build -o dist/myapp-windows-amd64.exe .
For CGO-dependent packages (SQLite, some crypto), cross-compilation requires a cross-C compiler. For pure Go code, it just works.
A release build script that produces archives alongside checksums:
#!/bin/bash
set -e
VERSION=${1:?usage: ./release.sh VERSION}
APP="myapp"
LDFLAGS="-s -w -X main.version=${VERSION} -X main.commit=$(git rev-parse --short HEAD)"
mkdir -p dist
# Build for each platform
declare -A TARGETS=([linux-amd64]="linux/amd64" [linux-arm64]="linux/arm64" \
[darwin-amd64]="darwin/amd64" [darwin-arm64]="darwin/arm64" \
[windows-amd64]="windows/amd64")
for name in "${!TARGETS[@]}"; do
IFS='/' read -r os arch <<< "${TARGETS[$name]}"
out="dist/${APP}-${name}"
[[ "$os" == "windows" ]] && out="${out}.exe"
echo "building ${name}..."
GOOS=$os GOARCH=$arch go build -trimpath -ldflags "$LDFLAGS" -o "$out" .
# Create archive
if [[ "$os" == "windows" ]]; then
zip -j "dist/${APP}-${VERSION}-${name}.zip" "$out"
else
tar -czf "dist/${APP}-${VERSION}-${name}.tar.gz" -C dist "$(basename $out)"
fi
done
# SHA256 checksums
cd dist
sha256sum *.tar.gz *.zip > SHA256SUMS
echo "release ${VERSION} complete — artifacts in dist/"
goreleaser: Automating Everything
Manual release scripts work but require care to keep consistent. goreleaser automates the full release pipeline — builds, archives, checksums, GitHub releases, Homebrew, Docker images — from one config file:
go install github.com/goreleaser/goreleaser@latest
goreleaser init # creates .goreleaser.yaml
A minimal .goreleaser.yaml:
# .goreleaser.yaml
version: 2
builds:
- main: .
binary: myapp
env: [CGO_ENABLED=0]
flags: [-trimpath]
ldflags:
- -s -w
- -X main.version={{.Version}}
- -X main.commit={{.Commit}}
- -X main.buildDate={{.Date}}
goos: [linux, darwin, windows]
goarch: [amd64, arm64]
archives:
- format: tar.gz
name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
format_overrides:
- goos: windows
format: zip
checksum:
name_template: "SHA256SUMS"
algorithm: sha256
changelog:
sort: asc
filters:
exclude: ["^docs:", "^test:", "Merge pull request"]
release:
github:
owner: yourname
name: myapp
Release a new version:
git tag v1.2.3
git push --tags
goreleaser release --clean # builds, packages, creates GitHub release
goreleaser reads the git tag, injects it as the version, builds for all platforms, creates archives and checksums, and creates a GitHub release with all artifacts attached.
Installation Channels
go install — Simplest for Go Developers
go install github.com/yourname/myapp@latest
go install github.com/yourname/[email protected]
go install downloads, builds, and installs to $GOPATH/bin. It requires a go.sum-locked module. Add to your README. Works immediately for anyone with Go installed.
Homebrew — macOS and Linux Power Users
A Homebrew formula is a Ruby file describing how to install your binary:
# Formula/myapp.rb
class Myapp < Formula
desc "A tool that does something useful"
homepage "https://github.com/yourname/myapp"
version "1.2.3"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/yourname/myapp/releases/download/v#{version}/myapp-#{version}-darwin-arm64.tar.gz"
sha256 "abc123..." # from SHA256SUMS
else
url "https://github.com/yourname/myapp/releases/download/v#{version}/myapp-#{version}-darwin-amd64.tar.gz"
sha256 "def456..."
end
end
on_linux do
url "https://github.com/yourname/myapp/releases/download/v#{version}/myapp-#{version}-linux-amd64.tar.gz"
sha256 "ghi789..."
end
def install
bin.install "myapp"
end
test do
assert_match "#{version}", shell_output("#{bin}/myapp --version")
end
end
goreleaser can generate and update Homebrew formulas automatically with its brews configuration section.
Docker — Containerized Environments
Multi-stage Docker builds produce minimal images. The build stage compiles the binary; the final stage is just the binary plus minimal OS:
# Build stage
FROM golang:1.22-bookworm AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build \
-trimpath \
-ldflags="-s -w -X main.version=$(cat VERSION)" \
-o myapp .
# Production image — distroless has no shell, no package manager
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app/myapp /myapp
USER nonroot:nonroot
ENTRYPOINT ["/myapp"]
The resulting image is typically 5–15 MB. distroless/static has no shell at all — smaller attack surface and a clear “this is a single-binary tool” statement.
Checksums and Verification
Always ship a SHA256SUMS file. It lets users verify downloads haven’t been tampered with:
# User verification
sha256sum --check SHA256SUMS
# or on macOS
shasum -a 256 --check SHA256SUMS
For higher assurance, sign releases with GPG or cosign (from the Sigstore project). goreleaser supports both.
Release Checklist
Before releasing:
- All tests pass:
go test ./... - No race conditions:
go test -race ./... - Version tag follows semver:
v1.2.3 - CHANGELOG updated
- README installation instructions up to date
After releasing:
- GitHub release created with all artifacts
- SHA256SUMS attached and verified
-
go installworks from the new tag - Homebrew formula updated (if applicable)
- Docker image pushed (if applicable)
Summary
- Inject version, commit, and build date via
-ldflags "-X main.version=..."— never hardcode them - Use
-trimpath -s -wfor production builds: removes file paths, strips debug info, reduces binary size - Cross-compile with
GOOSandGOARCHenvironment variables — pure Go needs no cross-toolchain - Use goreleaser to automate the full release pipeline: builds, archives, checksums, GitHub release, Homebrew
- Always ship a
SHA256SUMSfile for every release — enables users to verify downloads
Resources
- goreleaser documentation
- Go cross-compilation
- Homebrew formula cookbook
- distroless images
- Semantic versioning
Comments