So, you've written a small Go program. main.go, a flag.StringVar, a flag.Parse, and a fmt.Println at the bottom. It works. You alias it in your shell, run it a few times, and feel pretty good about life.

Then a coworker asks if they can use it. You add a subcommand. The subcommand needs a config file. You start hand-rolling a YAML loader. Someone wants to override the config with an env var. Then a flag. Then they want completion. Then they want a Homebrew formula. Then you realize your tool has been running off go build on your laptop for three months and there's no Windows binary, no version string, no checksums, no changelog.

This is the gap I want to close: between "I wrote a Go script" and "this is a tool people install and trust." The fun part is that Go is unusually good at this once you know the right pieces. The boring parts (flags, help text, exit codes, cross-compilation) are mostly solved problems. You just have to pick the right tools and wire them in the right order.

We'll walk through:

  • a project layout that scales past one file,
  • when stdlib flag is plenty and when it stops being enough,
  • Cobra: command trees, persistent flags, args validation, and the small features people forget,
  • Viper-style layered configuration (flag → env → file → default), and when to skip Viper entirely,
  • UX details that separate a hobby CLI from a tool people actually keep installed,
  • testing CLIs without losing your sanity,
  • and finally, GoReleaser: the part that turns a binary into a real release.

By the end you should have a mental checklist for any new CLI you start in Go, and a pretty clear sense of which knobs matter and which ones you can leave at defaults.

Why Go is unusually good for CLIs

Worth saying out loud before we dive in: there's a reason so many infra tools (kubectl, terraform, gh, hugo, cosign, goreleaser itself) are written in Go. The combination is hard to beat.

Static binaries. No runtime, no pip install, no nvm. go build gives you one file you can drop anywhere. Cross-compilation is built in: GOOS=linux GOARCH=arm64 go build works the same on your Mac and on a CI runner. The standard library has HTTP, JSON, file I/O, and process control without third-party imports. Startup time is in single-digit milliseconds. Errors are values, which fits the "report something useful and exit non-zero" pattern CLIs need.

The flip side: Go won't hand you argparse for free. You'll either lean on stdlib flag or pull in a library. That's the next question.

Project structure that survives past 500 lines

A main.go is fine for one command and one flag. The moment you add a subcommand, you want a real layout. The convention most serious Go CLIs converge on looks like this:

Text
mytool/
├── go.mod
├── go.sum
├── main.go
├── cmd/
│   ├── root.go
│   ├── version.go
│   ├── apply.go
│   └── status.go
├── internal/
│   ├── config/
│   │   └── config.go
│   ├── client/
│   │   └── client.go
│   └── runner/
│       └── runner.go
└── .goreleaser.yaml

The pieces:

  • main.go is a one-screen entrypoint. It calls cmd.Execute(). That's it.
  • cmd/ holds Cobra command definitions, one file per command (or per command group). Each file registers itself with the root command via init().
  • internal/ holds the actual logic. The internal/ folder is enforced by the Go toolchain, and nothing outside this module can import it, which keeps your "library surface" small and on-purpose.
  • pkg/ (optional) holds anything you genuinely want consumable as a library. Most CLIs don't need this. Don't create it preemptively.

The split between cmd/ and internal/ matters more than it looks. cmd/ should be thin: flag parsing, argument validation, maybe some output formatting. The interesting work belongs in internal/. When you can call your tool's behavior from a test without going through Cobra, you'll thank yourself.

main.go ends up looking dull:

Go main.go
package main

import "github.com/you/mytool/cmd"

func main() {
    cmd.Execute()
}

That's the whole file. Dull on purpose.

Anatomy of a Go CLI project: left side shows a directory tree with main.go, cmd/, and internal/, right side shows a runtime flow from entrypoint to Cobra command tree to internal logic

Stdlib flag is fine, until it isn't

Before you reach for Cobra, it's worth understanding what the standard library gives you. The flag package is small, fast, and shipped with Go itself.

Go cmd/simple/main.go
package main

import (
    "flag"
    "fmt"
    "os"
)

