15 - CLI Distribution & Auto-Updates
📋 Jump to Takeaways🎁 You spent three days building devctl. Now you need to get it onto 40 engineers' machines, keep it there, and make sure nobody is running a six-month-old version with a broken deploy command. How do you do that without a weekly Slack message saying "please upgrade"?
By now devctl can tail log files, check service health, run CI pipelines locally, and ship rolling deploys with automatic rollback. This lesson answers the last question: how do you get this binary onto 40 engineers' machines and keep it there?
You pushed devctl v1.4.0 with a critical fix. Half the team is still on v1.1.0. You know because the Kubernetes namespace they're targeting no longer exists and they keep opening tickets. You could add a note to the team wiki. Or you could make the tool tell them itself.
This lesson covers the full distribution lifecycle: cross-compiling for every platform, hosting binaries on GitHub Releases, embedding version info at build time, checking for updates on every run, and enforcing a minimum version so old builds stop working gracefully instead of silently misbehaving.
Why Not Just Share a Git Repo?
The obvious first approach: put the repo on GitHub and tell everyone to clone and build.
git clone https://github.com/yourorg/devctl
cd devctl
go build -o devctl .This breaks in several ways.
Engineers need Go installed. Not everyone on a platform team writes Go day-to-day. Your SRE colleagues probably don't have a Go toolchain. Even the ones who do have Go installed may have different versions.
# Engineer A: Go 1.21, works fine
# Engineer B: Go 1.18, compile error on generics
# Engineer C: Windows, $GOPATH issues, PATH not set correctlyWhen you push a fix, nobody gets it unless they remember to pull and rebuild. There's no mechanism to tell them an update exists. And there's no way to say "this version is broken, stop using it."
❌ Wrong approach: rely on everyone to maintain their own build environment. ✅ Right approach: ship a prebuilt binary they can install in one command.
Cross-Compiling for Every Platform
Go's cross-compilation is one of the best things about the language. You set two environment variables and get a binary for any supported target.
GOOS=linux GOARCH=amd64 go build -o devctl-linux-amd64 .
GOOS=darwin GOARCH=amd64 go build -o devctl-darwin-amd64 .
GOOS=darwin GOARCH=arm64 go build -o devctl-darwin-arm64 .
GOOS=windows GOARCH=amd64 go build -o devctl-windows-amd64.exe .GOOS is the operating system. GOARCH is the CPU architecture. You run these on your CI machine and get four separate binaries, each targeting a different platform.
Set CGO_ENABLED=0 to produce a statically linked binary. Without it, the binary links against the system's C library, which may not match on the target machine.
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o devctl-linux-amd64 .A static binary runs anywhere on that platform with no dependencies. No glibc version to worry about, no shared libraries to install.
Here's a Makefile that builds all four targets at once:
VERSION ?= dev
COMMIT ?= $(shell git rev-parse --short HEAD)
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS = -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)
.PHONY: build
build:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o dist/devctl-linux-amd64 .
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o dist/devctl-darwin-amd64 .
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="$(LDFLAGS)" -o dist/devctl-darwin-arm64 .
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o dist/devctl-windows-amd64.exe .Run make build VERSION=v1.4.0 and all four binaries land in dist/.
GitHub Releases Distribution
GitHub Releases is free binary hosting with a stable URL structure. Upload your binaries as release assets and they're available at predictable URLs.
Releases are identified by semver tags — git tags following the vMAJOR.MINOR.PATCH convention: v1.0.0, v1.4.0, v2.0.0. MAJOR bumps for breaking changes, MINOR for new features, PATCH for bug fixes. When you create a GitHub Release, you pick a tag like v1.4.0 — that tag is what appears in the download URL and what the auto-update check compares against.
The naming convention matters. Use devctl-{OS}-{ARCH} so the install script can construct the URL from the output of uname.
devctl-Linux-x86_64 (uname -s = Linux, uname -m = x86_64)
devctl-Darwin-x86_64 (uname -s = Darwin, uname -m = x86_64)
devctl-Darwin-arm64 (uname -s = Darwin, uname -m = arm64)
devctl-windows-amd64.exe (manual install on Windows)The install one-liner for Linux and macOS:
curl -L \
"https://github.com/yourorg/devctl/releases/latest/download/devctl-$(uname -s)-$(uname -m)" \
-o /usr/local/bin/devctl \
&& chmod +x /usr/local/bin/devctluname -s returns Linux or Darwin. uname -m returns x86_64 or arm64. The URL resolves to the right binary automatically.
Add this to your team's onboarding doc. New engineers run one command and they're done. No Go, no git, no build step.
For CI automation, pin to a specific version instead of latest:
VERSION=v1.4.0
curl -L \
"https://github.com/yourorg/devctl/releases/download/${VERSION}/devctl-$(uname -s)-$(uname -m)" \
-o /usr/local/bin/devctl \
&& chmod +x /usr/local/bin/devctlHomebrew and GoReleaser
The curl one-liner works, but most macOS engineers expect brew install. You can support both with a Homebrew tap — a GitHub repo that contains your formula.
# engineer installs with:
brew tap yourorg/devctl
brew install devctl
# or in one command:
brew install yourorg/devctl/devctlWriting the formula by hand and keeping it updated on every release is tedious. GoReleaser automates the entire release pipeline: cross-compiling, creating the GitHub Release, uploading binaries, and generating the Homebrew formula.
Install it:
brew install goreleaserCreate .goreleaser.yaml at the root of your repo:
builds:
- env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
ldflags:
- -X main.version={{.Version}}
- -X main.commit={{.Commit}}
- -X main.date={{.Date}}
archives:
- format: tar.gz
format_overrides:
- goos: windows
format: zip
brews:
- repository:
owner: yourorg
name: homebrew-devctl
homepage: https://github.com/yourorg/devctl
description: DevOps control plane CLI
checksum:
name_template: checksums.txt
release:
github:
owner: yourorg
name: devctlTo release, create a git tag and run GoReleaser:
git tag v1.4.0
git push origin v1.4.0
goreleaser release --cleanGoReleaser:
- Cross-compiles for all OS/arch combinations
- Creates the GitHub Release with all binaries attached
- Generates and pushes the Homebrew formula to
yourorg/homebrew-devctl
After the first release, engineers on macOS can install and upgrade with:
brew install yourorg/devctl/devctl
brew upgrade devctlFor CI, run GoReleaser in GitHub Actions on every tag push:
# .github/workflows/release.yml
on:
push:
tags:
- 'v*'
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: ${{ secrets.GITHUB_TOKEN }}Push a tag and GoReleaser handles everything. No manual Makefile, no manual formula updates, no uploading binaries by hand.
Embedding Version Info
The go build -ldflags flag can overwrite package-level variables at link time. Declare them in main.go with default values for local builds, then stamp them in CI.
package main
// version, commit, and date are stamped at build time via -ldflags.
// When built locally without flags, they show their zero values.
var (
version = "dev"
commit = "none"
date = "unknown"
)Then wire a version subcommand into Cobra (from github.com/spf13/cobra):
// newVersionCmd returns the version subcommand.
func newVersionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print version info",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("devctl %s (commit %s, built %s)\n", version, commit, date)
},
}
}Register it in init():
func init() {
rootCmd.AddCommand(newVersionCmd())
}Running devctl version on a release build prints:
devctl v1.4.0 (commit a3f8c12, built 2026-08-09T14:32:00Z)Running it on a local build prints:
devctl dev (commit none, built unknown)As an alternative, runtime/debug.ReadBuildInfo() returns VCS metadata from the Go module system without any ldflags:
import "runtime/debug"
// readBuildVersion extracts the module version from the Go build info.
// Returns "dev" when the binary was not built from a tagged module.
func readBuildVersion() string {
if info, ok := debug.ReadBuildInfo(); ok {
if info.Main.Version != "" && info.Main.Version != "(devel)" {
return info.Main.Version
}
}
return "dev"
}The limitation: ReadBuildInfo only returns a version when the binary was installed via go install with a tagged module. For release builds where you control the pipeline, ldflags is more reliable.
SHA-Based Enforcement
For internal tools, there's a simpler pattern that doesn't require semver tags or a GitHub API call: embed the git SHA at build time, then compare it against the latest SHA from your repo on every run. If they don't match, the tool quits and tells the user to update.
This works well when your team distributes the tool from a single internal repo and you want zero tolerance for stale builds — no "please upgrade" Slack messages, no version drift.
Embed the SHA with ldflags (the commit variable you already have):
go build -ldflags="-X main.commit=$(git rev-parse HEAD)" -o devctl .On each run, fetch the latest SHA from your repo and compare:
// checkSHA fetches the latest commit SHA from the main branch and exits
// if the running binary is out of date. Silently returns on network errors.
func checkSHA(current string) {
const shaURL = "https://raw.githubusercontent.com/yourorg/devctl/main/.sha"
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, shaURL, nil)
if err != nil {
return
}
resp, err := http.DefaultClient.Do(req)
if err != nil || resp.StatusCode != http.StatusOK {
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return
}
latest := strings.TrimSpace(string(body))
if latest != "" && latest != current {
fmt.Fprintf(os.Stderr,
"devctl is out of date (running %s, latest %s).\n"+
"Update: curl -L https://github.com/yourorg/devctl/releases/latest/download/devctl-$(uname -s)-$(uname -m) -o /usr/local/bin/devctl && chmod +x /usr/local/bin/devctl\n",
current[:8], latest[:8],
)
os.Exit(1)
}
}In CI, write the current SHA to .sha and commit it alongside the release:
git rev-parse HEAD > .sha
git add .sha && git commit -m "release: update .sha"Wire it into main() before rootCmd.Execute():
func main() {
checkSHA(commit) // exits if stale
go checkForUpdate(version)
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}The trade-off: this is stricter than semver-based checks. Every commit to main that ships a new binary will force everyone to update, even for minor changes. Use it when consistency matters more than convenience — security tooling, deploy scripts, anything where running stale code causes real problems.
Auto-Update Check
Every time devctl runs, it hits the GitHub API, compares the latest release tag to the embedded version, and prints a warning if the user is behind. The check runs in a goroutine so it never blocks the command.
First, define the structs for the GitHub Releases API response:
// githubRelease represents the relevant fields from the GitHub Releases API.
type githubRelease struct {
TagName string `json:"tag_name"` // e.g. "v1.4.0"
HTMLURL string `json:"html_url"`
}Next, the check function itself:
// checkForUpdate fetches the latest release from GitHub and prints a warning
// if the running version is older. It exits silently on any network error
// so a missing connection never breaks normal usage.
func checkForUpdate(current string) {
const apiURL = "https://api.github.com/repos/yourorg/devctl/releases/latest"
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
if err != nil {
return
}
req.Header.Set("User-Agent", "devctl/"+current)
resp, err := http.DefaultClient.Do(req)
if err != nil || resp.StatusCode != http.StatusOK {
return
}
defer resp.Body.Close()
var release githubRelease
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return
}
latest := release.TagName
// semver.Compare requires "v" prefix. Normalize current if needed.
if !strings.HasPrefix(current, "v") {
return // dev build, skip comparison
}
if semver.Compare(current, latest) < 0 {
fmt.Fprintf(os.Stderr,
"\n\033[33mUpdate available: %s → %s\033[0m\n"+
"Run: curl -L %s/releases/latest/download/devctl-$(uname -s)-$(uname -m) -o /usr/local/bin/devctl && chmod +x /usr/local/bin/devctl\n\n",
current, latest, "https://github.com/yourorg/devctl",
)
}
}semver.Compare comes from golang.org/x/mod/semver. It returns -1 if the first argument is an older version, 0 if equal, 1 if newer. The 3-second timeout means a slow or unavailable network never holds up the command.
Add the dependency and import:
go get golang.org/x/mod@latestimport (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"time"
"golang.org/x/mod/semver"
)Then run it as a goroutine so the command executes immediately and the warning appears after. rootCmd is your Cobra root command — wire this into the same main() you built in lesson 01:
func main() {
go checkForUpdate(version)
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}The goroutine fires and the command runs in parallel. By the time rootCmd.Execute() returns, the HTTP request has usually finished, so the warning appears right after the command output.
Minimum Version Enforcement
Background update checks are polite. Sometimes you need to be firm. When a breaking API change ships or a security bug is fixed, old versions should refuse to run and tell users exactly what to do.
Host a small JSON file in your repo (or on any URL you control):
{
"min_version": "v1.3.0",
"message": "v1.3.0 fixes a critical bug in the deploy command. Upgrade required."
}Serve it from a raw GitHub URL like https://raw.githubusercontent.com/yourorg/devctl/main/policy.json.
Then fetch and enforce it at startup:
// versionPolicy holds the minimum version requirement fetched from the remote policy file.
type versionPolicy struct {
MinVersion string `json:"min_version"`
Message string `json:"message"`
}
// enforceMinVersion fetches the remote policy and exits if the running version
// is below the minimum. Silently returns if the policy cannot be fetched.
func enforceMinVersion(current string) {
const policyURL = "https://raw.githubusercontent.com/yourorg/devctl/main/policy.json"
if !strings.HasPrefix(current, "v") {
return // dev build, skip enforcement
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, policyURL, nil)
if err != nil {
return
}
resp, err := http.DefaultClient.Do(req)
if err != nil || resp.StatusCode != http.StatusOK {
return
}
defer resp.Body.Close()
var policy versionPolicy
if err := json.NewDecoder(resp.Body).Decode(&policy); err != nil {
return
}
if semver.Compare(current, policy.MinVersion) < 0 {
fmt.Fprintf(os.Stderr,
"\033[31mError: devctl %s is below the minimum required version %s.\033[0m\n"+
"%s\n\n"+
"Upgrade: curl -L https://github.com/yourorg/devctl/releases/latest/download/devctl-$(uname -s)-$(uname -m) -o /usr/local/bin/devctl && chmod +x /usr/local/bin/devctl\n",
current, policy.MinVersion, policy.Message,
)
os.Exit(1)
}
}When a user on v1.1.0 runs any devctl command, they see:
Error: devctl v1.1.0 is below the minimum required version v1.3.0.
v1.3.0 fixes a critical bug in the deploy command. Upgrade required.
Upgrade: curl -L https://github.com/yourorg/devctl/releases/latest/download/devctl-$(uname -s)-$(uname -m) -o /usr/local/bin/devctl && chmod +x /usr/local/bin/devctlNo more chasing people on Slack. The tool enforces its own upgrade path.
Putting It Together: the Complete devctl
Here's the final main.go with all subcommands registered and both version checks wired in:
func init() {
// Subcommands assembled across lessons 01-15
rootCmd.AddCommand(logsCmd) // devctl logs tail <file>
rootCmd.AddCommand(healthCmd) // devctl health check <service>
rootCmd.AddCommand(pipelineCmd) // devctl pipeline run <pipeline.yaml>
rootCmd.AddCommand(rolloutCmd) // devctl rollout deploy <service> <version>
rootCmd.AddCommand(newVersionCmd())
}Here's the full main() with both checks wired in:
func main() {
// Enforce minimum version before doing anything else.
// This blocks (with a short timeout) so old builds fail fast.
enforceMinVersion(version)
// Start the update check in the background.
// The warning prints after the command finishes, if one is available.
go checkForUpdate(version)
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}enforceMinVersion runs synchronously because a blocked-too-old binary should stop immediately. checkForUpdate runs as a goroutine because a "you're slightly behind" warning should never delay real work.
The full startup sequence on a normally functioning build:
enforceMinVersionfetchespolicy.json. If the network is down, it returns immediately and the tool continues.checkForUpdategoroutine fires.rootCmd.Execute()runs the user's command.- If the update check found a newer version, the warning prints to stderr after the command output.
On an outdated build (below min_version):
enforceMinVersionprints the error and callsos.Exit(1).- Nothing else runs.
Here's an example session where the user is behind but not blocked:
$ devctl deploy staging v1.2.0
Deploying v1.2.0 to staging... done.
Update available: v1.3.0 → v1.4.1
Run: curl -L https://github.com/yourorg/devctl/releases/latest/download/devctl-$(uname -s)-$(uname -m) -o /usr/local/bin/devctl && chmod +x /usr/local/bin/devctlThe command ran. The warning appeared. The engineer knows what to do.
Key Takeaways
CGO_ENABLED=0produces a statically linked binary that runs on any machine of that OS/architecture without shared library dependencies.-ldflags="-X main.version=..."stamps variables at link time. Use it to embed version, commit hash, and build date.runtime/debug.ReadBuildInfo()extracts version metadata without ldflags, but only when the binary was installed viago installwith a tagged module.- Host binaries on GitHub Releases. The
devctl-$(uname -s)-$(uname -m)naming convention makes the install one-liner work on Linux and macOS without branching. golang.org/x/mod/semver.Comparecompares semantic version strings. It returns -1 (older), 0 (equal), or 1 (newer).- Run the update check in a goroutine. It should never block the command the user actually ran.
- Minimum version enforcement is how you force upgrades without chasing people. The tool exits with an error and prints the upgrade command.
- A 3-second
context.WithTimeouton all external HTTP calls means network issues never break normal usage.
🎁 You've built devctl from a single deploy command to a full platform engineering toolkit. The next step is taking it further: add your team's own subcommands, integrate with your internal APIs, and make it the first thing every engineer reaches for.