Skip to main content

Packaging and Distributing Go CLI Tools

Published: May 24, 2026 Updated: August 29, 2026 Larry Qu 6 min read

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 install works 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 -w for production builds: removes file paths, strips debug info, reduces binary size
  • Cross-compile with GOOS and GOARCH environment variables — pure Go needs no cross-toolchain
  • Use goreleaser to automate the full release pipeline: builds, archives, checksums, GitHub release, Homebrew
  • Always ship a SHA256SUMS file for every release — enables users to verify downloads

Resources

Comments

👍 Was this article helpful?