Go CLI Version Flag

Goal

For a Go command-line tool, add a --version flag that works well when users install from a Git tag:

go install example.com/tools/hello-world@v1.2.3
hello-world --version

Expected output:

hello-world v1.2.3

The cleanest approach is to read Go's embedded build info with runtime/debug.ReadBuildInfo. When a user installs a command with go install <module>@<version>, Go records that resolved module version in the binary.

Tagging

For a single-module repository, tag releases normally:

git tag -a v1.2.3 -m "v1.2.3"
git push origin v1.2.3

For a monorepo where the module lives in a subdirectory, prefix the Git tag with the module directory:

git tag -a hello-world/v1.2.3 -m "hello-world/v1.2.3"
git push origin hello-world/v1.2.3

Consumers still install with the plain semantic version:

go install example.com/tools/hello-world@v1.2.3

Implementation

Add a version helper:

package main

import "runtime/debug"

const develVersion = "(devel)"

func versionString() string {
	info, ok := debug.ReadBuildInfo()
	return versionFromBuildInfo(info, ok)
}

func versionFromBuildInfo(info *debug.BuildInfo, ok bool) string {
	if !ok || info == nil {
		return "unknown"
	}
	if info.Main.Version != "" && info.Main.Version != develVersion {
		return info.Main.Version
	}

	settings := buildSettings(info)
	revision := settings["vcs.revision"]
	if revision == "" {
		return develVersion
	}
	if len(revision) > 12 {
		revision = revision[:12]
	}
	version := develVersion + " " + revision
	if settings["vcs.modified"] == "true" {
		version += " modified"
	}
	return version
}

func buildSettings(info *debug.BuildInfo) map[string]string {
	settings := make(map[string]string, len(info.Settings))
	for _, setting := range info.Settings {
		settings[setting.Key] = setting.Value
	}
	return settings
}

Wire it into argument parsing before normal config loading or app startup:

package main

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

func main() {
	var version bool

	flags := flag.NewFlagSet(os.Args[0], flag.ExitOnError)
	flags.BoolVar(&version, "version", false, "print version and exit")
	flags.Parse(os.Args[1:])

	if version {
		fmt.Fprintf(os.Stdout, "hello-world %s\n", versionString())
		return
	}

	// Normal application startup goes here.
}

If the project already has a config parser, make --version return before required config-file validation. Version checks should work from any directory.

Behavior

For a tagged install:

go install example.com/tools/hello-world@v1.2.3
hello-world --version

Output:

hello-world v1.2.3

For a local checkout build:

go build -o ./bin/hello-world .
./bin/hello-world --version

Possible output:

hello-world (devel) abc123def456 modified

(devel) means the binary was built directly from a local source tree rather than installed as a resolved module version. The revision and modified marker come from Go's VCS stamping when Git metadata is available.

Docker and CI Builds

If a Docker build copies only source files and not .git, Go may not have VCS metadata. In that case a local-source build may print only:

hello-world (devel)

That is acceptable for simple local images. If production images need an exact version, pass one explicitly from CI using -ldflags and keep runtime/debug as the default for normal go install usage.

Example override:

var versionOverride string

func versionString() string {
	if versionOverride != "" {
		return versionOverride
	}
	info, ok := debug.ReadBuildInfo()
	return versionFromBuildInfo(info, ok)
}

Build with:

go build -ldflags="-X main.versionOverride=v1.2.3" -o ./bin/hello-world .

Use this only when the normal Go build info path is insufficient.

Tests

Keep the formatter testable by passing build info into a pure helper:

func TestVersionFromBuildInfoUsesModuleVersion(t *testing.T) {
	got := versionFromBuildInfo(&debug.BuildInfo{
		Main: debug.Module{Version: "v1.2.3"},
	}, true)

	if got != "v1.2.3" {
		t.Fatalf("version = %q, want v1.2.3", got)
	}
}

Also test that --version skips required config validation if the command normally needs a config file.

Checklist

  1. Add --version to CLI flags.
  2. Return before config loading or app startup.
  3. Read runtime/debug.ReadBuildInfo.
  4. Prefer info.Main.Version when it is not empty and not (devel).
  5. Fall back to vcs.revision and vcs.modified.
  6. Publish normal SemVer tags for single-module repos.
  7. Publish directory-prefixed tags for subdirectory modules in monorepos.
  8. Install with go install <module>@vX.Y.Z.