From 45ff3e78bade217fda9f9abb9e93b569081014d1 Mon Sep 17 00:00:00 2001 From: simonfaltum Date: Mon, 8 Jun 2026 15:24:11 +0200 Subject: [PATCH 1/6] Add `databricks version --check` to check for CLI updates The version command only printed the current version, with no way to tell whether a newer one was available. This adds an update check that fetches the latest release from GitHub, compares it to the running build, and prints the upgrade command for the detected install method (Homebrew, WinGet, Chocolatey, or the install script). Exposed as both `databricks version --check` and a `databricks version check` subcommand. Development builds skip the check, and `--output json` is supported for scripting. Co-authored-by: Isaac --- acceptance/cmd/version/check/out.test.toml | 3 + acceptance/cmd/version/check/output.txt | 21 +++ acceptance/cmd/version/check/script | 10 + acceptance/cmd/version/check/test.toml | 3 + cmd/version/version.go | 50 +++++ cmd/version/version_test.go | 77 ++++++++ libs/versioncheck/versioncheck.go | 204 +++++++++++++++++++++ libs/versioncheck/versioncheck_test.go | 123 +++++++++++++ 8 files changed, 491 insertions(+) create mode 100644 acceptance/cmd/version/check/out.test.toml create mode 100644 acceptance/cmd/version/check/output.txt create mode 100644 acceptance/cmd/version/check/script create mode 100644 acceptance/cmd/version/check/test.toml create mode 100644 cmd/version/version_test.go create mode 100644 libs/versioncheck/versioncheck.go create mode 100644 libs/versioncheck/versioncheck_test.go diff --git a/acceptance/cmd/version/check/out.test.toml b/acceptance/cmd/version/check/out.test.toml new file mode 100644 index 00000000000..d6187dcb046 --- /dev/null +++ b/acceptance/cmd/version/check/out.test.toml @@ -0,0 +1,3 @@ +Local = true +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = [] diff --git a/acceptance/cmd/version/check/output.txt b/acceptance/cmd/version/check/output.txt new file mode 100644 index 00000000000..298ddc68525 --- /dev/null +++ b/acceptance/cmd/version/check/output.txt @@ -0,0 +1,21 @@ + +=== version --check + +>>> [CLI] version --check +Databricks CLI v[DEV_VERSION] +This is a development build; skipping the update check. + +=== version check subcommand + +>>> [CLI] version check +Databricks CLI v[DEV_VERSION] +This is a development build; skipping the update check. + +=== version --check --output json + +>>> [CLI] version --check --output json +{ + "current_version": "[DEV_VERSION]", + "update_available": false, + "development_build": true +} diff --git a/acceptance/cmd/version/check/script b/acceptance/cmd/version/check/script new file mode 100644 index 00000000000..c5607043b26 --- /dev/null +++ b/acceptance/cmd/version/check/script @@ -0,0 +1,10 @@ +# The acceptance binary is a development build, so the check short-circuits +# without contacting GitHub. This exercises command wiring and output rendering. +title "version --check\n" +trace $CLI version --check + +title "version check subcommand\n" +trace $CLI version check + +title "version --check --output json\n" +trace $CLI version --check --output json diff --git a/acceptance/cmd/version/check/test.toml b/acceptance/cmd/version/check/test.toml new file mode 100644 index 00000000000..97179048cb4 --- /dev/null +++ b/acceptance/cmd/version/check/test.toml @@ -0,0 +1,3 @@ +# The update check is not bundle-aware; run it once instead of per-engine. +[EnvMatrix] +DATABRICKS_BUNDLE_ENGINE = [] diff --git a/cmd/version/version.go b/cmd/version/version.go index 813bb97aa79..9bce174194d 100644 --- a/cmd/version/version.go +++ b/cmd/version/version.go @@ -3,12 +3,33 @@ package version import ( + "fmt" + "github.com/databricks/cli/cmd/root" "github.com/databricks/cli/internal/build" "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/versioncheck" "github.com/spf13/cobra" ) +// updateCheckTemplate renders an update check in text mode. JSON output is +// rendered directly from the versioncheck.Result struct by cmdio. +const updateCheckTemplate = `Databricks CLI v{{.CurrentVersion}} +{{if .DevelopmentBuild -}} +This is a development build; skipping the update check. +{{- else if .UpdateAvailable -}} +{{yellow "A new version is available"}}: {{.LatestVersion}} (you have {{.CurrentVersion}}). +{{if .UpgradeCommand -}} +To upgrade, run: + {{.UpgradeCommand}} +{{- else -}} +Download the latest release: https://github.com/databricks/cli/releases +{{- end}} +{{- else -}} +{{green "You're on the latest version."}} +{{- end}} +` + func New() *cobra.Command { cmd := &cobra.Command{ Use: "version", @@ -19,9 +40,38 @@ func New() *cobra.Command { }, } + var check bool + cmd.Flags().BoolVar(&check, "check", false, "Check whether a newer version of the CLI is available") + cmd.RunE = func(cmd *cobra.Command, args []string) error { + if check { + return runUpdateCheck(cmd) + } return cmdio.Render(cmd.Context(), build.GetInfo()) } + cmd.AddCommand(newCheckCommand()) + return cmd } + +func newCheckCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "check", + Args: root.NoArgs, + Short: "Check whether a newer version of the CLI is available", + } + cmd.RunE = func(cmd *cobra.Command, args []string) error { + return runUpdateCheck(cmd) + } + return cmd +} + +func runUpdateCheck(cmd *cobra.Command) error { + ctx := cmd.Context() + result, err := versioncheck.Check(ctx) + if err != nil { + return fmt.Errorf("check for updates: %w", err) + } + return cmdio.RenderWithTemplate(ctx, result, "", updateCheckTemplate) +} diff --git a/cmd/version/version_test.go b/cmd/version/version_test.go new file mode 100644 index 00000000000..9a224711d9d --- /dev/null +++ b/cmd/version/version_test.go @@ -0,0 +1,77 @@ +package version + +import ( + "testing" + + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/versioncheck" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUpdateCheckTemplate(t *testing.T) { + tests := []struct { + name string + result versioncheck.Result + want string + }{ + { + name: "update available with upgrade command", + result: versioncheck.Result{ + CurrentVersion: "0.240.0", + LatestVersion: "0.245.0", + UpdateAvailable: true, + InstallMethod: versioncheck.InstallHomebrew, + UpgradeCommand: "brew upgrade databricks", + }, + want: `Databricks CLI v0.240.0 +A new version is available: 0.245.0 (you have 0.240.0). +To upgrade, run: + brew upgrade databricks +`, + }, + { + name: "update available without known install method", + result: versioncheck.Result{ + CurrentVersion: "0.240.0", + LatestVersion: "0.245.0", + UpdateAvailable: true, + InstallMethod: versioncheck.InstallUnknown, + }, + want: `Databricks CLI v0.240.0 +A new version is available: 0.245.0 (you have 0.240.0). +Download the latest release: https://github.com/databricks/cli/releases +`, + }, + { + name: "up to date", + result: versioncheck.Result{ + CurrentVersion: "0.245.0", + LatestVersion: "0.245.0", + UpdateAvailable: false, + }, + want: `Databricks CLI v0.245.0 +You're on the latest version. +`, + }, + { + name: "development build", + result: versioncheck.Result{ + CurrentVersion: "0.0.0-dev+abc123", + DevelopmentBuild: true, + }, + want: `Databricks CLI v0.0.0-dev+abc123 +This is a development build; skipping the update check. +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, out := cmdio.NewTestContextWithStdout(t.Context()) + err := cmdio.RenderWithTemplate(ctx, tt.result, "", updateCheckTemplate) + require.NoError(t, err) + assert.Equal(t, tt.want, out.String()) + }) + } +} diff --git a/libs/versioncheck/versioncheck.go b/libs/versioncheck/versioncheck.go new file mode 100644 index 00000000000..ed95013de95 --- /dev/null +++ b/libs/versioncheck/versioncheck.go @@ -0,0 +1,204 @@ +// Package versioncheck reports whether a newer Databricks CLI release is +// available and, based on how the running binary was installed, how to upgrade. +package versioncheck + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/databricks/cli/internal/build" + "github.com/databricks/cli/libs/env" + "github.com/databricks/cli/libs/log" + "golang.org/x/mod/semver" +) + +const ( + // GitHub returns the latest non-prerelease, non-draft release here, which is + // exactly the "latest stable" the CLI ships from. + // https://docs.github.com/en/rest/releases/releases#get-the-latest-release + defaultGitHubAPIURL = "https://api.github.com" + latestReleasePath = "/repos/databricks/cli/releases/latest" + + // gitHubAPIURLEnv overrides the GitHub API base URL. It lets acceptance + // tests point the lookup at the mock server (and gives airgapped setups an + // escape hatch); it is not a documented, user-facing setting. + gitHubAPIURLEnv = "DATABRICKS_CLI_GITHUB_API_URL" + + // fetchTimeout bounds the release lookup so a hung connection never blocks + // the user's command indefinitely. + fetchTimeout = 10 * time.Second +) + +// Upgrade commands per install method, matching the documented install flows: +// https://docs.databricks.com/dev-tools/cli/install.html +const ( + upgradeHomebrew = "brew upgrade databricks" + upgradeWinget = "winget upgrade Databricks.DatabricksCLI" + upgradeChocolatey = "choco upgrade databricks-cli" + upgradeScript = "curl -fsSL https://raw.githubusercontent.com/databricks/setup-cli/main/install.sh | sh" +) + +// InstallMethod identifies how the running binary was installed, which +// determines the command a user runs to upgrade. +type InstallMethod string + +const ( + InstallHomebrew InstallMethod = "homebrew" + InstallWinget InstallMethod = "winget" + InstallChocolatey InstallMethod = "chocolatey" + InstallScript InstallMethod = "script" // the curl | sh installer + InstallUnknown InstallMethod = "unknown" +) + +// Result is the outcome of an update check. +type Result struct { + CurrentVersion string `json:"current_version"` + LatestVersion string `json:"latest_version,omitempty"` + UpdateAvailable bool `json:"update_available"` + DevelopmentBuild bool `json:"development_build,omitempty"` + InstallMethod InstallMethod `json:"install_method,omitempty"` + UpgradeCommand string `json:"upgrade_command,omitempty"` +} + +// Check fetches the latest released version and compares it with the running +// build, reporting how to upgrade based on the detected install method. +// +// Development and snapshot builds have no meaningful released version to +// compare against, so they short-circuit without a network call. +func Check(ctx context.Context) (*Result, error) { + info := build.GetInfo() + if isDevelopmentBuild(info) { + return &Result{ + CurrentVersion: info.Version, + DevelopmentBuild: true, + }, nil + } + + method, command := DetectInstallMethod(ctx) + + ctx, cancel := context.WithTimeout(ctx, fetchTimeout) + defer cancel() + latest, err := fetchLatestVersion(ctx) + if err != nil { + return nil, err + } + + return &Result{ + CurrentVersion: info.Version, + LatestVersion: latest, + UpdateAvailable: isNewer(info.Version, latest), + InstallMethod: method, + UpgradeCommand: command, + }, nil +} + +// isDevelopmentBuild reports whether the binary was not built from a tagged +// release. Snapshot builds (goreleaser --snapshot) and local `go build` +// binaries (version 0.0.0-dev+) fall into this category. +func isDevelopmentBuild(info build.Info) bool { + return info.IsSnapshot || strings.HasPrefix(info.Version, "0.0.0") +} + +// isNewer reports whether latest is a higher semver than current. Both are +// bare versions without a leading "v". +func isNewer(current, latest string) bool { + cv := "v" + current + lv := "v" + latest + if !semver.IsValid(cv) || !semver.IsValid(lv) { + return false + } + return semver.Compare(lv, cv) > 0 +} + +func fetchLatestVersion(ctx context.Context) (string, error) { + base := env.Get(ctx, gitHubAPIURLEnv) + if base == "" { + base = defaultGitHubAPIURL + } + + url := base + latestReleasePath + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", err + } + // GitHub rejects requests without a User-Agent and recommends pinning the + // API version. https://docs.github.com/en/rest/using-the-rest-api + req.Header.Set("User-Agent", "databricks-cli/"+build.GetInfo().Version) + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("fetch latest release: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("fetch latest release: unexpected status %s", resp.Status) + } + + var release struct { + TagName string `json:"tag_name"` + } + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return "", fmt.Errorf("parse latest release: %w", err) + } + + version := strings.TrimPrefix(release.TagName, "v") + if version == "" { + return "", errors.New("latest release has an empty tag_name") + } + return version, nil +} + +// DetectInstallMethod inspects the running executable's path to infer how the +// CLI was installed and the command to upgrade it. It returns InstallUnknown +// with an empty command when the install method can't be determined. +func DetectInstallMethod(ctx context.Context) (InstallMethod, string) { + exe, err := os.Executable() + if err != nil { + log.Debugf(ctx, "version check: cannot determine executable path: %v", err) + return InstallUnknown, "" + } + // Resolve symlinks so a Homebrew shim (e.g. /usr/local/bin/databricks -> + // ../Cellar/...) is classified by its real location rather than the shim. + if resolved, err := filepath.EvalSymlinks(exe); err == nil { + exe = resolved + } + return detectInstallMethod(runtime.GOOS, exe) +} + +func detectInstallMethod(goos, execPath string) (InstallMethod, string) { + // Normalize separators independently of the host OS (not filepath.ToSlash, + // which only swaps the host's separator) so the goos parameter fully + // determines classification and the logic is testable cross-platform. + p := strings.ReplaceAll(execPath, `\`, "/") + if goos == "windows" { + lower := strings.ToLower(p) + switch { + case strings.Contains(lower, "/winget/"): + return InstallWinget, upgradeWinget + case strings.Contains(lower, "/chocolatey/"): + return InstallChocolatey, upgradeChocolatey + case lower == "c:/windows/databricks.exe": + return InstallScript, upgradeScript + } + return InstallUnknown, "" + } + + // macOS and Linux. + switch { + case strings.Contains(p, "/Cellar/"), strings.Contains(p, "/homebrew/"), strings.Contains(p, "/linuxbrew/"): + return InstallHomebrew, upgradeHomebrew + case p == "/usr/local/bin/databricks": + return InstallScript, upgradeScript + } + return InstallUnknown, "" +} diff --git a/libs/versioncheck/versioncheck_test.go b/libs/versioncheck/versioncheck_test.go new file mode 100644 index 00000000000..178cc3ace5f --- /dev/null +++ b/libs/versioncheck/versioncheck_test.go @@ -0,0 +1,123 @@ +package versioncheck + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/databricks/cli/internal/build" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDetectInstallMethod(t *testing.T) { + tests := []struct { + name string + goos string + execPath string + wantMethod InstallMethod + wantCmd string + }{ + {"homebrew apple silicon", "darwin", "/opt/homebrew/Cellar/databricks/0.240.0/bin/databricks", InstallHomebrew, upgradeHomebrew}, + {"homebrew intel", "darwin", "/usr/local/Cellar/databricks/0.240.0/bin/databricks", InstallHomebrew, upgradeHomebrew}, + {"homebrew linux", "linux", "/home/linuxbrew/.linuxbrew/Cellar/databricks/0.240.0/bin/databricks", InstallHomebrew, upgradeHomebrew}, + {"script unix", "linux", "/usr/local/bin/databricks", InstallScript, upgradeScript}, + {"script macos", "darwin", "/usr/local/bin/databricks", InstallScript, upgradeScript}, + {"unknown unix", "linux", "/home/me/bin/databricks", InstallUnknown, ""}, + {"winget", "windows", `C:\Users\me\AppData\Local\Microsoft\WinGet\Packages\Databricks.DatabricksCLI_X\databricks.exe`, InstallWinget, upgradeWinget}, + {"chocolatey", "windows", `C:\ProgramData\chocolatey\bin\databricks.exe`, InstallChocolatey, upgradeChocolatey}, + {"script windows", "windows", `C:\Windows\databricks.exe`, InstallScript, upgradeScript}, + {"unknown windows", "windows", `C:\tools\databricks.exe`, InstallUnknown, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + method, cmd := detectInstallMethod(tt.goos, tt.execPath) + assert.Equal(t, tt.wantMethod, method) + assert.Equal(t, tt.wantCmd, cmd) + }) + } +} + +func TestIsNewer(t *testing.T) { + tests := []struct { + current string + latest string + want bool + }{ + {"0.240.0", "0.245.0", true}, + {"0.245.0", "0.245.0", false}, + {"0.245.0", "0.240.0", false}, + {"0.245.0", "1.0.0", true}, + {"1.0.0", "0.245.0", false}, + {"0.240.0", "not-a-version", false}, + } + + for _, tt := range tests { + t.Run(tt.current+"_vs_"+tt.latest, func(t *testing.T) { + assert.Equal(t, tt.want, isNewer(tt.current, tt.latest)) + }) + } +} + +func TestCheck(t *testing.T) { + // Check reads the running build version via package-global state; restore it + // so the mutation doesn't leak into other tests in this binary. + original := build.GetInfo().Version + t.Cleanup(func() { build.SetBuildVersion(original) }) + + newReleaseServer := func(t *testing.T, status int, tag string) *httptest.Server { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, latestReleasePath, r.URL.Path) + w.WriteHeader(status) + if tag != "" { + _, _ = w.Write([]byte(`{"tag_name":"` + tag + `"}`)) + } + })) + t.Cleanup(srv.Close) + return srv + } + + t.Run("update available", func(t *testing.T) { + build.SetBuildVersion("0.240.0") + srv := newReleaseServer(t, http.StatusOK, "v0.245.0") + t.Setenv(gitHubAPIURLEnv, srv.URL) + + result, err := Check(t.Context()) + require.NoError(t, err) + assert.Equal(t, "0.240.0", result.CurrentVersion) + assert.Equal(t, "0.245.0", result.LatestVersion) + assert.True(t, result.UpdateAvailable) + assert.False(t, result.DevelopmentBuild) + }) + + t.Run("up to date", func(t *testing.T) { + build.SetBuildVersion("0.245.0") + srv := newReleaseServer(t, http.StatusOK, "v0.245.0") + t.Setenv(gitHubAPIURLEnv, srv.URL) + + result, err := Check(t.Context()) + require.NoError(t, err) + assert.False(t, result.UpdateAvailable) + assert.Equal(t, "0.245.0", result.LatestVersion) + }) + + t.Run("development build skips the network call", func(t *testing.T) { + build.SetBuildVersion("0.0.0-dev+abc123") + // No server env set: a network call here would fail, proving we skip it. + result, err := Check(t.Context()) + require.NoError(t, err) + assert.True(t, result.DevelopmentBuild) + assert.False(t, result.UpdateAvailable) + assert.Empty(t, result.LatestVersion) + }) + + t.Run("server error surfaces", func(t *testing.T) { + build.SetBuildVersion("0.240.0") + srv := newReleaseServer(t, http.StatusInternalServerError, "") + t.Setenv(gitHubAPIURLEnv, srv.URL) + + _, err := Check(t.Context()) + require.Error(t, err) + }) +} From 68ed2a4c39ec65705561e333bd0e544658c8f18b Mon Sep 17 00:00:00 2001 From: simonfaltum Date: Mon, 8 Jun 2026 15:25:09 +0200 Subject: [PATCH 2/6] Add NEXT_CHANGELOG entry for version --check Co-authored-by: Isaac --- NEXT_CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 90b65ec3e86..29f576fa5ba 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -5,6 +5,7 @@ ### Notable Changes ### CLI +* Add `databricks version --check` (and a `databricks version check` subcommand) to report whether a newer CLI version is available and print the upgrade command for the detected install method ([#5469](https://github.com/databricks/cli/pull/5469)). ### Bundles * Set the default `data_security_mode` to `DATA_SECURITY_MODE_AUTO` in bundle templates ([#5452](https://github.com/databricks/cli/pull/5452)). From c6abc0e900f576be76a979ff30d940d111cc1e37 Mon Sep 17 00:00:00 2001 From: simonfaltum Date: Tue, 9 Jun 2026 14:27:13 +0200 Subject: [PATCH 3/6] Fail gently when `version --check` can't reach GitHub Cap the release lookup at 2s and, when GitHub is unreachable, times out, or rate-limits, report it gently instead of failing the command: `version --check` now prints that it couldn't reach GitHub and links to the releases page to check manually, and still exits 0. Co-authored-by: Isaac --- cmd/version/version.go | 2 ++ cmd/version/version_test.go | 10 +++++++++ libs/versioncheck/versioncheck.go | 29 +++++++++++++++++--------- libs/versioncheck/versioncheck_test.go | 22 ++++++++++++++++--- 4 files changed, 50 insertions(+), 13 deletions(-) diff --git a/cmd/version/version.go b/cmd/version/version.go index 9bce174194d..b16ffad9d7a 100644 --- a/cmd/version/version.go +++ b/cmd/version/version.go @@ -17,6 +17,8 @@ import ( const updateCheckTemplate = `Databricks CLI v{{.CurrentVersion}} {{if .DevelopmentBuild -}} This is a development build; skipping the update check. +{{- else if .CheckFailed -}} +Could not reach GitHub to check for a newer version. See https://github.com/databricks/cli/releases for the latest release. {{- else if .UpdateAvailable -}} {{yellow "A new version is available"}}: {{.LatestVersion}} (you have {{.CurrentVersion}}). {{if .UpgradeCommand -}} diff --git a/cmd/version/version_test.go b/cmd/version/version_test.go index 9a224711d9d..ce86641bc35 100644 --- a/cmd/version/version_test.go +++ b/cmd/version/version_test.go @@ -62,6 +62,16 @@ You're on the latest version. }, want: `Databricks CLI v0.0.0-dev+abc123 This is a development build; skipping the update check. +`, + }, + { + name: "check failed", + result: versioncheck.Result{ + CurrentVersion: "0.240.0", + CheckFailed: true, + }, + want: `Databricks CLI v0.240.0 +Could not reach GitHub to check for a newer version. See https://github.com/databricks/cli/releases for the latest release. `, }, } diff --git a/libs/versioncheck/versioncheck.go b/libs/versioncheck/versioncheck.go index ed95013de95..45e58cfcb24 100644 --- a/libs/versioncheck/versioncheck.go +++ b/libs/versioncheck/versioncheck.go @@ -32,9 +32,9 @@ const ( // escape hatch); it is not a documented, user-facing setting. gitHubAPIURLEnv = "DATABRICKS_CLI_GITHUB_API_URL" - // fetchTimeout bounds the release lookup so a hung connection never blocks - // the user's command indefinitely. - fetchTimeout = 10 * time.Second + // fetchTimeout bounds the release lookup. The explicit `version --check` + // waits on it, so keep it short: a quick "couldn't reach GitHub" beats a hang. + fetchTimeout = 2 * time.Second ) // Upgrade commands per install method, matching the documented install flows: @@ -60,12 +60,15 @@ const ( // Result is the outcome of an update check. type Result struct { - CurrentVersion string `json:"current_version"` - LatestVersion string `json:"latest_version,omitempty"` - UpdateAvailable bool `json:"update_available"` - DevelopmentBuild bool `json:"development_build,omitempty"` - InstallMethod InstallMethod `json:"install_method,omitempty"` - UpgradeCommand string `json:"upgrade_command,omitempty"` + CurrentVersion string `json:"current_version"` + LatestVersion string `json:"latest_version,omitempty"` + UpdateAvailable bool `json:"update_available"` + DevelopmentBuild bool `json:"development_build,omitempty"` + // CheckFailed is set when the latest version couldn't be fetched (offline, + // timeout, rate-limited). The command reports this gently instead of erroring. + CheckFailed bool `json:"check_failed,omitempty"` + InstallMethod InstallMethod `json:"install_method,omitempty"` + UpgradeCommand string `json:"upgrade_command,omitempty"` } // Check fetches the latest released version and compares it with the running @@ -88,7 +91,13 @@ func Check(ctx context.Context) (*Result, error) { defer cancel() latest, err := fetchLatestVersion(ctx) if err != nil { - return nil, err + // Fail gently: a failed lookup shouldn't fail the command. The caller + // renders a "couldn't reach GitHub" message instead. + log.Debugf(ctx, "version check: %v", err) + return &Result{ + CurrentVersion: info.Version, + CheckFailed: true, + }, nil } return &Result{ diff --git a/libs/versioncheck/versioncheck_test.go b/libs/versioncheck/versioncheck_test.go index 178cc3ace5f..e2f0dec921f 100644 --- a/libs/versioncheck/versioncheck_test.go +++ b/libs/versioncheck/versioncheck_test.go @@ -112,12 +112,28 @@ func TestCheck(t *testing.T) { assert.Empty(t, result.LatestVersion) }) - t.Run("server error surfaces", func(t *testing.T) { + t.Run("server error fails gently", func(t *testing.T) { build.SetBuildVersion("0.240.0") srv := newReleaseServer(t, http.StatusInternalServerError, "") t.Setenv(gitHubAPIURLEnv, srv.URL) - _, err := Check(t.Context()) - require.Error(t, err) + result, err := Check(t.Context()) + require.NoError(t, err) + assert.True(t, result.CheckFailed) + assert.False(t, result.UpdateAvailable) + assert.Empty(t, result.LatestVersion) + }) + + t.Run("unreachable GitHub fails gently", func(t *testing.T) { + build.SetBuildVersion("0.240.0") + // A server closed immediately gives a definitely-refused port. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + url := srv.URL + srv.Close() + t.Setenv(gitHubAPIURLEnv, url) + + result, err := Check(t.Context()) + require.NoError(t, err) + assert.True(t, result.CheckFailed) }) } From bd824573a47ce8384635d8ba344922f405b4d61f Mon Sep 17 00:00:00 2001 From: simonfaltum Date: Tue, 9 Jun 2026 19:45:01 +0200 Subject: [PATCH 4/6] Pin the GitHub API version on the release lookup The comment already cited GitHub's recommendation to pin the API version but no X-GitHub-Api-Version header was actually sent. Co-authored-by: Isaac --- libs/versioncheck/versioncheck.go | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/versioncheck/versioncheck.go b/libs/versioncheck/versioncheck.go index 45e58cfcb24..565b07793ef 100644 --- a/libs/versioncheck/versioncheck.go +++ b/libs/versioncheck/versioncheck.go @@ -142,6 +142,7 @@ func fetchLatestVersion(ctx context.Context) (string, error) { // API version. https://docs.github.com/en/rest/using-the-rest-api req.Header.Set("User-Agent", "databricks-cli/"+build.GetInfo().Version) req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") resp, err := http.DefaultClient.Do(req) if err != nil { From b10e5584dd5ba6d0b9d1fd9eb6fb7227752d3c73 Mon Sep 17 00:00:00 2001 From: simonfaltum Date: Wed, 10 Jun 2026 11:59:59 +0200 Subject: [PATCH 5/6] Fold the update check into the version command itself Review feedback: version check / version --check / --version --check was too many permutations. Instead, `databricks version` now performs the check directly. The --version flag (cobra built-in) stays lightweight, and `version --output json` keeps the build-info shape and skips the network lookup so scripts are unaffected. Also drop the always-nil error return from versioncheck.Check; lookup failures are reported via Result.CheckFailed. Co-authored-by: Isaac --- NEXT_CHANGELOG.md | 2 +- acceptance/cmd/version/check/output.txt | 21 -------- acceptance/cmd/version/check/script | 10 ---- .../cmd/version/{check => }/out.test.toml | 0 acceptance/cmd/version/output.txt | 11 ++++ acceptance/cmd/version/script | 7 +++ acceptance/cmd/version/{check => }/test.toml | 0 cmd/version/version.go | 50 +++++-------------- cmd/version/version_test.go | 4 +- libs/versioncheck/versioncheck.go | 15 +++--- libs/versioncheck/versioncheck_test.go | 16 ++---- 11 files changed, 46 insertions(+), 90 deletions(-) delete mode 100644 acceptance/cmd/version/check/output.txt delete mode 100644 acceptance/cmd/version/check/script rename acceptance/cmd/version/{check => }/out.test.toml (100%) create mode 100644 acceptance/cmd/version/output.txt create mode 100644 acceptance/cmd/version/script rename acceptance/cmd/version/{check => }/test.toml (100%) diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index ad8f4dc4f2f..b4a7f042cd1 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -7,7 +7,7 @@ ### CLI * Added the `databricks quickstart` command, a short introduction to the CLI that prints a human-friendly guide interactively and an agent-oriented version when run non-interactively ([#5464](https://github.com/databricks/cli/pull/5464)). -* Add `databricks version --check` (and a `databricks version check` subcommand) to report whether a newer CLI version is available and print the upgrade command for the detected install method ([#5469](https://github.com/databricks/cli/pull/5469)). +* The `databricks version` command now reports whether a newer CLI version is available and prints the upgrade command for the detected install method. The `--version` flag and `databricks version --output json` are unchanged ([#5469](https://github.com/databricks/cli/pull/5469)). ### Bundles * Set the default `data_security_mode` to `DATA_SECURITY_MODE_AUTO` in bundle templates ([#5452](https://github.com/databricks/cli/pull/5452)). diff --git a/acceptance/cmd/version/check/output.txt b/acceptance/cmd/version/check/output.txt deleted file mode 100644 index 298ddc68525..00000000000 --- a/acceptance/cmd/version/check/output.txt +++ /dev/null @@ -1,21 +0,0 @@ - -=== version --check - ->>> [CLI] version --check -Databricks CLI v[DEV_VERSION] -This is a development build; skipping the update check. - -=== version check subcommand - ->>> [CLI] version check -Databricks CLI v[DEV_VERSION] -This is a development build; skipping the update check. - -=== version --check --output json - ->>> [CLI] version --check --output json -{ - "current_version": "[DEV_VERSION]", - "update_available": false, - "development_build": true -} diff --git a/acceptance/cmd/version/check/script b/acceptance/cmd/version/check/script deleted file mode 100644 index c5607043b26..00000000000 --- a/acceptance/cmd/version/check/script +++ /dev/null @@ -1,10 +0,0 @@ -# The acceptance binary is a development build, so the check short-circuits -# without contacting GitHub. This exercises command wiring and output rendering. -title "version --check\n" -trace $CLI version --check - -title "version check subcommand\n" -trace $CLI version check - -title "version --check --output json\n" -trace $CLI version --check --output json diff --git a/acceptance/cmd/version/check/out.test.toml b/acceptance/cmd/version/out.test.toml similarity index 100% rename from acceptance/cmd/version/check/out.test.toml rename to acceptance/cmd/version/out.test.toml diff --git a/acceptance/cmd/version/output.txt b/acceptance/cmd/version/output.txt new file mode 100644 index 00000000000..f5254103fd4 --- /dev/null +++ b/acceptance/cmd/version/output.txt @@ -0,0 +1,11 @@ + +=== version runs the update check + +>>> [CLI] version +Databricks CLI v[DEV_VERSION] +This is a development build; skipping the update check. + +=== version --output json keeps the build-info shape and skips the check + +>>> [CLI] version --output json +"[DEV_VERSION]" diff --git a/acceptance/cmd/version/script b/acceptance/cmd/version/script new file mode 100644 index 00000000000..f27d5b5d8ae --- /dev/null +++ b/acceptance/cmd/version/script @@ -0,0 +1,7 @@ +# The acceptance binary is a development build, so the check short-circuits +# without contacting GitHub. This exercises command wiring and output rendering. +title "version runs the update check\n" +trace $CLI version + +title "version --output json keeps the build-info shape and skips the check\n" +trace $CLI version --output json | jq .Version diff --git a/acceptance/cmd/version/check/test.toml b/acceptance/cmd/version/test.toml similarity index 100% rename from acceptance/cmd/version/check/test.toml rename to acceptance/cmd/version/test.toml diff --git a/cmd/version/version.go b/cmd/version/version.go index b16ffad9d7a..bd6f3011490 100644 --- a/cmd/version/version.go +++ b/cmd/version/version.go @@ -3,24 +3,23 @@ package version import ( - "fmt" - "github.com/databricks/cli/cmd/root" "github.com/databricks/cli/internal/build" "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/flags" "github.com/databricks/cli/libs/versioncheck" "github.com/spf13/cobra" ) -// updateCheckTemplate renders an update check in text mode. JSON output is -// rendered directly from the versioncheck.Result struct by cmdio. +// updateCheckTemplate renders the version command's text output, including the +// outcome of the update check. const updateCheckTemplate = `Databricks CLI v{{.CurrentVersion}} {{if .DevelopmentBuild -}} This is a development build; skipping the update check. {{- else if .CheckFailed -}} Could not reach GitHub to check for a newer version. See https://github.com/databricks/cli/releases for the latest release. {{- else if .UpdateAvailable -}} -{{yellow "A new version is available"}}: {{.LatestVersion}} (you have {{.CurrentVersion}}). +{{yellow "A new version is available"}}: {{.LatestVersion}} {{if .UpgradeCommand -}} To upgrade, run: {{.UpgradeCommand}} @@ -36,44 +35,19 @@ func New() *cobra.Command { cmd := &cobra.Command{ Use: "version", Args: root.NoArgs, - Short: "Retrieve information about the current version of this CLI", - Annotations: map[string]string{ - "template": "Databricks CLI v{{.Version}}\n", - }, + Short: "Show the CLI version and check whether a newer version is available", } - var check bool - cmd.Flags().BoolVar(&check, "check", false, "Check whether a newer version of the CLI is available") - cmd.RunE = func(cmd *cobra.Command, args []string) error { - if check { - return runUpdateCheck(cmd) + ctx := cmd.Context() + // JSON output keeps the build-info shape scripts already parse and + // stays offline. The update check applies to the human-facing text + // mode only; the --version flag (handled by cobra) stays lightweight. + if root.OutputType(cmd) == flags.OutputJSON { + return cmdio.Render(ctx, build.GetInfo()) } - return cmdio.Render(cmd.Context(), build.GetInfo()) + return cmdio.RenderWithTemplate(ctx, versioncheck.Check(ctx), "", updateCheckTemplate) } - cmd.AddCommand(newCheckCommand()) - - return cmd -} - -func newCheckCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "check", - Args: root.NoArgs, - Short: "Check whether a newer version of the CLI is available", - } - cmd.RunE = func(cmd *cobra.Command, args []string) error { - return runUpdateCheck(cmd) - } return cmd } - -func runUpdateCheck(cmd *cobra.Command) error { - ctx := cmd.Context() - result, err := versioncheck.Check(ctx) - if err != nil { - return fmt.Errorf("check for updates: %w", err) - } - return cmdio.RenderWithTemplate(ctx, result, "", updateCheckTemplate) -} diff --git a/cmd/version/version_test.go b/cmd/version/version_test.go index ce86641bc35..760cc8787a9 100644 --- a/cmd/version/version_test.go +++ b/cmd/version/version_test.go @@ -25,7 +25,7 @@ func TestUpdateCheckTemplate(t *testing.T) { UpgradeCommand: "brew upgrade databricks", }, want: `Databricks CLI v0.240.0 -A new version is available: 0.245.0 (you have 0.240.0). +A new version is available: 0.245.0 To upgrade, run: brew upgrade databricks `, @@ -39,7 +39,7 @@ To upgrade, run: InstallMethod: versioncheck.InstallUnknown, }, want: `Databricks CLI v0.240.0 -A new version is available: 0.245.0 (you have 0.240.0). +A new version is available: 0.245.0 Download the latest release: https://github.com/databricks/cli/releases `, }, diff --git a/libs/versioncheck/versioncheck.go b/libs/versioncheck/versioncheck.go index 565b07793ef..cc2780281e0 100644 --- a/libs/versioncheck/versioncheck.go +++ b/libs/versioncheck/versioncheck.go @@ -32,8 +32,8 @@ const ( // escape hatch); it is not a documented, user-facing setting. gitHubAPIURLEnv = "DATABRICKS_CLI_GITHUB_API_URL" - // fetchTimeout bounds the release lookup. The explicit `version --check` - // waits on it, so keep it short: a quick "couldn't reach GitHub" beats a hang. + // fetchTimeout bounds the release lookup. The `version` command waits on + // it, so keep it short: a quick "couldn't reach GitHub" beats a hang. fetchTimeout = 2 * time.Second ) @@ -72,17 +72,18 @@ type Result struct { } // Check fetches the latest released version and compares it with the running -// build, reporting how to upgrade based on the detected install method. +// build, reporting how to upgrade based on the detected install method. It +// never fails: lookup errors are reported via Result.CheckFailed. // // Development and snapshot builds have no meaningful released version to // compare against, so they short-circuit without a network call. -func Check(ctx context.Context) (*Result, error) { +func Check(ctx context.Context) *Result { info := build.GetInfo() if isDevelopmentBuild(info) { return &Result{ CurrentVersion: info.Version, DevelopmentBuild: true, - }, nil + } } method, command := DetectInstallMethod(ctx) @@ -97,7 +98,7 @@ func Check(ctx context.Context) (*Result, error) { return &Result{ CurrentVersion: info.Version, CheckFailed: true, - }, nil + } } return &Result{ @@ -106,7 +107,7 @@ func Check(ctx context.Context) (*Result, error) { UpdateAvailable: isNewer(info.Version, latest), InstallMethod: method, UpgradeCommand: command, - }, nil + } } // isDevelopmentBuild reports whether the binary was not built from a tagged diff --git a/libs/versioncheck/versioncheck_test.go b/libs/versioncheck/versioncheck_test.go index e2f0dec921f..84089e53171 100644 --- a/libs/versioncheck/versioncheck_test.go +++ b/libs/versioncheck/versioncheck_test.go @@ -7,7 +7,6 @@ import ( "github.com/databricks/cli/internal/build" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestDetectInstallMethod(t *testing.T) { @@ -83,8 +82,7 @@ func TestCheck(t *testing.T) { srv := newReleaseServer(t, http.StatusOK, "v0.245.0") t.Setenv(gitHubAPIURLEnv, srv.URL) - result, err := Check(t.Context()) - require.NoError(t, err) + result := Check(t.Context()) assert.Equal(t, "0.240.0", result.CurrentVersion) assert.Equal(t, "0.245.0", result.LatestVersion) assert.True(t, result.UpdateAvailable) @@ -96,8 +94,7 @@ func TestCheck(t *testing.T) { srv := newReleaseServer(t, http.StatusOK, "v0.245.0") t.Setenv(gitHubAPIURLEnv, srv.URL) - result, err := Check(t.Context()) - require.NoError(t, err) + result := Check(t.Context()) assert.False(t, result.UpdateAvailable) assert.Equal(t, "0.245.0", result.LatestVersion) }) @@ -105,8 +102,7 @@ func TestCheck(t *testing.T) { t.Run("development build skips the network call", func(t *testing.T) { build.SetBuildVersion("0.0.0-dev+abc123") // No server env set: a network call here would fail, proving we skip it. - result, err := Check(t.Context()) - require.NoError(t, err) + result := Check(t.Context()) assert.True(t, result.DevelopmentBuild) assert.False(t, result.UpdateAvailable) assert.Empty(t, result.LatestVersion) @@ -117,8 +113,7 @@ func TestCheck(t *testing.T) { srv := newReleaseServer(t, http.StatusInternalServerError, "") t.Setenv(gitHubAPIURLEnv, srv.URL) - result, err := Check(t.Context()) - require.NoError(t, err) + result := Check(t.Context()) assert.True(t, result.CheckFailed) assert.False(t, result.UpdateAvailable) assert.Empty(t, result.LatestVersion) @@ -132,8 +127,7 @@ func TestCheck(t *testing.T) { srv.Close() t.Setenv(gitHubAPIURLEnv, url) - result, err := Check(t.Context()) - require.NoError(t, err) + result := Check(t.Context()) assert.True(t, result.CheckFailed) }) } From 8ed809b77851933f7df261302b0e229cdf65a432 Mon Sep 17 00:00:00 2001 From: simonfaltum Date: Wed, 10 Jun 2026 13:40:27 +0200 Subject: [PATCH 6/6] Keep bare version lightweight; put the check behind --check only Settled middle ground: `databricks version` matches `databricks --version` exactly (no network call, no output change, original help text restored, which also fixes the help acceptance test). The update check runs only via `version --check`; the `check` subcommand stays removed so there is a single way to invoke it. Co-authored-by: Isaac --- NEXT_CHANGELOG.md | 2 +- acceptance/cmd/version/output.txt | 17 +++++++++++++---- acceptance/cmd/version/script | 11 +++++++---- cmd/version/version.go | 22 ++++++++++++---------- libs/versioncheck/versioncheck.go | 4 ++-- 5 files changed, 35 insertions(+), 21 deletions(-) diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index b4a7f042cd1..fad8bb143be 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -7,7 +7,7 @@ ### CLI * Added the `databricks quickstart` command, a short introduction to the CLI that prints a human-friendly guide interactively and an agent-oriented version when run non-interactively ([#5464](https://github.com/databricks/cli/pull/5464)). -* The `databricks version` command now reports whether a newer CLI version is available and prints the upgrade command for the detected install method. The `--version` flag and `databricks version --output json` are unchanged ([#5469](https://github.com/databricks/cli/pull/5469)). +* Add `databricks version --check` to report whether a newer CLI version is available and print the upgrade command for the detected install method ([#5469](https://github.com/databricks/cli/pull/5469)). ### Bundles * Set the default `data_security_mode` to `DATA_SECURITY_MODE_AUTO` in bundle templates ([#5452](https://github.com/databricks/cli/pull/5452)). diff --git a/acceptance/cmd/version/output.txt b/acceptance/cmd/version/output.txt index f5254103fd4..a52b8c2f754 100644 --- a/acceptance/cmd/version/output.txt +++ b/acceptance/cmd/version/output.txt @@ -1,11 +1,20 @@ -=== version runs the update check +=== version stays lightweight >>> [CLI] version Databricks CLI v[DEV_VERSION] + +=== version --check + +>>> [CLI] version --check +Databricks CLI v[DEV_VERSION] This is a development build; skipping the update check. -=== version --output json keeps the build-info shape and skips the check +=== version --check --output json ->>> [CLI] version --output json -"[DEV_VERSION]" +>>> [CLI] version --check --output json +{ + "current_version": "[DEV_VERSION]", + "update_available": false, + "development_build": true +} diff --git a/acceptance/cmd/version/script b/acceptance/cmd/version/script index f27d5b5d8ae..d4e36422ed0 100644 --- a/acceptance/cmd/version/script +++ b/acceptance/cmd/version/script @@ -1,7 +1,10 @@ +title "version stays lightweight\n" +trace $CLI version + # The acceptance binary is a development build, so the check short-circuits # without contacting GitHub. This exercises command wiring and output rendering. -title "version runs the update check\n" -trace $CLI version +title "version --check\n" +trace $CLI version --check -title "version --output json keeps the build-info shape and skips the check\n" -trace $CLI version --output json | jq .Version +title "version --check --output json\n" +trace $CLI version --check --output json diff --git a/cmd/version/version.go b/cmd/version/version.go index bd6f3011490..146e94fce98 100644 --- a/cmd/version/version.go +++ b/cmd/version/version.go @@ -6,13 +6,12 @@ import ( "github.com/databricks/cli/cmd/root" "github.com/databricks/cli/internal/build" "github.com/databricks/cli/libs/cmdio" - "github.com/databricks/cli/libs/flags" "github.com/databricks/cli/libs/versioncheck" "github.com/spf13/cobra" ) -// updateCheckTemplate renders the version command's text output, including the -// outcome of the update check. +// updateCheckTemplate renders an update check in text mode. JSON output is +// rendered directly from the versioncheck.Result struct by cmdio. const updateCheckTemplate = `Databricks CLI v{{.CurrentVersion}} {{if .DevelopmentBuild -}} This is a development build; skipping the update check. @@ -35,18 +34,21 @@ func New() *cobra.Command { cmd := &cobra.Command{ Use: "version", Args: root.NoArgs, - Short: "Show the CLI version and check whether a newer version is available", + Short: "Retrieve information about the current version of this CLI", + Annotations: map[string]string{ + "template": "Databricks CLI v{{.Version}}\n", + }, } + var check bool + cmd.Flags().BoolVar(&check, "check", false, "Check whether a newer version of the CLI is available") + cmd.RunE = func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - // JSON output keeps the build-info shape scripts already parse and - // stays offline. The update check applies to the human-facing text - // mode only; the --version flag (handled by cobra) stays lightweight. - if root.OutputType(cmd) == flags.OutputJSON { - return cmdio.Render(ctx, build.GetInfo()) + if check { + return cmdio.RenderWithTemplate(ctx, versioncheck.Check(ctx), "", updateCheckTemplate) } - return cmdio.RenderWithTemplate(ctx, versioncheck.Check(ctx), "", updateCheckTemplate) + return cmdio.Render(ctx, build.GetInfo()) } return cmd diff --git a/libs/versioncheck/versioncheck.go b/libs/versioncheck/versioncheck.go index cc2780281e0..ca642ef68e9 100644 --- a/libs/versioncheck/versioncheck.go +++ b/libs/versioncheck/versioncheck.go @@ -32,8 +32,8 @@ const ( // escape hatch); it is not a documented, user-facing setting. gitHubAPIURLEnv = "DATABRICKS_CLI_GITHUB_API_URL" - // fetchTimeout bounds the release lookup. The `version` command waits on - // it, so keep it short: a quick "couldn't reach GitHub" beats a hang. + // fetchTimeout bounds the release lookup. The explicit `version --check` + // waits on it, so keep it short: a quick "couldn't reach GitHub" beats a hang. fetchTimeout = 2 * time.Second )