From f9d7984e5e061142dd5f98e88c012535d17b2c23 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 01:12:56 +0000 Subject: [PATCH] feat: add version command and improve --version output Agent-Logs-Url: https://github.com/score-spec/score-k8s/sessions/121099cf-86ba-4cca-a966-aa8b08ba8154 Co-authored-by: mathieu-benoit <11720844+mathieu-benoit@users.noreply.github.com> --- .github/workflows/release.yaml | 2 + .goreleaser.yaml | 2 + Dockerfile | 9 ++- internal/command/root.go | 7 +- internal/command/root_test.go | 2 +- internal/command/version.go | 139 +++++++++++++++++++++++++++++++++ internal/version/version.go | 26 +----- 7 files changed, 160 insertions(+), 27 deletions(-) create mode 100644 internal/command/version.go diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 7dd2206..8e501b0 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -50,6 +50,8 @@ jobs: set-meta-annotations: true build-args: | "VERSION=${{ github.ref_name }}" + "GIT_COMMIT=${{ github.sha }}" + "BUILD_DATE=${{ github.event.head_commit.timestamp }}" meta-images: | ghcr.io/score-spec/score-k8s scorespec/score-k8s diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 4212a31..71d20df 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -9,6 +9,8 @@ builds: main: ./cmd/score-k8s ldflags: - -X github.com/score-spec/score-k8s/internal/version.Version={{ .Version }} + - -X github.com/score-spec/score-k8s/internal/version.GitCommit={{ .Commit }} + - -X github.com/score-spec/score-k8s/internal/version.BuildDate={{ .Date }} env: - CGO_ENABLED=0 targets: diff --git a/Dockerfile b/Dockerfile index be747cf..ac315bf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,8 @@ FROM --platform=$BUILDPLATFORM dhi.io/golang:1.26.2-alpine3.23-dev@sha256:0d916bcd9ca2060863389d96c8ea686d72e03ccc48ebf498be2150f21f720999 AS builder ARG VERSION=0.0.0 +ARG GIT_COMMIT=unknown +ARG BUILD_DATE=unknown # Set the current working directory inside the container. WORKDIR /go/src/github.com/score-spec/score-k8s @@ -11,7 +13,12 @@ RUN go mod download # Copy the entire project and build it. COPY . . -RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X github.com/score-spec/score-k8s/internal/version.Version=${VERSION}" -o /usr/local/bin/score-k8s ./cmd/score-k8s +RUN CGO_ENABLED=0 GOOS=linux \ + go build -ldflags="-s -w \ + -X github.com/score-spec/score-k8s/internal/version.Version=${VERSION} \ + -X github.com/score-spec/score-k8s/internal/version.GitCommit=${GIT_COMMIT} \ + -X github.com/score-spec/score-k8s/internal/version.BuildDate=${BUILD_DATE}" \ + -o /usr/local/bin/score-k8s ./cmd/score-k8s # We can use static since we don't rely on any linux libs or state, but we need ca-certificates to connect to https/oci with the init command. FROM dhi.io/static:20251003-alpine3.23@sha256:a08d9a53a4758b4006d56341aa88b1edf583ddebd93e620a32acd5135535573c diff --git a/internal/command/root.go b/internal/command/root.go index 471f882..795e1ef 100644 --- a/internal/command/root.go +++ b/internal/command/root.go @@ -24,8 +24,10 @@ import ( "github.com/score-spec/score-k8s/internal/version" ) +var ScoreImplementationName = "score-k8s" + var rootCmd = &cobra.Command{ - Use: "score-k8s", + Use: ScoreImplementationName, Short: "Score to Kubernetes manifest translator", Long: `Score is a specification for defining environment agnostic configuration for cloud based workloads. This tool produces a file of Kubernetes manifests from the Score specification.`, @@ -50,8 +52,7 @@ This tool produces a file of Kubernetes manifests from the Score specification.` func init() { rootCmd.Version = version.BuildVersionString() - rootCmd.SetVersionTemplate(`{{with .Name}}{{printf "%s " .}}{{end}}{{printf "%s" .Version}} -`) + rootCmd.SetVersionTemplate(`{{with .Name}}{{printf "%s " .}}{{end}}{{printf "%s\n" .Version}}`) rootCmd.PersistentFlags().Bool("quiet", false, "Mute any logging output") rootCmd.PersistentFlags().CountP("verbose", "v", "Increase log verbosity and detail by specifying this flag one or more times") } diff --git a/internal/command/root_test.go b/internal/command/root_test.go index 4a82115..502029b 100644 --- a/internal/command/root_test.go +++ b/internal/command/root_test.go @@ -64,7 +64,7 @@ func executeAndResetCommand(ctx context.Context, cmd *cobra.Command, args []stri func TestRootVersion(t *testing.T) { stdout, stderr, err := executeAndResetCommand(context.Background(), rootCmd, []string{"--version"}) assert.NoError(t, err) - pattern := regexp.MustCompile(`^score-k8s 0.0.0 \(build: \S+, sha: \S+\)\n$`) + pattern := regexp.MustCompile(`^score-k8s 0\.0\.0 \(go\S+ - \S+/\S+\)\ngit commit: \S+\nbuild date: \S+\n$`) assert.Truef(t, pattern.MatchString(stdout), "%s does not match: '%s'", pattern.String(), stdout) assert.Equal(t, "", stderr) } diff --git a/internal/command/version.go b/internal/command/version.go new file mode 100644 index 0000000..106e76c --- /dev/null +++ b/internal/command/version.go @@ -0,0 +1,139 @@ +// Copyright 2024 The Score 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 command + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/score-spec/score-k8s/internal/version" + "github.com/spf13/cobra" +) + +const ( + versionCmdFileNoLogo = "no-logo" + versionCmdFileNoUpdatesCheck = "no-updates-check" + logo = ` + ... ............. + ....... ............. + ......... ............ + ......... ..... + ....... .... .. + .......... ..... ...... + ........ ...... .......... + ..... ..... .......... + .... ........ + ........... ......... + .............. ......... + ................ ...... + ............... .. + ............ + ` +) + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Show the version for " + ScoreImplementationName + " and new version to update if available.", + Args: cobra.NoArgs, + CompletionOptions: cobra.CompletionOptions{ + HiddenDefaultCmd: true, + }, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + + if noLogo, _ := cmd.Flags().GetBool(versionCmdFileNoLogo); !noLogo { + fmt.Println(logo) + } + + fmt.Println(ScoreImplementationName, version.BuildVersionString()) + + if noUpdateCheck, _ := cmd.Flags().GetBool(versionCmdFileNoUpdatesCheck); !noUpdateCheck { + if newer, err := checkForNewerVersion(version.Version); err == nil && newer != "" { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nA newer version is available: %s\nUpdate at: https://github.com/score-spec/%s/releases/tag/%s\n", newer, ScoreImplementationName, newer) + } + } + + return nil + }, +} + +// checkForNewerVersion queries the GitHub releases API and returns the tag name of the latest +// release if it is newer than currentVersion. Returns an empty string if no newer version is found +// or if the current version cannot be parsed. +func checkForNewerVersion(currentVersion string) (string, error) { + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Get("https://api.github.com/repos/score-spec/" + ScoreImplementationName + "/releases/latest") + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("unexpected status from releases API: %s", resp.Status) + } + + var release struct { + TagName string `json:"tag_name"` + } + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return "", err + } + + if isNewerVersion(currentVersion, release.TagName) { + return release.TagName, nil + } + return "", nil +} + +// isNewerVersion reports whether latestVersion is strictly greater than currentVersion. +// Both versions may optionally start with a "v" prefix and are expected to follow semver +// (MAJOR.MINOR.PATCH). Non-numeric or unparseable segments are treated as 0. +func isNewerVersion(currentVersion, latestVersion string) bool { + current := parseSemver(currentVersion) + latest := parseSemver(latestVersion) + for i := range current { + if latest[i] > current[i] { + return true + } + if latest[i] < current[i] { + return false + } + } + return false +} + +// parseSemver splits a version string (with an optional leading "v") into its three numeric +// components [major, minor, patch]. Components that cannot be parsed are treated as 0. +func parseSemver(v string) [3]int { + v = strings.TrimPrefix(v, "v") + parts := strings.SplitN(v, ".", 3) + var out [3]int + for i := 0; i < 3 && i < len(parts); i++ { + n, _ := strconv.Atoi(parts[i]) + out[i] = n + } + return out +} + +func init() { + versionCmd.Flags().Bool(versionCmdFileNoLogo, false, "Do not show the Score logo") + versionCmd.Flags().Bool(versionCmdFileNoUpdatesCheck, false, "Do not check for a new version") + rootCmd.AddCommand(versionCmd) +} diff --git a/internal/version/version.go b/internal/version/version.go index 4b98a85..dc6ee17 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -17,39 +17,21 @@ package version import ( "fmt" "regexp" - "runtime/debug" + "runtime" "strconv" ) var ( Version string = "0.0.0" + GitCommit string = "unknown" + BuildDate string = "unknown" semverPattern = regexp.MustCompile(`^(?:v?)(\d+)(?:\.(\d+))?(?:\.(\d+))?$`) constraintAndSemver = regexp.MustCompile("^(>|>=|=)?" + semverPattern.String()[1:]) ) // BuildVersionString constructs a version string by looking at the build metadata injected at build time. -// This is particularly useful when score-k8s is installed from the go module using go install. func BuildVersionString() string { - versionNumber, buildTime, gitSha, isDirtySuffix := Version, "local", "unknown", "" - if info, ok := debug.ReadBuildInfo(); ok { - for _, setting := range info.Settings { - switch setting.Key { - case "vcs.time": - if setting.Value != "" { - buildTime = setting.Value - } - case "vcs.revision": - if setting.Value != "" { - gitSha = setting.Value - } - case "vcs.modified": - if setting.Value == "true" { - isDirtySuffix = "-dirty" - } - } - } - } - return fmt.Sprintf("%s (build: %s, sha: %s%s)", versionNumber, buildTime, gitSha, isDirtySuffix) + return fmt.Sprintf("%s (%s - %s/%s)\ngit commit: %s\nbuild date: %s", Version, runtime.Version(), runtime.GOOS, runtime.GOARCH, GitCommit, BuildDate) } func semverToI(x string) (int, error) {