From 20d9567c4d4819ce375b9fd8926ecd3806811ccc Mon Sep 17 00:00:00 2001 From: Mat Byczkowski Date: Mon, 5 Oct 2026 11:58:08 -0700 Subject: [PATCH] feat(cli): read --version from Go build info --- .github/workflows/release.yaml | 23 +++++++++----- .gitignore | 4 ++- certstrap.go | 4 +-- version.go | 50 ++++++++++++++++++++++++++++++ version_test.go | 56 ++++++++++++++++++++++++++++++++++ 5 files changed, 126 insertions(+), 11 deletions(-) create mode 100644 version.go create mode 100644 version_test.go diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 88cb296..f5dfa2a 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -46,19 +46,28 @@ jobs: GOARCH: ${{ matrix.goarch }} CGO_ENABLED: "0" run: | - if [[ "$GITHUB_REF" == refs/tags/* ]]; then - version="${GITHUB_REF_NAME#v}" - else - version="dev-${GITHUB_SHA::7}" - fi out="dist/certstrap-${GOOS}-${GOARCH}" - go build -trimpath -ldflags "-X main.release=${version}" -o "$out" . + go build -trimpath -o "$out" . # Fail if the binary wasn't built for the requested platform. go version -m "$out" | grep -Eq "^[[:space:]]+build[[:space:]]+GOOS=${GOOS}$" go version -m "$out" | grep -Eq "^[[:space:]]+build[[:space:]]+GOARCH=${GOARCH}$" + + # --version prints the module version that Go stamps from git. A tag + # build must report exactly that tag, and no build may be dirty or + # missing git metadata. + version=$(go version -m "$out" | awk '$1 == "mod" { print $3 }') + echo "Module version: ${version}" + if [[ "$GITHUB_REF" == refs/tags/* && "$version" != "$GITHUB_REF_NAME" ]]; then + echo "::error::binary reports ${version}, want tag ${GITHUB_REF_NAME}" + exit 1 + fi + if [[ "$version" == "(devel)" || "$version" == *+dirty ]]; then + echo "::error::binary reports ${version}, want a version from a clean git checkout" + exit 1 + fi if [[ "$GOOS" == linux && "$GOARCH" == amd64 ]]; then - "./$out" --version | grep -F "$version" + test "$("./$out" --version)" = "certstrap version ${version#v}" fi - name: Upload artifact diff --git a/.gitignore b/.gitignore index 336d34b..174c300 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ certstrap # Nice to have in .gitignore .idea/ # .DS_Store file sometimes generated by Mac computers -.DS_Store \ No newline at end of file +.DS_Store +# Release build output +dist/ diff --git a/certstrap.go b/certstrap.go index a8666af..22c53c0 100644 --- a/certstrap.go +++ b/certstrap.go @@ -25,12 +25,10 @@ import ( "github.com/urfave/cli" ) -var release = "1.3.0" - func main() { app := cli.NewApp() app.Name = "certstrap" - app.Version = release + app.Version = appVersion() app.Usage = "A simple certificate manager written in Go, to bootstrap your own certificate authority and public key infrastructure." app.Flags = []cli.Flag{ cli.StringFlag{ diff --git a/version.go b/version.go new file mode 100644 index 0000000..d0df638 --- /dev/null +++ b/version.go @@ -0,0 +1,50 @@ +/*- + * Copyright 2026 Square Inc. + * + * 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 main + +import ( + "runtime/debug" + "strings" +) + +// release overrides what --version prints. Leave it empty to print the +// module version that Go stamps into the binary at build time. Builds +// without git metadata, such as from a source tarball, can set it with +// -ldflags "-X main.release=1.2.3". +var release string + +// appVersion returns the version for --version, without the leading "v" +// of a Go module version. +func appVersion() string { + var buildVersion string + if info, ok := debug.ReadBuildInfo(); ok { + buildVersion = info.Main.Version + } + return resolveVersion(release, buildVersion) +} + +// resolveVersion returns override if it is set, then buildVersion without +// its leading "v", and "(devel)" if neither is set. +func resolveVersion(override, buildVersion string) string { + if override != "" { + return override + } + if buildVersion != "" { + return strings.TrimPrefix(buildVersion, "v") + } + return "(devel)" +} diff --git a/version_test.go b/version_test.go new file mode 100644 index 0000000..d4714b3 --- /dev/null +++ b/version_test.go @@ -0,0 +1,56 @@ +/*- + * Copyright 2026 Square Inc. + * + * 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 main + +import "testing" + +func TestResolveVersion(t *testing.T) { + tests := []struct { + name string + override string + buildVersion string + want string + }{ + {name: "override wins over build info", override: "1.2.3", buildVersion: "v1.4.0", want: "1.2.3"}, + {name: "tagged build", buildVersion: "v1.4.0", want: "1.4.0"}, + {name: "prerelease tag", buildVersion: "v1.4.0-rc.1", want: "1.4.0-rc.1"}, + {name: "untagged commit", buildVersion: "v1.4.1-0.20261005184410-5abe9dab23cc", want: "1.4.1-0.20261005184410-5abe9dab23cc"}, + {name: "uncommitted changes", buildVersion: "v1.4.0+dirty", want: "1.4.0+dirty"}, + {name: "no git metadata", buildVersion: "(devel)", want: "(devel)"}, + {name: "no build info", want: "(devel)"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := resolveVersion(tc.override, tc.buildVersion); got != tc.want { + t.Fatalf("resolveVersion(%q, %q) = %q, want %q", tc.override, tc.buildVersion, got, tc.want) + } + }) + } +} + +func TestAppVersion(t *testing.T) { + if appVersion() == "" { + t.Fatal("appVersion() is empty") + } + + defer func(v string) { release = v }(release) + release = "1.2.3" + if got := appVersion(); got != "1.2.3" { + t.Fatalf("appVersion() = %q, want the -X override %q", got, "1.2.3") + } +}