Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 196 additions & 10 deletions cmd/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,23 @@ package cmd

import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"runtime/debug"
"strconv"
"strings"
"time"

"golang.org/x/mod/semver"
)

const unknown = "unknown"
const (
unknown = "unknown"
develVersion = "(devel)"
pseudoVersionTimestampLayout = "20060102150405"
)

// var needs to be used instead of const as ldflags is used to fill this
// information in the release process
Expand All @@ -47,11 +60,7 @@ type version struct {

// versionString returns the Full CLI version
func versionString() string {
if kubeBuilderVersion == unknown {
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" {
kubeBuilderVersion = info.Main.Version
}
}
kubeBuilderVersion = getKubebuilderVersion()

return fmt.Sprintf("Version: %#v", version{
kubeBuilderVersion,
Expand All @@ -65,10 +74,187 @@ func versionString() string {

// getKubebuilderVersion returns only the CLI version string
func getKubebuilderVersion() string {
if kubeBuilderVersion == unknown {
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" {
kubeBuilderVersion = info.Main.Version
}
if strings.Contains(kubeBuilderVersion, "dirty") {
return develVersion
}
if shouldResolveVersion(kubeBuilderVersion) {
kubeBuilderVersion = resolveKubebuilderVersion()
}
return kubeBuilderVersion
}

func shouldResolveVersion(v string) bool {
return v == "" || v == unknown || v == develVersion
}

func resolveKubebuilderVersion() string {
if info, ok := debug.ReadBuildInfo(); ok {
if info.Main.Sum == "" {
return develVersion
}
mainVersion := strings.TrimSpace(info.Main.Version)
if mainVersion != "" && mainVersion != develVersion {
return mainVersion
}

if v := pseudoVersionFromGit(info.Main.Path); v != "" {
return v
}
}

if v := pseudoVersionFromGit(""); v != "" {
return v
}

return unknown
}

func pseudoVersionFromGit(modulePath string) string {
repoRoot, err := findRepoRoot()
if err != nil {
return ""
}
return pseudoVersionFromGitDir(modulePath, repoRoot)
}

func pseudoVersionFromGitDir(modulePath, repoRoot string) string {
dirty, err := repoDirty(repoRoot)
if err != nil {
return ""
}
if dirty {
return develVersion
}

commitHash, err := runGitCommand(repoRoot, "rev-parse", "--short=12", "HEAD")
if err != nil || commitHash == "" {
return ""
}

commitTimestamp, err := runGitCommand(repoRoot, "show", "-s", "--format=%ct", "HEAD")
if err != nil || commitTimestamp == "" {
return ""
}
seconds, err := strconv.ParseInt(commitTimestamp, 10, 64)
if err != nil {
return ""
}
timestamp := time.Unix(seconds, 0).UTC().Format(pseudoVersionTimestampLayout)

if tag, err := runGitCommand(repoRoot, "describe", "--tags", "--exact-match"); err == nil {
tag = strings.TrimSpace(tag)
if tag != "" {
return tag
}
}

if baseTag, err := runGitCommand(repoRoot, "describe", "--tags", "--abbrev=0"); err == nil {
baseTag = strings.TrimSpace(baseTag)
if semver.IsValid(baseTag) {
if next := incrementPatch(baseTag); next != "" {
return fmt.Sprintf("%s-0.%s-%s", next, timestamp, commitHash)
}
}
if baseTag != "" {
return baseTag
}
}

major := moduleMajorVersion(modulePath)
return buildDefaultPseudoVersion(major, timestamp, commitHash)
}

func repoDirty(repoRoot string) (bool, error) {
status, err := runGitCommand(repoRoot, "status", "--porcelain", "--untracked-files=no")
if err != nil {
return false, err
}
return status != "", nil
}

func incrementPatch(tag string) string {
trimmed := strings.TrimPrefix(tag, "v")
trimmed = strings.SplitN(trimmed, "-", 2)[0]
parts := strings.Split(trimmed, ".")
if len(parts) < 3 {
return ""
}
major, err := strconv.Atoi(parts[0])
if err != nil {
return ""
}
minor, err := strconv.Atoi(parts[1])
if err != nil {
return ""
}
patch, err := strconv.Atoi(parts[2])
if err != nil {
return ""
}
patch++
return fmt.Sprintf("v%d.%d.%d", major, minor, patch)
}

func buildDefaultPseudoVersion(major int, timestamp, commitHash string) string {
if major < 0 {
major = 0
}
return fmt.Sprintf("v%d.0.0-%s-%s", major, timestamp, commitHash)
}

func moduleMajorVersion(modulePath string) int {
if modulePath == "" {
return 0
}
lastSlash := strings.LastIndex(modulePath, "/v")
if lastSlash == -1 || lastSlash == len(modulePath)-2 {
return 0
}
majorStr := modulePath[lastSlash+2:]
if strings.Contains(majorStr, "/") {
majorStr = majorStr[:strings.Index(majorStr, "/")]
}
major, err := strconv.Atoi(majorStr)
if err != nil {
return 0
}
return major
}

func findRepoRoot() (string, error) {
_, currentFile, _, ok := runtime.Caller(0)
if !ok {
return "", fmt.Errorf("failed to determine caller")
}

if !filepath.IsAbs(currentFile) {
abs, err := filepath.Abs(currentFile)
if err != nil {
return "", fmt.Errorf("getting absolute path: %w", err)
}
currentFile = abs
}

dir := filepath.Dir(currentFile)
for {
if dir == "" || dir == filepath.Dir(dir) {
return "", fmt.Errorf("git repository root not found from %s", currentFile)
}

if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
return dir, nil
}
dir = filepath.Dir(dir)
}
}

func runGitCommand(dir string, args ...string) (string, error) {
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C")
output, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("running git %v: %w", args, err)
}
return strings.TrimSpace(string(output)), nil
}
148 changes: 148 additions & 0 deletions cmd/version_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/*
Copyright 2025 The Kubernetes Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package cmd

import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
)

func TestPseudoVersionFromGitDirExactTag(t *testing.T) {
repo := initGitRepo(t)

if _, err := runGitCommand(repo, "tag", "v1.2.3"); err != nil {
t.Fatalf("tagging repo: %v", err)
}

version := pseudoVersionFromGitDir("example.com/module/v1", repo)
if version != "v1.2.3" {
t.Fatalf("expected tag version, got %q", version)
}
}

func TestPseudoVersionFromGitDirAfterTag(t *testing.T) {
repo := initGitRepo(t)

if _, err := runGitCommand(repo, "tag", "v1.2.3"); err != nil {
t.Fatalf("tagging repo: %v", err)
}
createCommit(t, repo, "second file", "second change")

version := pseudoVersionFromGitDir("example.com/module/v1", repo)
if version == "" {
t.Fatalf("expected pseudo version, got empty string")
}

hash, err := runGitCommand(repo, "rev-parse", "--short=12", "HEAD")
if err != nil {
t.Fatalf("retrieving hash: %v", err)
}
timestampStr, err := runGitCommand(repo, "show", "-s", "--format=%ct", "HEAD")
if err != nil {
t.Fatalf("retrieving timestamp: %v", err)
}
seconds, err := strconv.ParseInt(timestampStr, 10, 64)
if err != nil {
t.Fatalf("parsing timestamp: %v", err)
}
expected := fmt.Sprintf("v1.2.4-0.%s-%s", time.Unix(seconds, 0).UTC().Format(pseudoVersionTimestampLayout), hash)
if version != expected {
t.Fatalf("expected %q, got %q", expected, version)
}
}

func TestPseudoVersionFromGitDirDirty(t *testing.T) {
repo := initGitRepo(t)

if _, err := runGitCommand(repo, "tag", "v1.2.3"); err != nil {
t.Fatalf("tagging repo: %v", err)
}
createCommit(t, repo, "second file", "second change")

targetFile := filepath.Join(repo, "tracked.txt")
if err := os.WriteFile(targetFile, []byte("dirty change\n"), 0o644); err != nil {
t.Fatalf("creating dirty file: %v", err)
}

version := pseudoVersionFromGitDir("example.com/module/v1", repo)
if version != develVersion {
t.Fatalf("expected %q for dirty repo, got %q", develVersion, version)
}
}

func TestPseudoVersionFromGitDirWithoutTag(t *testing.T) {
repo := initGitRepo(t)
version := pseudoVersionFromGitDir("example.com/module/v4", repo)
if !strings.HasPrefix(version, "v4.0.0-") {
t.Fatalf("expected prefix v4.0.0-, got %q", version)
}
}

func TestGetKubebuilderVersionDirtyString(t *testing.T) {
t.Cleanup(func() { kubeBuilderVersion = unknown })
kubeBuilderVersion = "v1.2.3+dirty"
if got := getKubebuilderVersion(); got != develVersion {
t.Fatalf("expected %q, got %q", develVersion, got)
}
}

func initGitRepo(t *testing.T) string {
t.Helper()

dir := t.TempDir()

commands := [][]string{
{"init"},
{"config", "user.email", "dev@kubebuilder.test"},
{"config", "user.name", "Kubebuilder Dev"},
}
for _, args := range commands {
if _, err := runGitCommand(dir, args...); err != nil {
t.Fatalf("initializing repo (%v): %v", args, err)
}
}

createCommit(t, dir, "tracked.txt", "initial")
return dir
}

func createCommit(t *testing.T, repo, file, content string) {
t.Helper()

if err := os.WriteFile(filepath.Join(repo, file), []byte(content+"\n"), 0o644); err != nil {
t.Fatalf("writing file: %v", err)
}
if _, err := runGitCommand(repo, "add", file); err != nil {
t.Fatalf("git add: %v", err)
}
commitEnv := append(os.Environ(),
"GIT_COMMITTER_DATE=2006-01-02T15:04:05Z",
"GIT_AUTHOR_DATE=2006-01-02T15:04:05Z",
)
cmd := exec.Command("git", "commit", "-m", fmt.Sprintf("commit %s", file))
cmd.Dir = repo
cmd.Env = append(commitEnv, "LC_ALL=C", "LANG=C")
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git commit: %v: %s", err, output)
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "Kubebuilder DevContainer",
"image": "golang:1.24",
"image": "golang:1.25",
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {},
"ghcr.io/devcontainers/features/git:1": {}
Expand Down
2 changes: 1 addition & 1 deletion docs/book/src/cronjob-tutorial/testdata/project/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Build the manager binary
FROM golang:1.24 AS builder
FROM golang:1.25 AS builder
ARG TARGETOS
ARG TARGETARCH

Expand Down
Loading
Loading