byob-go-cli

Version via -ldflags -X into a dedicated build package

byob-release.1 concurrencyrelease

Problem: the binary needs to know its own version, commit, and build date at runtime. Options: ldflags-injected package vars, debug.ReadBuildInfo(), a hand-maintained version.go constant, a generated file. Each has gaps — constants go stale, generated files churn, BuildInfo is empty or partial under go build from a dirty tree or from an archive extraction.

Idea: a small package internal/<bin>cmd/build exposes three package-scope vars with sentinel defaults (named constants so consumers test against one symbol, not a string literal):

const (
    VersionDev  = "dev"
    CommitNone  = "none"
    DateUnknown = "unknown"
)
var (
    Version = VersionDev
    Commit  = CommitNone
    Date    = DateUnknown
)

Both the Makefile and .goreleaser.yml inject the same ldflags so every build path populates the same three vars:

-ldflags "-X <pkg>/build.Version=$(VERSION)
         -X <pkg>/build.Commit=$(COMMIT)
         -X <pkg>/build.Date=$(DATE)"

debug.ReadBuildInfo() is a secondary source inside the build package: when Version == VersionDev (no ldflags), an Info() accessor consults BuildInfo for VCS revision + dirty flag so plain go install github.com/x/y@latest still produces a usable version output.

Thread-safety: Info() must not mutate the package vars from its read path. Two parallel test packages calling build.Info() concurrently would race. Compute the augmented values into locals and return them; wrap in sync.OnceValue (single-return — the closure never errors) so the debug.ReadBuildInfo scan runs at most once per process.

Tradeoffs: ldflags strings are verbose and easy to typo — hence the shared Makefile variable both the local build target and the GHA release workflow reference. A single source of truth for the flag string prevents drift between dev and release builds.

Design

// internal/mytoolcmd/build/build.go
package build

import (
    "runtime/debug"
    "sync"
)

const (
    VersionDev  = "dev"
    CommitNone  = "none"
    DateUnknown = "unknown"
)

var (
    Version = VersionDev
    Commit  = CommitNone
    Date    = DateUnknown
)

type BuildInfo struct {
    Version, Commit, Date string
}

// Info returns the version/commit/date resolved from either ldflags
// (authoritative) or debug.ReadBuildInfo() (fallback for go install /
// go run). Safe for concurrent calls. sync.OnceValue (single-return —
// the closure never errors) so the BuildInfo scan runs at most once.
var Info = sync.OnceValue(func() BuildInfo {
    v, c, d := Version, Commit, Date
    if v == VersionDev {
        if info, ok := debug.ReadBuildInfo(); ok {
            for _, s := range info.Settings {
                switch s.Key {
                case "vcs.revision":
                    if c == CommitNone { c = s.Value }
                case "vcs.time":
                    if d == DateUnknown { d = s.Value }
                case "vcs.modified":
                    if s.Value == "true" { c += "-dirty" }
                }
            }
        }
    }
    return BuildInfo{Version: v, Commit: c, Date: d}
})

Callers read info := build.Info() and use info.Version, info.Commit, info.Date directly — no destructure helper needed.

Makefile exports LDFLAGS once and both build and release targets consume it; .goreleaser.yml uses {{ .Env.LDFLAGS }} or its own template to produce the same -X entries. No init() in this package — keep go test -run -short hermetic and avoid ordering surprises.