func main() {
    var (
        addr    = flag.String("addr", ":8080", "address to bind")
        verbose = flag.Bool("v", false, "verbose output")
    )
    flag.Parse()

    if *verbose {
        fmt.Fprintf(os.Stderr, "listening on %s\n", *addr)
    }
    // ... actual work ...
}

It works. It generates -h help. It handles --addr=:9000, --addr :9000, and -addr :9000. For a single-purpose binary with a handful of flags, this is genuinely all you need.

Where stdlib flag stops keeping up:

  • Subcommands. flag.NewFlagSet lets you build them by hand, but it's annoying. You parse os.Args[1], dispatch, then parse the rest. Doable, but you'll write the same scaffolding every time.
  • Persistent flags across subcommands. No native concept.
  • Required flags, mutually exclusive flags, args validation. Roll your own.
  • Shell completion. Roll your own.
  • Auto-generated help with sections. Roll your own.

The break point in practice is around the second subcommand. One subcommand, hand-roll it. Two or more, reach for Cobra.

Cobra: the actual command tree

Cobra (github.com/spf13/cobra) is the library most production Go CLIs use. It gives you a tree of Command structs, each with its own flags, args, and Run function. The tree mirrors the user's typed input. So mytool apply config --dry-run resolves to the apply command's config subcommand with the --dry-run flag set.

A minimal root looks like this:

Go cmd/root.go
package cmd

import (
    "fmt"
    "os"

    "github.com/spf13/cobra"
)

var rootCmd = &cobra.Command{
    Use:   "mytool",
    Short: "A small tool for doing the thing",
    Long: `mytool manages your widgets across environments.

It supports applying, inspecting, and rolling back widget configs.`,
}

