From ebc44f0e3a5e02070923ca9bfd12675a3298a899 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20Myyr=C3=A4?= Date: Wed, 19 Aug 2026 13:09:08 +0300 Subject: [PATCH] Include ldflags in builds, have a version command where it's normally found from --- .github/workflows/release.yml | 18 ++++++++++++ .goreleaser.yml | 13 ++++++++- internal/verda-cli/cmd/cmd.go | 46 ++++++++++++++++++++++++------- internal/verda-cli/cmd/version.go | 38 +++++++++++++++++++++++++ pkg/version/version.go | 21 ++++++++++++-- pkg/version/version_test.go | 25 +++++++++++++++++ 6 files changed, 147 insertions(+), 14 deletions(-) create mode 100644 internal/verda-cli/cmd/version.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e2b1896..8f9cc8b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,6 +11,7 @@ on: permissions: contents: write id-token: write + attestations: write jobs: release: @@ -115,6 +116,23 @@ jobs: echo "--- Binary checksums ---" cat "$SUMFILE" + # Shell-scoped VER is not visible to YAML expressions in later steps. + echo "VER=${VER}" >> "$GITHUB_ENV" + + # Provenance covers the same two sets the cosign steps sign, driven off the + # checksum files rather than a dist/ glob so the subject list cannot drift + # from the signed one. Verification needs no .sig/.pem handling: + # gh attestation verify ./verda --repo verda-cloud/verda-cli + - name: Attest archives and packages + uses: actions/attest-build-provenance@v4 + with: + subject-checksums: dist/verda_${{ env.VER }}_SHA256SUMS + + - name: Attest binaries + uses: actions/attest-build-provenance@v4 + with: + subject-checksums: dist/verda_${{ env.VER }}_binary_SHA256SUMS + - name: Sign checksum files with cosign # Keyless signing via GitHub OIDC. The certificate identity will be: # https://github.com/verda-cloud/verda-cli/.github/workflows/release.yml@refs/heads/main diff --git a/.goreleaser.yml b/.goreleaser.yml index 0ce9f01..8cb434a 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -8,8 +8,19 @@ builds: - id: verda binary: verda main: ./cmd/verda/ + # buildDate carries .CommitDate, not .Date: the stamp is a property of the + # tag, so re-running a release does not change it, and it matches the + # vcs.time fallback in version.GetFromDebugInfo. ldflags: - - -s -w -X github.com/verda-cloud/verda-cli/pkg/version.gitVersion={{ .Version }} + - -s -w + - -X github.com/verda-cloud/verda-cli/pkg/version.gitVersion={{ .Version }} + - -X github.com/verda-cloud/verda-cli/pkg/version.gitCommit={{ .FullCommit }} + - -X github.com/verda-cloud/verda-cli/pkg/version.gitTreeState={{ .GitTreeState }} + - -X github.com/verda-cloud/verda-cli/pkg/version.buildDate={{ .CommitDate }} + # -trimpath strips absolute build paths, so the output depends only on the + # tag and not on where CI happened to check out. + flags: + - -trimpath env: - CGO_ENABLED=0 goos: diff --git a/internal/verda-cli/cmd/cmd.go b/internal/verda-cli/cmd/cmd.go index 58e5be4..b89f813 100644 --- a/internal/verda-cli/cmd/cmd.go +++ b/internal/verda-cli/cmd/cmd.go @@ -60,7 +60,7 @@ func NewRootCommand(ioStreams cmdutil.IOStreams) (*cobra.Command, *clioptions.Op var showVersion bool cmd := &cobra.Command{ - Use: "verda", + Use: rootCmdName, Short: "Command-line interface for Verda Cloud", Long: cmdutil.LongDesc(` Command-line interface for Verda Cloud.`), @@ -193,6 +193,7 @@ func NewRootCommand(ioStreams cmdutil.IOStreams) (*cobra.Command, *clioptions.Op doctor.NewCmdDoctor(f, ioStreams), settings.NewCmdSettings(f, ioStreams), update.NewCmdUpdate(f, ioStreams), + newVersionCommand(ioStreams), }, }, ) @@ -234,7 +235,9 @@ func skipCredentialResolution(cmd *cobra.Command) bool { return true case pName == "registry": return true - case cmd.Name() == "doctor" && pName == "verda": + case cmd.Name() == "doctor" && pName == rootCmdName: + return true + case cmd.Name() == "version" && pName == rootCmdName: return true } return false @@ -246,7 +249,7 @@ func shouldCheckVersion(cmd *cobra.Command) bool { switch cmd.Name() { case "help": return true - case "verda": + case rootCmdName: // Root with no subcommand (prints help then PostRun). return true } @@ -257,18 +260,41 @@ func shouldCheckVersion(cmd *cobra.Command) bool { // Callers should check for this error and exit 0 instead of printing it. var ErrVersionRequested = errors.New("version requested") -// versionOutput returns the formatted version string. +const ( + rootCmdName = "verda" + cliModulePath = "github.com/verda-cloud/verda-cli" + sdkModulePath = "github.com/verda-cloud/verdacloud-sdk-go" +) + +// versionOutput returns the formatted version string. GetFromDebugInfo (not +// Get) so commit/date survive builds without ldflags — `go install`, `make +// build` — by falling back to the vcs.* stamps Go embeds. func versionOutput() string { - info := version.Get() - sdkVer := depVersion("github.com/verda-cloud/verdacloud-sdk-go") - return fmt.Sprintf(" Version: %s\n Platform: %s\n SDK: %s\n", - info.GitVersion, info.Platform, sdkVer) + info := version.GetFromDebugInfo(cliModulePath) + + out := " Version: " + info.GitVersion + "\n" + if info.GitCommit != unknownVersionValue { + out += " Commit: " + info.GitCommit + if info.GitTreeState == "dirty" { + out += " (dirty)" + } + out += "\n" + } + if info.BuildDate != unknownVersionValue { + out += " Built: " + info.BuildDate + "\n" + } + out += " Platform: " + info.Platform + "\n" + out += " SDK: " + depVersion(sdkModulePath) + "\n" + return out } +// Mirrors the sentinel pkg/version uses for unstamped build vars. +const unknownVersionValue = "unknown" + func depVersion(modulePath string) string { bi, ok := debug.ReadBuildInfo() if !ok { - return "unknown" + return unknownVersionValue } for _, dep := range bi.Deps { if dep.Path == modulePath { @@ -278,5 +304,5 @@ func depVersion(modulePath string) string { return dep.Version } } - return "unknown" + return unknownVersionValue } diff --git a/internal/verda-cli/cmd/version.go b/internal/verda-cli/cmd/version.go new file mode 100644 index 0000000..d91fb51 --- /dev/null +++ b/internal/verda-cli/cmd/version.go @@ -0,0 +1,38 @@ +// Copyright 2026 Verda Cloud Oy +// +// 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" + + "github.com/spf13/cobra" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +// newVersionCommand: `verda version` and `verda --version` share versionOutput +// verbatim. The flag predates the subcommand and stays supported. +// skipCredentialResolution covers it — version must answer unconfigured. +func newVersionCommand(ioStreams cmdutil.IOStreams) *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print version information", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + _, _ = fmt.Fprint(ioStreams.Out, versionOutput()) + return nil + }, + } +} diff --git a/pkg/version/version.go b/pkg/version/version.go index 592b491..5c9437e 100644 --- a/pkg/version/version.go +++ b/pkg/version/version.go @@ -25,6 +25,7 @@ package version import ( "encoding/json" "fmt" + "regexp" "runtime" "runtime/debug" ) @@ -32,11 +33,18 @@ import ( const ( unknownValue = "unknown" trueValue = "true" + devVersion = "v0.0.0-dev" ) +// Go synthesizes a pseudo-version as Main.Version for a build from local +// source. The separator before the timestamp is '.' when the pseudo-version +// has a base tag (v1.8.2-0.-) and '-' when it does not +// (v0.0.0--). A real tag never ends this way. +var pseudoVersionRe = regexp.MustCompile(`[-.]\d{14}-[0-9a-f]{12}(\+[\w.]+)?$`) + // Build-time variables set via -ldflags. var ( - gitVersion = "v0.0.0-dev" + gitVersion = devVersion gitCommit = unknownValue gitTreeState = unknownValue buildDate = unknownValue @@ -77,14 +85,17 @@ func GetFromDebugInfo(modulePath string) Info { return info } - if info.GitVersion == "v0.0.0-dev" { + if info.GitVersion == devVersion { for _, dep := range bi.Deps { if dep.Path == modulePath { info.GitVersion = dep.Version break } } - if info.GitVersion == "v0.0.0-dev" && bi.Main.Version != "" && bi.Main.Version != "(devel)" { + // Main.Version is a real tag for `go install pkg@v1.2.3` but a + // pseudo-version for a local `go build`. Keep the sentinel for the + // latter: GitCommit/GitTreeState already say what a dev build is. + if info.GitVersion == devVersion && isTaggedVersion(bi.Main.Version) { info.GitVersion = bi.Main.Version } } @@ -113,6 +124,10 @@ func GetFromDebugInfo(modulePath string) Info { return info } +func isTaggedVersion(v string) bool { + return v != "" && v != "(devel)" && !pseudoVersionRe.MatchString(v) +} + // String returns the git version string. func (i Info) String() string { return i.GitVersion diff --git a/pkg/version/version_test.go b/pkg/version/version_test.go index 689573d..cb682e1 100644 --- a/pkg/version/version_test.go +++ b/pkg/version/version_test.go @@ -117,3 +117,28 @@ func TestVersionValue_Type(t *testing.T) { t.Errorf("Type() = %q, want 'version'", v.Type()) } } + +func TestIsTaggedVersion(t *testing.T) { + tests := []struct { + name string + in string + want bool + }{ + {"real tag", "v1.8.2", true}, + {"real prerelease tag", "v1.8.2-rc.1", true}, + {"incompatible tag", "v2.0.0+incompatible", true}, + {"empty", "", false}, + {"devel", "(devel)", false}, + {"pseudo-version", "v1.8.2-0.20260818162257-0abc56bfdc8e", false}, + {"dirty pseudo-version", "v1.8.2-0.20260818162257-0abc56bfdc8e+dirty", false}, + {"pseudo-version off a prerelease", "v1.8.2-rc.1.0.20260818162257-0abc56bfdc8e", false}, + {"pseudo-version with no base tag", "v0.0.0-20260818162257-0abc56bfdc8e", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isTaggedVersion(tt.in); got != tt.want { + t.Errorf("isTaggedVersion(%q) = %v, want %v", tt.in, got, tt.want) + } + }) + } +}