diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 2a608e28..9fedd2ba 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-compose scorespec/score-compose diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 085dbc11..90afb750 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -9,6 +9,8 @@ builds: main: ./cmd/score-compose ldflags: - -X github.com/score-spec/score-compose/internal/version.Version={{ .Version }} + - -X github.com/score-spec/score-compose/internal/version.GitCommit={{ .Commit }} + - -X github.com/score-spec/score-compose/internal/version.BuildDate={{ .Date }} env: - CGO_ENABLED=0 targets: diff --git a/Dockerfile b/Dockerfile index fb542e4d..b80f69c6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,8 @@ FROM 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-compose @@ -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-compose/internal/version.Version=${VERSION}" -o /usr/local/bin/score-compose ./cmd/score-compose +RUN CGO_ENABLED=0 GOOS=linux \ + go build -ldflags="-s -w \ + -X github.com/score-spec/score-compose/internal/version.Version=${VERSION} \ + -X github.com/score-spec/score-compose/internal/version.GitCommit=${GIT_COMMIT} \ + -X github.com/score-spec/score-compose/internal/version.BuildDate=${BUILD_DATE}" \ + -o /usr/local/bin/score-compose ./cmd/score-compose # 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 94f57109..ed918cc4 100644 --- a/internal/command/root.go +++ b/internal/command/root.go @@ -24,9 +24,11 @@ import ( "github.com/score-spec/score-compose/internal/version" ) +var ScoreImplementationName = "score-compose" + var ( rootCmd = &cobra.Command{ - Use: "score-compose", + Use: ScoreImplementationName, Short: "SCORE to docker-compose translator", Long: `SCORE is a specification for defining environment agnostic configuration for cloud based workloads. This tool produces a docker-compose configuration file from the SCORE specification. diff --git a/internal/command/root_test.go b/internal/command/root_test.go index 80bc5644..f4ed7234 100644 --- a/internal/command/root_test.go +++ b/internal/command/root_test.go @@ -79,6 +79,7 @@ Available Commands: init Initialise a new score-compose project with local state directory and score file provisioners Subcommands related to provisioners resources Subcommands related to provisioned resources + version Show the version for score-compose and new version to update if available. Flags: -h, --help help for score-compose @@ -94,7 +95,7 @@ Use "score-compose [command] --help" for more information about a command. func TestRootVersion(t *testing.T) { stdout, stderr, err := executeAndResetCommand(context.Background(), rootCmd, []string{"--version"}) assert.NoError(t, err) - pattern := regexp.MustCompile(`^score-compose 0.0.0 \(build: \S+, sha: \S+\)\n$`) + pattern := regexp.MustCompile(`^score-compose 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 00000000..1b109ba2 --- /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-compose/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 27a1e296..eef2906e 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -10,35 +10,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-compose is stilled 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": - buildTime = setting.Value - case "vcs.revision": - 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) {