func Execute() {
    if err := rootCmd.Execute(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

A subcommand goes in its own file and registers itself in init():

Go cmd/version.go
package cmd

import (
    "fmt"

    "github.com/spf13/cobra"
)

var (
    version = "dev"
    commit  = "none"
    date    = "unknown"
)

var versionCmd = &cobra.Command{
    Use:   "version",
    Short: "Print the version",
    Run: func(cmd *cobra.Command, args []string) {
        fmt.Printf("mytool %s (commit %s, built %s)\n", version, commit, date)
    },
}

func init() {
    rootCmd.AddCommand(versionCmd)
}

Notice the package-level version, commit, date variables. These get filled in at build time by -ldflags, and GoReleaser will fill them automatically. We'll come back to that.

A few Cobra fields you'll lean on constantly:

  • Use: the one-liner usage string. Use: "apply [flags] FILE" shows up in help.
  • Short: one-sentence summary shown in the parent's command list.
  • Long: multi-paragraph help shown by --help.
  • Run or RunE: the work. Prefer RunE so you can return err cleanly.
  • Args: argument validation. Cobra ships cobra.ExactArgs(1), cobra.MinimumNArgs(1), cobra.NoArgs, cobra.OnlyValidArgs, and friends.
  • PreRunE: runs after args are validated but before RunE. Good for setting up clients, loading config, validating flag combinations.
  • Example: an Example: block shows up in help and is one of the highest-leverage bits of UX you can add.

Here's a real subcommand with most of those wired in:

Go cmd/apply.go
package cmd

import (
    "fmt"

    "github.com/spf13/cobra"

    "github.com/you/mytool/internal/runner"
)

var (
    applyDryRun bool
    applyForce  bool
)

var applyCmd = &cobra.Command{
    Use:   "apply [flags] FILE",
    Short: "Apply a widget config from a file",
    Long: `Apply reads a widget config and reconciles the target environment to match.

By default, apply will refuse to run against production unless --force is passed.`,
    Example: `  mytool apply widgets.yaml
  mytool apply --dry-run widgets.yaml
  mytool apply --force prod-widgets.yaml`,
    Args: cobra.ExactArgs(1),
    RunE: func(cmd *cobra.Command, args []string) error {
        path := args[0]
        opts := runner.ApplyOptions{
            DryRun: applyDryRun,
            Force:  applyForce,
        }
        return runner.Apply(cmd.Context(), path, opts)
    },
}

func init() {
    applyCmd.Flags().BoolVar(&applyDryRun, "dry-run", false, "show what would change without applying")
    applyCmd.Flags().BoolVar(&applyForce, "force", false, "allow apply against production targets")
    rootCmd.AddCommand(applyCmd)
}

Two details worth pointing out:

cmd.Context() returns a context.Context that Cobra will cancel when the process gets SIGINT or SIGTERM (you do this once on the root with rootCmd.SetContext(ctx) or use cobra.CheckErr). Pass this context down into internal/runner.Apply and your work cancels cleanly when someone hits Ctrl-C. Don't skip this. The difference between a CLI that cancels cleanly and one that strands resources is one context parameter.

The command file does flag binding, args validation, and one function call. The actual work lives in internal/runner. You can unit-test runner.Apply without ever touching Cobra.

Persistent flags: shared across the whole tree

Some flags belong on every command. --config, --verbose, --output, --profile. You define them once on the root:

Go cmd/root.go
var (
    cfgFile   string
    verbose   bool
    outputFmt string
)

var rootCmd = &cobra.Command{
    Use:   "mytool",
    Short: "A small tool for doing the thing",
}

func init() {
    rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "path to config file (default: $HOME/.mytool.yaml)")
    rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
    rootCmd.PersistentFlags().StringVarP(&outputFmt, "output", "o", "text", "output format: text|json|yaml")
}

Persistent flags are inherited by every child command, recursively. mytool apply --verbose foo.yaml and mytool status --verbose both work without apply or status knowing the flag exists.

StringVarP (with the trailing P) takes both a long name and a short name. The convention is sensible single letters: -v for verbose, -o for output, -c for config. Don't get creative. -x for "execute" reads as "extra" to half your users.

Flag types, defaults, and the small ergonomic touches

Cobra exposes most types you'd reasonably want:

Go
flags := cmd.Flags()

flags.StringVar(&name, "name", "default", "usage")
flags.IntVar(&count, "count", 1, "how many")
flags.BoolVar(&recursive, "recursive", false, "descend into subdirs")
flags.DurationVar(&timeout, "timeout", 30*time.Second, "request timeout")
flags.StringSliceVar(&tags, "tag", nil, "tag (repeatable)")
flags.StringToStringVar(&labels, "label", nil, "label as key=value")

A few small features that matter:

Required flags. Mark a flag required and Cobra refuses to run without it.

Go
applyCmd.Flags().StringVar(&env, "env", "", "target environment")
applyCmd.MarkFlagRequired("env")

Mutually exclusive flags. Tell Cobra two flags can't both be set.

Go
applyCmd.MarkFlagsMutuallyExclusive("dry-run", "force")

Required together. Tell Cobra two flags must appear together.

Go
applyCmd.MarkFlagsRequiredTogether("client-cert", "client-key")

These look like trivia, but they're the difference between "the tool tells me I forgot something" and "the tool ran for 40 seconds, made half a change, then exploded because a flag was missing."

Deprecated flags. Don't just remove them; deprecate first.

Go
flags.StringVar(&legacyAddr, "address", "", "DEPRECATED: use --addr")
flags.MarkDeprecated("address", "use --addr instead")

Cobra prints a warning, hides the flag from help, but still accepts it. Users who scripted against your old flag don't get a unknown flag error on Monday morning.

Arg validation: catch shape problems before work starts

The Args field on a cobra.Command takes a function with the signature func(cmd *Command, args []string) error. Cobra ships a bunch of ready-made ones:

Go
Args: cobra.NoArgs,
Args: cobra.ExactArgs(2),
Args: cobra.MinimumNArgs(1),
Args: cobra.MaximumNArgs(3),
Args: cobra.RangeArgs(1, 3),
Args: cobra.OnlyValidArgs, // requires ValidArgs to be set

You can combine them with cobra.MatchAll:

Go
Args: cobra.MatchAll(
    cobra.ExactArgs(1),
    func(cmd *cobra.Command, args []string) error {
        if !strings.HasSuffix(args[0], ".yaml") {
            return fmt.Errorf("file must be a .yaml")
        }
        return nil
    },
),

This lets your RunE assume the args are well-shaped. Validation lives in one place, errors are user-readable, and --help still shows the right usage.

Configuration: flag, env, file, default

Past a few flags, users start wanting a config file. They also want to override individual settings with an env var. And they want a CI flag to win over both. That's a four-layer precedence problem:

Text
1. Command-line flag    (highest priority)
2. Environment variable
3. Config file          (yaml / toml / json)
4. Built-in default     (lowest priority)

This is the layering most production CLIs converge on. kubectl, terraform, gh, all do some version of it.

Configuration precedence in a Go CLI: four stacked layers from highest to lowest priority, namely command-line flag, environment variable, config file, and built-in default

You have two reasonable choices for implementing it: Viper, or hand-rolled.

Option A: Viper

Viper (github.com/spf13/viper) is the obvious match for Cobra: same author, same era, designed to interop. It handles all four layers out of the box.

Go internal/config/config.go
package config

import (
    "fmt"
    "strings"
    "time"

    "github.com/spf13/cobra"
    "github.com/spf13/viper"
)

type Config struct {
    Addr    string        `mapstructure:"addr"`
    Timeout time.Duration `mapstructure:"timeout"`
    Env     string        `mapstructure:"env"`
    Verbose bool          `mapstructure:"verbose"`
}

func Load(cmd *cobra.Command, cfgFile string) (*Config, error) {
    v := viper.New()

    // 4. Defaults.
    v.SetDefault("addr", ":8080")
    v.SetDefault("timeout", 30*time.Second)

    // 3. Config file.
    if cfgFile != "" {
        v.SetConfigFile(cfgFile)
    } else {
        v.SetConfigName(".mytool")
        v.SetConfigType("yaml")
        v.AddConfigPath("$HOME")
        v.AddConfigPath(".")
    }
    if err := v.ReadInConfig(); err != nil {
        if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
            return nil, fmt.Errorf("read config: %w", err)
        }
    }

    // 2. Environment. MYTOOL_ADDR, MYTOOL_TIMEOUT, etc.
    v.SetEnvPrefix("MYTOOL")
    v.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_"))
    v.AutomaticEnv()

    // 1. Flags (highest priority).
    if err := v.BindPFlags(cmd.Flags()); err != nil {
        return nil, fmt.Errorf("bind flags: %w", err)
    }
    if err := v.BindPFlags(cmd.PersistentFlags()); err != nil {
        return nil, fmt.Errorf("bind persistent flags: %w", err)
    }

    var cfg Config
    if err := v.Unmarshal(&cfg); err != nil {
        return nil, fmt.Errorf("unmarshal config: %w", err)
    }
    return &cfg, nil
}

