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.
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
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.
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.
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.
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.
--version to CLI flags.runtime/debug.ReadBuildInfo.info.Main.Version when it is not empty and not (devel).vcs.revision and vcs.modified.go install <module>@vX.Y.Z.