diff --git a/.goreleaser.yml b/.goreleaser.yml index e7ea4795a3..1a9777218e 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -16,7 +16,7 @@ before: builds: - id: coder-slim dir: cmd/coder - ldflags: ["-s -w"] + ldflags: ["-s -w -X github.com/coder/coder/cli/buildinfo.tag={{ .Version }}"] env: [CGO_ENABLED=0] goos: [darwin, linux, windows] goarch: [amd64] @@ -28,7 +28,7 @@ builds: - id: coder dir: cmd/coder flags: [-tags=embed] - ldflags: ["-s -w"] + ldflags: ["-s -w -X github.com/coder/coder/cli/buildinfo.tag={{ .Version }}"] env: [CGO_ENABLED=0] goos: [darwin, linux, windows] goarch: [amd64, arm64] @@ -58,3 +58,6 @@ nfpms: release: ids: [coder, packages] + +snapshot: + name_template: '{{ .Version }}-devel+{{ .ShortCommit }}' diff --git a/.vscode/settings.json b/.vscode/settings.json index d9e97a91b4..38a091b323 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,6 +5,7 @@ "coderd", "coderdtest", "codersdk", + "devel", "drpc", "drpcconn", "drpcmux", diff --git a/cli/buildinfo/buildinfo.go b/cli/buildinfo/buildinfo.go new file mode 100644 index 0000000000..b748b4f426 --- /dev/null +++ b/cli/buildinfo/buildinfo.go @@ -0,0 +1,83 @@ +package buildinfo + +import ( + "path" + "runtime/debug" + "sync" + "time" + + "golang.org/x/mod/semver" +) + +var ( + buildInfo *debug.BuildInfo + buildInfoValid bool + readBuildInfo sync.Once + + // Injected with ldflags at build! + tag string +) + +// Version returns the semantic version of the build. +// Use golang.org/x/mod/semver to compare versions. +func Version() string { + revision, valid := revision() + if valid { + revision = "+" + revision[:7] + } + if tag == "" { + return "v0.0.0-devel" + revision + } + if semver.Build(tag) == "" { + tag += revision + } + return "v" + tag +} + +// ExternalURL returns a URL referencing the current Coder version. +// For production builds, this will link directly to a release. +// For development builds, this will link to a commit. +func ExternalURL() string { + repo := "https://github.com/coder/coder" + revision, valid := revision() + if !valid { + return repo + } + return path.Join(repo, "commit", revision) +} + +// Time returns when the Git revision was published. +func Time() (time.Time, bool) { + value, valid := find("vcs.time") + if !valid { + return time.Time{}, false + } + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + panic("couldn't parse time: " + err.Error()) + } + return parsed, true +} + +// revision returns the Git hash of the build. +func revision() (string, bool) { + return find("vcs.revision") +} + +// find panics if a setting with the specific key was not +// found in the build info. +func find(key string) (string, bool) { + readBuildInfo.Do(func() { + buildInfo, buildInfoValid = debug.ReadBuildInfo() + }) + if !buildInfoValid { + panic("couldn't read build info") + } + for _, setting := range buildInfo.Settings { + if setting.Key != key { + continue + } + return setting.Value, true + } + return "", false +} diff --git a/cli/buildinfo/buildinfo_test.go b/cli/buildinfo/buildinfo_test.go new file mode 100644 index 0000000000..15733afe2c --- /dev/null +++ b/cli/buildinfo/buildinfo_test.go @@ -0,0 +1,32 @@ +package buildinfo_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/mod/semver" + + "github.com/coder/coder/cli/buildinfo" +) + +func TestBuildInfo(t *testing.T) { + t.Parallel() + t.Run("Version", func(t *testing.T) { + t.Parallel() + version := buildinfo.Version() + require.True(t, semver.IsValid(version)) + prerelease := semver.Prerelease(version) + require.Equal(t, "-devel", prerelease) + require.Equal(t, "v0", semver.Major(version)) + }) + t.Run("ExternalURL", func(t *testing.T) { + t.Parallel() + require.Equal(t, "https://github.com/coder/coder", buildinfo.ExternalURL()) + }) + // Tests don't include Go build info. + t.Run("NoTime", func(t *testing.T) { + t.Parallel() + _, valid := buildinfo.Time() + require.False(t, valid) + }) +} diff --git a/cli/root.go b/cli/root.go index 542cd67d30..65003b56fb 100644 --- a/cli/root.go +++ b/cli/root.go @@ -4,12 +4,14 @@ import ( "net/url" "os" "strings" + "time" "github.com/charmbracelet/lipgloss" "github.com/kirsle/configdir" "github.com/mattn/go-isatty" "github.com/spf13/cobra" + "github.com/coder/coder/cli/buildinfo" "github.com/coder/coder/cli/cliui" "github.com/coder/coder/cli/config" "github.com/coder/coder/codersdk" @@ -28,6 +30,7 @@ const ( func Root() *cobra.Command { cmd := &cobra.Command{ Use: "coder", + Version: buildinfo.Version(), SilenceUsage: true, Long: ` ▄█▀ ▀█▄ ▄▄ ▀▀▀ █▌ ██▀▀█▄ ▐█ @@ -55,6 +58,7 @@ func Root() *cobra.Command { `Flags:`, header.Render("Flags:"), `Additional help topics:`, header.Render("Additional help:"), ).Replace(cmd.UsageTemplate())) + cmd.SetVersionTemplate(versionTemplate()) cmd.AddCommand( configSSH(), @@ -142,3 +146,14 @@ func isTTY(cmd *cobra.Command) bool { } return isatty.IsTerminal(file.Fd()) } + +func versionTemplate() string { + template := `Coder {{printf "%s" .Version}}` + buildTime, valid := buildinfo.Time() + if valid { + template += " " + buildTime.Format(time.UnixDate) + } + template += "\r\n" + buildinfo.ExternalURL() + template += "\r\n" + return template +} diff --git a/go.mod b/go.mod index ea32b09081..f78d281152 100644 --- a/go.mod +++ b/go.mod @@ -237,7 +237,7 @@ require ( github.com/zclconf/go-cty v1.10.0 // indirect github.com/zeebo/errs v1.2.2 // indirect go.opencensus.io v0.23.0 // indirect - golang.org/x/mod v0.5.1 // indirect + golang.org/x/mod v0.5.1 golang.org/x/net v0.0.0-20220325170049-de3da57026de // indirect golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 golang.org/x/text v0.3.7 // indirect