The pieces that do real work:

  • SetDefault sets the bottom layer. Defaults you put in code rather than asking the user to discover.
  • AddConfigPath + SetConfigName + ReadInConfig covers the file layer. Viper auto-detects .yaml, .toml, .json, and a few others.
  • SetEnvPrefix("MYTOOL") + AutomaticEnv makes MYTOOL_ADDR map to the addr key. The replacer lets you have request.timeout map to MYTOOL_REQUEST_TIMEOUT.
  • BindPFlags plumbs Cobra flags as the highest-priority source. Flag values override env, file, and default.
  • Unmarshal populates your typed struct via mapstructure tags.

Call config.Load(cmd, cfgFile) from PreRunE on the root command, stash the result somewhere your subcommands can read it, and every subcommand gets the same merged config without duplicating logic.

Option B: skip Viper

Viper is convenient but it pulls in a lot of dependencies and its API is broad. If you want a smaller surface, hand-roll it. A common minimal pattern:

Go internal/config/config_simple.go
package config

import (
    "errors"
    "os"
    "time"

    "gopkg.in/yaml.v3"
)

type Config struct {
    Addr    string        `yaml:"addr"`
    Timeout time.Duration `yaml:"timeout"`
    Env     string        `yaml:"env"`
}

func Load(path string) (*Config, error) {
    cfg := &Config{
        // defaults
        Addr:    ":8080",
        Timeout: 30 * time.Second,
    }

    if path != "" {
        b, err := os.ReadFile(path)
        if err != nil && !errors.Is(err, os.ErrNotExist) {
            return nil, err
        }
        if err == nil {
            if err := yaml.Unmarshal(b, cfg); err != nil {
                return nil, err
            }
        }
    }

    if v := os.Getenv("MYTOOL_ADDR"); v != "" {
        cfg.Addr = v
    }
    if v := os.Getenv("MYTOOL_ENV"); v != "" {
        cfg.Env = v
    }
    // ... etc.
    return cfg, nil
}

