From deb8ebfabccbf2a5b8cba7657d38f2174a65b23d Mon Sep 17 00:00:00 2001 From: Drew Raines Date: Sat, 5 Sep 2026 14:30:18 -0500 Subject: [PATCH 1/3] fix: embed a valid version in nix flake builds k8s.io/component-base/version's gitVersion was never set via ldflags in flake.nix, so `datumctl version` crashed parsing its compiled-in placeholder "v0.0.0-master+$Format:%H$". The prior git-describe fallback was also dead: nix flakes strip .git from the copied source, so it silently produced "dev" instead of a real version. Now the git sha comes from self.dirtyRev/rev (reliable without .git), the last tag from an optional untracked VERSION file staged via `git add -N` (flake sources only see files git knows about), and both feed main.version and the component-base ldflags so the result is always a valid +[-dev] version string. --- Taskfile.yml | 7 +++++++ flake.nix | 39 +++++++++++++++++++++++++++------------ 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/Taskfile.yml b/Taskfile.yml index 5375a8f..8cbdc5e 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -14,6 +14,13 @@ tasks: nix-build: desc: Build the project with Nix cmds: + # `nix build` only sees files git knows about (even untracked ones must be + # staged), so drop the last tag in a VERSION file and stage it with + # intent-to-add; flake.nix reads it to embed + in the + # binary. Cleaned up afterwards so it's never actually committed. + - defer: git reset -- VERSION && rm -f VERSION + - git describe --tags --abbrev=0 > VERSION + - git add -N VERSION - nix build nix-dev: diff --git a/flake.nix b/flake.nix index c8e47e1..5b84b35 100644 --- a/flake.nix +++ b/flake.nix @@ -18,23 +18,36 @@ inherit system; }; - # Get version from git, fallback to "dev" if not in a git repo - version = - if (builtins.pathExists ./.git) - then builtins.replaceStrings ["\n"] [""] (builtins.readFile ( - pkgs.runCommand "get-version" {} '' - cd ${./.} - ${pkgs.git}/bin/git describe --tags --always --dirty 2>/dev/null > $out || echo "dev" > $out - '' - )) - else "dev"; + # Nix flakes always strip .git from the copied source (self/./. here has + # no .git even though the real checkout does), so `self.rev`/`dirtyRev` + # -- populated by the flake's git-fetcher from outside that copy -- are + # the only reliable way to get the commit sha. There's no equivalent + # flake attribute for "nearest git tag", so that comes from an optional + # untracked VERSION file (`git describe --tags --abbrev=0 > VERSION`, + # not committed) which -- being an ordinary file -- does survive the + # source copy; absent that file this falls back to a valid placeholder. + # Either way the result must be semver-shaped ("vX.Y.Z[+meta]"), since + # k8s.io/component-base/version (used by `datumctl version`) parses it + # strictly and a bare "dev"/"unknown" would fail that parse. + # self.dirtyRev/dirtyShortRev append a literal "-dirty" suffix when the + # working tree has uncommitted changes; swap it for "-dev" everywhere. + markDev = builtins.replaceStrings [ "-dirty" ] [ "-dev" ]; + gitCommit = markDev (self.dirtyRev or self.rev or "unknown"); + gitSha = markDev (self.dirtyShortRev or self.shortRev or "unknown"); + + lastTag = + if (builtins.pathExists ./VERSION) + then builtins.replaceStrings ["\n"] [""] (builtins.readFile ./VERSION) + else "v0.0.0"; + + gitVersion = "${lastTag}+${gitSha}"; in { packages = { default = (pkgs.buildGoModule.override { go = pkgs.go_1_26; }) { pname = "datumctl"; - inherit version; + version = gitVersion; src = ./.; @@ -50,7 +63,9 @@ ldflags = [ "-s" "-w" - "-X main.version=${version}" + "-X main.version=${gitVersion}" + "-X k8s.io/component-base/version.gitVersion=${gitVersion}" + "-X k8s.io/component-base/version.gitCommit=${gitCommit}" "-extldflags=-static" ]; From 0c63fc8f3dfdba862a806fdcc7aa0514feb4f5e7 Mon Sep 17 00:00:00 2001 From: Drew Raines Date: Sun, 6 Sep 2026 15:07:24 -0500 Subject: [PATCH 2/3] fix: give datumctl a sensible version outside nix/release builds too k8s.io/component-base/version's GitVersion is only real when ldflags set it (goreleaser or the nix flake); a plain `go build`/`go run` in local dev still left it at the unparseable compiled-in placeholder, which the plugin compatibility check and update checker both read as a version. Add internal/version.ApplyFallback, called at startup, which detects the placeholder and derives a valid v0.0.0+[-dev] version from the VCS info Go's toolchain already stamps into the binary automatically. It's a no-op whenever ldflags already provided a real version. Also drop the flake's `-X main.version=...` ldflag, which targeted a variable that doesn't exist in package main. --- flake.nix | 1 - internal/version/version.go | 60 ++++++++++++++++++++++++++++++++ internal/version/version_test.go | 58 ++++++++++++++++++++++++++++++ main.go | 3 ++ 4 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 internal/version/version.go create mode 100644 internal/version/version_test.go diff --git a/flake.nix b/flake.nix index 5b84b35..791c6ae 100644 --- a/flake.nix +++ b/flake.nix @@ -63,7 +63,6 @@ ldflags = [ "-s" "-w" - "-X main.version=${gitVersion}" "-X k8s.io/component-base/version.gitVersion=${gitVersion}" "-X k8s.io/component-base/version.gitCommit=${gitCommit}" "-extldflags=-static" diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..1e3c0ea --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,60 @@ +// Package version derives a fallback for k8s.io/component-base/version's +// GitVersion when datumctl is built without the ldflags that release and nix +// builds inject (e.g. a plain `go build`/`go run` during local development). +// Go's toolchain stamps VCS info into the binary automatically from the +// module's git checkout, so this uses that instead of leaving +// component-base's compiled-in placeholder, which fails to parse in places +// that expect a real semantic version (plugin compatibility checks, the +// update checker). +package version + +import ( + "runtime/debug" + + componentversion "k8s.io/component-base/version" +) + +// placeholder is k8s.io/component-base/version's compiled-in default, +// present whenever no ldflags have set a real GitVersion. +const placeholder = "v0.0.0-master+$Format:%H$" + +// ApplyFallback installs a fallback GitVersion derived from the binary's +// embedded VCS info, unless ldflags have already set a real one or no VCS +// info was stamped into the binary (e.g. building outside a git checkout). +func ApplyFallback() { + if componentversion.Get().GitVersion != placeholder { + return + } + info, ok := debug.ReadBuildInfo() + if !ok { + return + } + if fallback, ok := fallbackFromBuildInfo(info); ok { + _ = componentversion.SetDynamicVersion(fallback) + } +} + +func fallbackFromBuildInfo(info *debug.BuildInfo) (string, bool) { + var revision string + var dirty bool + for _, setting := range info.Settings { + switch setting.Key { + case "vcs.revision": + revision = setting.Value + case "vcs.modified": + dirty = setting.Value == "true" + } + } + if revision == "" { + return "", false + } + if len(revision) > 8 { + revision = revision[:8] + } + + fallback := "v0.0.0+" + revision + if dirty { + fallback += "-dev" + } + return fallback, true +} diff --git a/internal/version/version_test.go b/internal/version/version_test.go new file mode 100644 index 0000000..d99c560 --- /dev/null +++ b/internal/version/version_test.go @@ -0,0 +1,58 @@ +package version + +import ( + "runtime/debug" + "testing" +) + +func TestFallbackFromBuildInfo(t *testing.T) { + cases := []struct { + name string + settings []debug.BuildSetting + want string + wantOK bool + }{ + { + name: "clean checkout", + settings: []debug.BuildSetting{ + {Key: "vcs.revision", Value: "931f022eb016a57fcc8ee63e328e9ba12ded6aba"}, + {Key: "vcs.modified", Value: "false"}, + }, + want: "v0.0.0+931f022e", + wantOK: true, + }, + { + name: "dirty checkout", + settings: []debug.BuildSetting{ + {Key: "vcs.revision", Value: "931f022eb016a57fcc8ee63e328e9ba12ded6aba"}, + {Key: "vcs.modified", Value: "true"}, + }, + want: "v0.0.0+931f022e-dev", + wantOK: true, + }, + { + name: "short revision left untruncated", + settings: []debug.BuildSetting{ + {Key: "vcs.revision", Value: "abc123"}, + }, + want: "v0.0.0+abc123", + wantOK: true, + }, + { + name: "no vcs info", + settings: nil, + wantOK: false, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, ok := fallbackFromBuildInfo(&debug.BuildInfo{Settings: tc.settings}) + if ok != tc.wantOK { + t.Fatalf("ok = %v, want %v", ok, tc.wantOK) + } + if got != tc.want { + t.Fatalf("fallback = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/main.go b/main.go index 37e0591..e5590e5 100644 --- a/main.go +++ b/main.go @@ -13,6 +13,7 @@ import ( "github.com/spf13/cobra" "go.datum.net/datumctl/internal/cmd" customerrors "go.datum.net/datumctl/internal/errors" + datumversion "go.datum.net/datumctl/internal/version" "go.miloapis.com/service-catalog/pkg/activation" "k8s.io/component-base/cli" "k8s.io/component-base/logs" @@ -21,6 +22,8 @@ import ( ) func main() { + datumversion.ApplyFallback() + logs.GlogSetter(kubectlcmd.GetLogVerbosity(os.Args)) rootCmd := cmd.RootCmd() From 5e1a5f8bced59f135b4cc24d30b984ebf0dab8e3 Mon Sep 17 00:00:00 2001 From: Drew Raines Date: Sun, 6 Sep 2026 15:22:42 -0500 Subject: [PATCH 3/3] fix: give local go builds the real last-tag version too internal/version.ApplyFallback's runtime patch can only ever produce "v0.0.0+": component-base's SetDynamicVersion rejects any version whose major/minor/patch don't match the compiled-in placeholder's (0.0.0), and Go's automatic VCS stamping has no notion of the nearest tag anyway. So a plain `go build`/`go run` always showed v0.0.0 even when a real release tag was available. Add `task build`, which embeds the real +[-dev] version via ldflags at compile time (like nix/goreleaser already do), so local development builds get a proper version instead of the v0.0.0 fallback. Claude-Session: https://claude.ai/code/session_01PKKmeZhi8VV2EcvknMV9Qv --- Taskfile.yml | 14 ++++++++++++++ internal/version/version.go | 20 +++++++++++++------- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/Taskfile.yml b/Taskfile.yml index 8cbdc5e..ad35f1d 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -6,6 +6,20 @@ tasks: cmds: - task --list + build: + desc: Build datumctl locally with go, embedding a real + version + vars: + LAST_TAG: + sh: git describe --tags --abbrev=0 2>/dev/null || echo v0.0.0 + GIT_SHA: + sh: git rev-parse --short=8 HEAD + GIT_COMMIT: + sh: git rev-parse HEAD + DIRTY_SUFFIX: + sh: (git diff --quiet && git diff --cached --quiet) || echo -dev + cmds: + - go build -ldflags "-X k8s.io/component-base/version.gitVersion={{.LAST_TAG}}+{{.GIT_SHA}}{{.DIRTY_SUFFIX}} -X k8s.io/component-base/version.gitCommit={{.GIT_COMMIT}}" -o datumctl . + nix-update-hash: desc: Automatically update the vendorHash in flake.nix after Go dependency changes cmds: diff --git a/internal/version/version.go b/internal/version/version.go index 1e3c0ea..3c83a04 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -1,11 +1,17 @@ // Package version derives a fallback for k8s.io/component-base/version's -// GitVersion when datumctl is built without the ldflags that release and nix -// builds inject (e.g. a plain `go build`/`go run` during local development). -// Go's toolchain stamps VCS info into the binary automatically from the -// module's git checkout, so this uses that instead of leaving -// component-base's compiled-in placeholder, which fails to parse in places -// that expect a real semantic version (plugin compatibility checks, the -// update checker). +// GitVersion when datumctl is built without the ldflags that release, nix, +// and `task build` all inject (e.g. a bare `go run .` or `go test`). Go's +// toolchain stamps VCS info into the binary automatically from the module's +// git checkout, so this uses that instead of leaving component-base's +// compiled-in placeholder, which fails to parse in places that expect a real +// semantic version (plugin compatibility checks, the update checker). +// +// The fallback can only ever be "v0.0.0+[-dev]", never a real tag: +// component-base's SetDynamicVersion rejects any version whose major/minor/ +// patch don't match the compiled-in placeholder's (0.0.0), and Go's VCS +// stamping has no notion of the nearest tag anyway. Getting the real last +// tag requires setting GitVersion via ldflags at compile time instead, which +// is what `task build` (and nix/goreleaser) do. package version import (