diff --git a/Taskfile.yml b/Taskfile.yml index 5375a8f..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: @@ -14,6 +28,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..791c6ae 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,8 @@ ldflags = [ "-s" "-w" - "-X main.version=${version}" + "-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..3c83a04 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,66 @@ +// Package version derives a fallback for k8s.io/component-base/version's +// 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 ( + "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()