Then back in your Cobra command, after parsing flags, you do one last pass: if the flag was explicitly set, override the config value with it. cmd.Flags().Changed("addr") tells you whether the user actually passed --addr or whether Cobra is reporting the default.

Go
cfg, err := config.Load(cfgFile)
if err != nil {
    return err
}
if cmd.Flags().Changed("addr") {
    cfg.Addr = addr // addr is the flag-bound variable
}

It's more code than Viper, but the dependency graph is yaml.v3 and that's it. For small tools, this is often the right call. For tools with twenty config keys, you'll regret not using Viper around key fifteen.

UX details that separate hobby CLIs from real ones

The boring parts. None of these are technically difficult; all of them are the difference between a tool people keep installed and one they uninstall after a week.

Version, with build info

Hardcoding version = "1.0.0" in a string literal is the wrong move. Use -ldflags and let your release pipeline fill it in.

Go cmd/version.go
package cmd

var (
    version = "dev"
    commit  = "none"
    date    = "unknown"
)

At build time:

Bash manual build
go build \
  -ldflags "-X github.com/you/mytool/cmd.version=1.2.0 \
            -X github.com/you/mytool/cmd.commit=$(git rev-parse --short HEAD) \
            -X github.com/you/mytool/cmd.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  -o mytool .

In a dev build the version is dev. In a real release the version, commit, and date are baked in. GoReleaser will do this for you automatically, which we'll get to.

Shell completion

Cobra generates shell completion for bash, zsh, fish, and PowerShell. You enable it with a single command:

Go cmd/completion.go
package cmd

import (
    "os"

    "github.com/spf13/cobra"
)

var completionCmd = &cobra.Command{
    Use:   "completion [bash|zsh|fish|powershell]",
    Short: "Generate shell completion scripts",
    DisableFlagsInUseLine: true,
    ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
    Args:      cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
    RunE: func(cmd *cobra.Command, args []string) error {
        switch args[0] {
        case "bash":
            return rootCmd.GenBashCompletionV2(os.Stdout, true)
        case "zsh":
            return rootCmd.GenZshCompletion(os.Stdout)
        case "fish":
            return rootCmd.GenFishCompletion(os.Stdout, true)
        case "powershell":
            return rootCmd.GenPowerShellCompletionWithDesc(os.Stdout)
        }
        return nil
    },
}

func init() {
    rootCmd.AddCommand(completionCmd)
}

Users run mytool completion zsh > /usr/local/share/zsh/site-functions/_mytool once and tab-complete subcommands, flags, and (with a bit more work) flag values for the rest of time. This costs you about twenty lines and feels like magic.

You can take completion one step further by making specific flags or args completion-aware:

Go
applyCmd.RegisterFlagCompletionFunc("env", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
    return []string{"dev", "staging", "prod"}, cobra.ShellCompDirectiveNoFileComp
})

Now mytool apply --env <TAB> offers dev, staging, prod. This is a strong signal that your tool is being maintained by someone who cares.

Exit codes

UNIX convention: zero is success, non-zero is failure. Be specific about what each non-zero means, especially if scripts will read your tool's output.

Go cmd/exit_codes.go
package cmd

const (
    ExitOK         = 0
    ExitUsage      = 2  // bad CLI usage
    ExitConfig     = 3  // config load / validation failure
    ExitNetwork    = 4  // remote unreachable
    ExitConflict   = 5  // refused due to drift / conflict
    ExitInternal   = 70 // unexpected internal error
)

Cobra returns 1 by default when RunE returns a non-nil error. If you want finer control, set the exit code yourself and call os.Exit from Execute:

Go cmd/root.go
func Execute() {
    if err := rootCmd.Execute(); err != nil {
        var ee *runner.ExitError
        if errors.As(err, &ee) {
            os.Exit(ee.Code)
        }
        os.Exit(1)
    }
}

