From ad6d5f24ac5c973402473464834f10d80c5f2c8e Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 11 Nov 2026 00:29:48 +0000 Subject: [PATCH] Report the Go build info version when linker flags set none (closes #422) A `go install` of a tag sets no linker flags, so the binary reported `dev`. Version, commit and commit date that the linker flags leave unset now come from the build info Go records. Go records a pseudo-version for an untagged commit and a `+dirty` suffix for a modified checkout; `IsDevVersion` now counts both as development builds, using `golang.org/x/mod`. Model: opus-5-5 --- README.md | 35 ++++++--- go.mod | 1 + internal/cli/entry.go | 3 + internal/globals/globals.go | 68 ++++++++++++++++-- internal/globals/globals_test.go | 117 +++++++++++++++++++++++++++++-- 5 files changed, 205 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index be62a24..bb2de0c 100644 --- a/README.md +++ b/README.md @@ -1041,20 +1041,37 @@ is `git describe --tags --always --dirty`, which `script/version` runs for the A `docker build .` of a clone, with no build arguments, runs the same `git describe` (without `--dirty`) on the `.git` in its build context, so it stamps the same value for a clean commit; the build fails if the context carries -`.git` and no version, commit or commit date comes out. A binary built without -git metadata reports `dev`, or `unknown` when `script/docker` or -`script/cibuild` built it outside a git checkout. +`.git` and no version, commit or commit date comes out. `make` outside a git +checkout and `docker build .` without `.git` both stamp `dev`. `script/docker` +and `script/cibuild` stamp `unknown` when they run outside a git checkout. + +A binary built by plain `go install` or `go build` carries no stamped version. +It reports the version Go recorded in it instead: + +- a `go install` of the tag `v1.0.0` → `v1.0.0`. +- a `go build` of an untagged commit → a Go pseudo-version, such as + `v1.0.1-0.20261110231226-0123456789ab`. +- a build where Go recorded no version → `dev`. `goreleaser` stamps a release binary with the tag minus its leading `v`, so the tag `v1.0.0` produces `vaultik 1.0.0`, matching the archive name `vaultik_1.0.0_linux_amd64.tar.gz`. `goreleaser --snapshot` stamps `dev-<12 chars of the commit sha>` rather than inventing the next patch number. -`vaultik version` calls a build a development build when its version is `dev`, -`unknown`, `dev-`, the short commit sha or `--g`, with -or without `-dirty`; only a plain tag, such as `v1.0.0` or `1.0.0`, is a -release. If `script/version` cannot be run at all, `make` stops with an error -instead of building an unversioned binary, and a binary that somehow carries an -empty version string still reports itself as a development build. + +`vaultik version` calls a build a development build when its version is one of +these: + +- `dev` or `unknown`. +- `dev-`. +- the short commit sha. +- `--g`. +- a Go pseudo-version. +- any version with a `-dirty` or `+dirty` suffix. + +Only a plain tag, such as `v1.0.0` or `1.0.0`, is a release. If `script/version` +cannot be run at all, `make` stops with an error instead of building an +unversioned binary, and a binary that somehow carries an empty version string +still reports itself as a development build. ### cutting a release diff --git a/go.mod b/go.mod index 3acb01e..8942241 100644 --- a/go.mod +++ b/go.mod @@ -24,6 +24,7 @@ require ( github.com/stretchr/testify v1.11.1 go.uber.org/dig v1.19.0 go.uber.org/fx v1.24.0 + golang.org/x/mod v0.29.0 golang.org/x/sync v0.18.0 golang.org/x/sys v0.38.0 golang.org/x/term v0.37.0 diff --git a/internal/cli/entry.go b/internal/cli/entry.go index e9d3a6c..16b421c 100644 --- a/internal/cli/entry.go +++ b/internal/cli/entry.go @@ -21,6 +21,8 @@ const shortCommitLen = 12 const exitCodeInterrupted = 130 // Entry is the main entry point for the CLI application. +// It first calls globals.UseBuildInfo, which fills in the version, +// commit and commit date that the linker flags did not set. // It prints the startup banner to stderr (unless a banner-suppressing // flag is present in os.Args — see bannerSuppressedInArgs), executes the // root cobra command, and routes any returned error through the @@ -33,6 +35,7 @@ const exitCodeInterrupted = 130 // deferred profile writers run before the process ends. See run in // cmd/vaultik/main.go. func Entry() int { + globals.UseBuildInfo() emitStartupBanner(os.Args[1:], os.Stderr) rootCmd := NewRootCommand() diff --git a/internal/globals/globals.go b/internal/globals/globals.go index 984b8a0..8e43c02 100644 --- a/internal/globals/globals.go +++ b/internal/globals/globals.go @@ -1,19 +1,24 @@ // Package globals holds application-wide metadata (name, version, -// commit) that is populated at build time via linker flags. +// commit) that is populated at build time via linker flags, or from the +// build info Go records in the binary when the linker flags set none. package globals import ( "regexp" + "runtime/debug" "strings" "time" + + "golang.org/x/mod/module" ) // Appname is the application name, populated from main(). var Appname = "vaultik" //nolint:gochecknoglobals // set via -ldflags at build time // DevVersion is the version a binary reports when it was built without -// git metadata: script/version emits it outside a git checkout, and an -// unstamped `go build` keeps it. goreleaser's snapshot template stamps +// git metadata: script/version emits it outside a git checkout, and a +// binary keeps it when neither its linker flags nor its Go build info +// name a version. goreleaser's snapshot template stamps // it followed by "-" and the commit it was built from. It is // deliberately not a number. const DevVersion = "dev" @@ -32,6 +37,54 @@ var Commit = Unknown //nolint:gochecknoglobals // set via -ldflags at build time // CommitDate is the ISO-8601 date of the commit, populated from main(). var CommitDate = Unknown //nolint:gochecknoglobals // set via -ldflags at build time +// develVersion is the main module version Go records when it cannot +// tell which version it built. +const develVersion = "(devel)" + +// UseBuildInfo applies FromBuildInfo to Version, Commit and CommitDate, +// with the build info of the running binary. +func UseBuildInfo() { + info, ok := debug.ReadBuildInfo() + if !ok { + return + } + + Version, Commit, CommitDate = FromBuildInfo( + info, Version, Commit, CommitDate) +} + +// FromBuildInfo returns version, commit and commitDate. Each one that +// still holds its default ("dev" or empty for the version, "unknown" +// for the others) is replaced by the value Go recorded in info. A +// `go install` of a tag sets no linker flags, but Go records the tag +// as the main module's version. Go records the commit and its time +// only when it builds from a git checkout. The time is cut to a date, +// as the Makefile stamps it. +func FromBuildInfo( + info *debug.BuildInfo, version, commit, commitDate string, +) (string, string, string) { + if (version == "" || version == DevVersion) && + info.Main.Version != "" && info.Main.Version != develVersion { + version = info.Main.Version + } + + for _, s := range info.Settings { + switch s.Key { + case "vcs.revision": + if commit == Unknown { + commit = s.Value + } + case "vcs.time": + t, err := time.Parse(time.RFC3339, s.Value) + if commitDate == Unknown && err == nil { + commitDate = t.UTC().Format(time.DateOnly) + } + } + } + + return version, commit, commitDate +} + // Author identifies the upstream author of vaultik. const Author = "Jeffrey Paul " @@ -68,8 +121,10 @@ func New() (*Globals, error) { // a release. "dev" and goreleaser's snapshot "dev-" count, and so // does what `git describe --tags --always --dirty` gives a make or // docker build of an untagged commit: the bare short commit, or -// tag-N-gHASH on a commit after a tag. Any version ending in "-dirty" -// counts, a modified checkout of a tag ("v1.0.0-dirty") included. +// tag-N-gHASH on a commit after a tag. A Go pseudo-version counts: Go +// records one for a build of an untagged commit without linker flags. +// Any version ending in "-dirty" or "+dirty" counts, a modified +// checkout of a tag ("v1.0.0-dirty", "v1.0.0+dirty") included. // A plain tag such as "v1.0.0" or "1.0.0" is a release. // // The empty string counts too. Nothing that knows its version reports @@ -82,7 +137,8 @@ func New() (*Globals, error) { func IsDevVersion(v string) bool { if v == "" || v == Unknown || v == DevVersion || strings.HasPrefix(v, DevVersion+"-") || - strings.HasSuffix(v, "-dirty") { + strings.HasSuffix(v, "-dirty") || strings.HasSuffix(v, "+dirty") || + module.IsPseudoVersion(v) { return true } diff --git a/internal/globals/globals_test.go b/internal/globals/globals_test.go index fd05e4a..5622572 100644 --- a/internal/globals/globals_test.go +++ b/internal/globals/globals_test.go @@ -1,6 +1,7 @@ package globals_test import ( + "runtime/debug" "testing" "sneak.berlin/go/vaultik/internal/globals" @@ -59,12 +60,20 @@ func TestIsDevVersion(t *testing.T) { {"1.0.0-rc.1-12-g877eb2f", true}, // A tagged commit with uncommitted changes is not that tag. {"v1.0.0-dirty", true}, + // What Go records for a build without linker flags: a + // pseudo-version for an untagged commit, "+dirty" for a + // modified checkout. + {"v1.0.0-rc3.0.20261111001226-abcdefabcdef", true}, + {"v0.0.0-20261111001226-abcdefabcdef", true}, + {"v1.0.1-0.20261111001226-abcdefabcdef", true}, + {"v1.0.0+dirty", true}, // What a tagged build produces (goreleaser's .Version strips // the leading "v"; script/version keeps it). {"1.0.0", false}, {"0.1.0", false}, {"1.0.0-rc.1", false}, {"v1.0.0", false}, + {"v1.0.0-rc3", false}, // A release must not be mistaken for a dev build just because // the string happens to contain "dev". {"1.0.0-dev", false}, @@ -86,10 +95,9 @@ func TestIsDevVersion(t *testing.T) { } } -// TestDefaultVersionIsDev pins the linker-flag contract: an unstamped -// binary (no -ldflags at all, which is what `go build ./...` and `go -// install` produce) must report itself as a development build rather -// than as some default release number. +// TestDefaultVersionIsDev pins the default: a binary whose linker flags +// and build info name no version must report itself as a development +// build rather than as some default release number. func TestDefaultVersionIsDev(t *testing.T) { t.Parallel() @@ -98,3 +106,104 @@ func TestDefaultVersionIsDev(t *testing.T) { globals.DevVersion) } } + +// buildInfo returns build info with the given main module version and, +// when revision is not empty, the commit settings Go records for a +// build from a git checkout. +func buildInfo(version, revision string) *debug.BuildInfo { + info := &debug.BuildInfo{ + Main: debug.Module{Path: "sneak.berlin/go/vaultik", Version: version}, + } + + if revision != "" { + info.Settings = []debug.BuildSetting{ + {Key: "vcs.revision", Value: revision}, + {Key: "vcs.time", Value: "2026-11-10T23:12:26Z"}, + } + } + + return info +} + +// TestFromBuildInfo covers a binary from `go install` of a tag, which +// carries no linker flags: it must report the tag. Values the linker +// flags set are kept. +func TestFromBuildInfo(t *testing.T) { + t.Parallel() + + const ( + tag = "v1.0.0-rc3" + rev = "0123456789abcdef0123456789abcdef01234567" + ) + + cases := []struct { + name string + info *debug.BuildInfo + version, commit, date string + wantVersion, wantCommit string + wantDate string + }{ + { + name: "go install of a tag", + info: buildInfo(tag, ""), + version: globals.DevVersion, + commit: globals.Unknown, + date: globals.Unknown, + wantVersion: tag, + wantCommit: globals.Unknown, + wantDate: globals.Unknown, + }, + { + name: "empty version", + info: buildInfo(tag, ""), + version: "", + commit: globals.Unknown, + date: globals.Unknown, + wantVersion: tag, + wantCommit: globals.Unknown, + wantDate: globals.Unknown, + }, + { + name: "go build in a git checkout", + info: buildInfo(tag, rev), + version: globals.DevVersion, + commit: globals.Unknown, + date: globals.Unknown, + wantVersion: tag, + wantCommit: rev, + wantDate: "2026-11-10", + }, + { + name: "Go could not tell the version", + info: buildInfo("(devel)", ""), + version: globals.DevVersion, + commit: globals.Unknown, + date: globals.Unknown, + wantVersion: globals.DevVersion, + wantCommit: globals.Unknown, + wantDate: globals.Unknown, + }, + { + name: "linker flags set", + info: buildInfo(tag, rev), + version: "v1.0.0-rc2-4-gabcdef1", + commit: "abcdef1", + date: "2026-11-01", + wantVersion: "v1.0.0-rc2-4-gabcdef1", + wantCommit: "abcdef1", + wantDate: "2026-11-01", + }, + } + + for _, tc := range cases { + version, commit, date := globals.FromBuildInfo( + tc.info, tc.version, tc.commit, tc.date) + + if version != tc.wantVersion || commit != tc.wantCommit || + date != tc.wantDate { + t.Errorf("%s: got %q, %q, %q; want %q, %q, %q", tc.name, + version, commit, date, + tc.wantVersion, tc.wantCommit, tc.wantDate) + } + } +}