Where runner.ExitError is a custom error type that carries an exit code. The pattern is: deep code returns rich errors, cmd.Execute translates them to exit codes.

Output formatting: --output

Many real CLIs support --output text|json|yaml. Text for humans, JSON for scripts, YAML for kubectl-style ergonomics.

Go internal/output/output.go
package output

import (
    "encoding/json"
    "fmt"
    "io"

    "gopkg.in/yaml.v3"
)

type Format string

const (
    FormatText Format = "text"
    FormatJSON Format = "json"
    FormatYAML Format = "yaml"
)

type Renderer interface {
    Render(v any) error
}

func New(format Format, w io.Writer) (Renderer, error) {
    switch format {
    case FormatText:
        return textRenderer{w: w}, nil
    case FormatJSON:
        return jsonRenderer{w: w}, nil
    case FormatYAML:
        return yamlRenderer{w: w}, nil
    default:
        return nil, fmt.Errorf("unknown output format: %s", format)
    }
}

type jsonRenderer struct{ w io.Writer }

func (r jsonRenderer) Render(v any) error {
    enc := json.NewEncoder(r.w)
    enc.SetIndent("", "  ")
    return enc.Encode(v)
}

type yamlRenderer struct{ w io.Writer }

func (r yamlRenderer) Render(v any) error {
    enc := yaml.NewEncoder(r.w)
    defer enc.Close()
    return enc.Encode(v)
}

type textRenderer struct{ w io.Writer }

func (r textRenderer) Render(v any) error {
    // domain-specific human formatting per type
    switch t := v.(type) {
    case fmt.Stringer:
        _, err := fmt.Fprintln(r.w, t.String())
        return err
    default:
        _, err := fmt.Fprintf(r.w, "%v\n", v)
        return err
    }
}

The pattern: subcommands always produce a typed value, then hand it to a Renderer chosen by the --output flag. You never sprinkle if outputFmt == "json" inside business logic.

Quiet, verbose, and stderr vs stdout

One rule: machine-readable output goes to stdout, everything else goes to stderr. That includes progress logs, warnings, prompts, and "loading..." messages. If a user pipes your tool to jq, only the JSON should land in the pipe.

Go
fmt.Fprintln(os.Stderr, "loading config from", cfgFile) // stderr
enc.Encode(result)                                       // stdout

This is the single most-violated CLI rule in the wild, and getting it right makes your tool feel professional.

Help text

Cobra generates help automatically, but two small things make it much better:

  • Write Long descriptions in full sentences. Help is documentation users read at the moment they need it most.
  • Always fill in Example. One line of working invocation is worth ten lines of prose.

If you spot yourself writing the same help three times, you have a config or auth concept that belongs at the root, not in each subcommand.

Errors and silenced usage

Cobra's default behavior, when RunE returns an error, is to print the error and the full usage block. For a typo in a flag name, that's fine. For "remote server returned 503", it makes your tool look broken.

The fix:

Go cmd/root.go
var rootCmd = &cobra.Command{
    Use:   "mytool",
    Short: "...",
    SilenceUsage:  true,
    SilenceErrors: true,
}

func Execute() {
    if err := rootCmd.Execute(); err != nil {
        fmt.Fprintln(os.Stderr, "error:", err)
        os.Exit(1)
    }
}

SilenceUsage suppresses the usage dump on runtime errors. SilenceErrors lets you print the error yourself, with whatever prefix and formatting you want. Cobra will still print usage automatically for usage errors (unknown flag, wrong arg count), which is what you want.

Testing CLIs without losing your sanity

Two layers of tests, in order of importance:

1. Test the business logic, not the CLI. This is why we kept cmd/ thin. The interesting work lives in internal/runner, internal/client, etc. Test those with normal Go unit tests. No Cobra in the picture.

Go internal/runner/runner_test.go
package runner

import (
    "context"
    "testing"
)

func TestApply_DryRun(t *testing.T) {
    ctx := context.Background()
    res, err := Apply(ctx, "testdata/widgets.yaml", ApplyOptions{DryRun: true})
    if err != nil {
        t.Fatalf("Apply: %v", err)
    }
    if !res.NoOp {
        t.Errorf("expected no-op in dry-run, got %+v", res)
    }
}

This is 90% of where your tests should live.

2. Smoke-test the command wiring. A small number of tests that drive Cobra end-to-end and check that flags, args, and exit codes hang together. Cobra makes this easy:

Go cmd/apply_test.go
package cmd

import (
    "bytes"
    "strings"
    "testing"
)

func TestApply_RequiresFile(t *testing.T) {
    var stderr bytes.Buffer
    rootCmd.SetArgs([]string{"apply"}) // no FILE arg
    rootCmd.SetOut(&stderr)
    rootCmd.SetErr(&stderr)

    err := rootCmd.Execute()
    if err == nil {
        t.Fatalf("expected error, got nil")
    }
    if !strings.Contains(err.Error(), "accepts 1 arg") {
        t.Errorf("unexpected error: %v", err)
    }
}

Two warnings about this pattern:

  • Cobra commands hold global state via package-level vars. Tests that run in parallel can stomp on each other. Either run these serially or refactor commands to be constructed by functions (newApplyCmd() *cobra.Command) returning fresh instances per test.
  • rootCmd.SetArgs mutates a package-level command. Reset it in t.Cleanup if you want to be careful.

3. Golden-file tests for output. When you have --output json and --output text, a small "given this input, produce this output" suite catches accidental format breakage instantly. The pattern: snapshot the expected output in testdata/golden/*.txt, run the command, compare, and add a -update test flag that rewrites the golden file on demand.

Go
flag.BoolVar(&update, "update", false, "update golden files")

That's it. Run go test ./cmd/... -update after an intentional change, review the diff, commit.

Releases are the part everyone underestimates

So your binary works on your laptop. You're three flags away from git tag v0.1.0. The temptation is to call go build, upload the binary to a GitHub release, and call it a day.

Don't. Releases done by hand drift. Six months in you'll be missing a Windows build, your checksums won't match, and the install instructions in your README will reference a binary that doesn't exist on Linux/arm64. Use GoReleaser.

GoReleaser (goreleaser.com) is a release automation tool for Go. You write a .goreleaser.yaml, you run goreleaser release, and it produces cross-compiled binaries, archives, checksums, a GitHub release with a generated changelog, and optionally Homebrew/Scoop/Docker artifacts. It's the tool 80% of public Go CLIs use.

GoReleaser release pipeline: a git tag triggers goreleaser release, which fans out to cross-compiled binaries, archives, checksums, a GitHub release, a Homebrew tap PR, and Docker images

A minimal .goreleaser.yaml for a single-binary CLI:

YAML .goreleaser.yaml
version: 2

before:
  hooks:
    - go mod tidy

builds:
  - id: mytool
    main: ./
    binary: mytool
    env:
      - CGO_ENABLED=0
    goos:
      - linux
      - darwin
      - windows
    goarch:
      - amd64
      - arm64
    ldflags:
      - -s -w
      - -X github.com/you/mytool/cmd.version={{.Version}}
      - -X github.com/you/mytool/cmd.commit={{.Commit}}
      - -X github.com/you/mytool/cmd.date={{.Date}}

archives:
  - id: mytool
    name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
    formats: [ tar.gz ]
    format_overrides:
      - goos: windows
        formats: [ zip ]

checksum:
  name_template: "checksums.txt"
  algorithm: sha256

snapshot:
  version_template: "{{ incpatch .Version }}-snapshot"

changelog:
  sort: asc
  filters:
    exclude:
      - "^docs:"
      - "^test:"
      - "^chore:"

What that buys you, one goreleaser release away:

  • Six binaries: linux/amd64, linux/arm64, darwin/amd64, darwin/arm64, windows/amd64, windows/arm64.
  • Each one packaged as a tar.gz (or zip on Windows) with a predictable name.
  • A checksums.txt with SHA-256 for every artifact.
  • A GitHub release with a changelog auto-generated from commit messages, excluding docs:, test:, chore:.
  • The cmd.version / cmd.commit / cmd.date vars filled in via -ldflags, so mytool version reports the real release.
  • The -s -w flags strip the binary, dropping size meaningfully.

CGO_ENABLED=0 is the small detail that matters. It produces a fully static binary. Without it, your Linux build links against glibc and won't run on Alpine. With it, your binary runs anywhere the kernel exists.

Local snapshots

Before tagging, you can run a dry-run with --snapshot:

Bash snapshot release
goreleaser release --snapshot --clean

This builds everything into ./dist/ without pushing to GitHub. Inspect the artifacts, run the binary, sanity-check the changelog. Then tag and run for real.

Releases from CI

In production you tag a commit, GitHub Actions runs GoReleaser, and the release appears within a minute or two. A minimal workflow:

YAML .github/workflows/release.yml
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: ${{ secrets.GITHUB_TOKEN }}

fetch-depth: 0 matters: GoReleaser uses git history for the changelog and the version, so a shallow clone confuses it.

Homebrew, Scoop, and Docker

Once binaries are good, you can add distribution channels in the same config:

YAML .goreleaser.yaml - distribution
brews:
  - name: mytool
    repository:
      owner: you
      name: homebrew-tap
    homepage: "https://github.com/you/mytool"
    description: "A small tool for doing the thing"
    license: "MIT"
    test: |
      system "#{bin}/mytool version"

scoops:
  - name: mytool
    repository:
      owner: you
      name: scoop-bucket
    homepage: "https://github.com/you/mytool"

dockers:
  - image_templates:
      - "ghcr.io/you/mytool:{{ .Version }}-amd64"
    use: buildx
    build_flag_templates:
      - "--platform=linux/amd64"
    goarch: amd64
  - image_templates:
      - "ghcr.io/you/mytool:{{ .Version }}-arm64"
    use: buildx
    build_flag_templates:
      - "--platform=linux/arm64"
    goarch: arm64

docker_manifests:
  - name_template: "ghcr.io/you/mytool:{{ .Version }}"
    image_templates:
      - "ghcr.io/you/mytool:{{ .Version }}-amd64"
      - "ghcr.io/you/mytool:{{ .Version }}-arm64"
  - name_template: "ghcr.io/you/mytool:latest"
    image_templates:
      - "ghcr.io/you/mytool:{{ .Version }}-amd64"
      - "ghcr.io/you/mytool:{{ .Version }}-arm64"

For Homebrew you need a separate repo (homebrew-tap by convention) and a personal access token with permission to push to it. GoReleaser opens a PR (or pushes directly) to update the formula with the new version and SHA. Users brew install you/tap/mytool and they're done.

For Docker you'll typically also set up buildx in your CI workflow. The multi-arch manifest pattern above means docker pull ghcr.io/you/mytool:1.2.0 works on both Intel and ARM hosts.

Signing and supply chain

Once your tool gets used by other people, you'll want to think about supply chain. cosign-based signing is one extra block:

YAML .goreleaser.yaml - signing
signs:
  - cmd: cosign
    args:
      - "sign-blob"
      - "--yes"
      - "--output-signature=${signature}"
      - "${artifact}"
    artifacts: checksum

That signs checksums.txt with cosign. Combined with the SHA-256s, users can verify both that the binary they downloaded matches the published checksum, and that the checksum file itself was signed by you (or by your CI's OIDC identity, in the keyless flow).

This is overkill for personal tools. It's table stakes for anything used by a company.

A small note on the boring parts

A lot of what makes a CLI feel good is invisible. Help text that uses real sentences. A --version that reports a commit. Exit codes that scripts can trust. Errors that go to stderr while data goes to stdout. A goreleaser release that produces every artifact your users expect on the first try.

Most of those are five-line decisions you make once. The cost is small. The compounding payoff (over the months you'll spend supporting the tool, the people you'll onboard onto it, the bug reports you won't get) is large.

Go is unusually well-suited to this whole stack. The standard library covers the bits you'd otherwise build, Cobra is the obvious choice for command trees, Viper handles config layering when you outgrow stdlib, and GoReleaser turns the act of shipping into a single tagged commit. Pick the pieces that match your tool's size, wire them in the order above, and you'll end up with something people install, trust, and quietly forget is yours: which, for a CLI tool, is the highest compliment there is.