From b4442852ccc5bd5fbf01e0235c85752cfed6f97e Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Tue, 9 Jun 2026 16:13:40 +0200 Subject: [PATCH 1/4] Recommend upgrading the CLI when a newer release is available Print a short advisory on stderr when the installed CLI is older than the latest GitHub release, modeled on `gh`'s update notifier. The check never adds latency to a command: every run reads the latest known version from a local cache (~/.databricks/cli-version-check.json). The GitHub API is only contacted by a background goroutine, and only when the cache is older than 24h; the notice is always rendered from the cache. The cache write is atomic, so a short-lived process can't corrupt it. The notice is suppressed unless it is actionable and wanted: only for released builds, interactive text output (TTY), and not on Databricks Runtime or in CI. Co-authored-by: Isaac --- cmd/root/root.go | 9 + cmd/root/upgrade_notice.go | 117 +++++++++++++ cmd/root/upgrade_notice_test.go | 48 ++++++ libs/upgradecheck/upgradecheck.go | 217 +++++++++++++++++++++++++ libs/upgradecheck/upgradecheck_test.go | 113 +++++++++++++ 5 files changed, 504 insertions(+) create mode 100644 cmd/root/upgrade_notice.go create mode 100644 cmd/root/upgrade_notice_test.go create mode 100644 libs/upgradecheck/upgradecheck.go create mode 100644 libs/upgradecheck/upgradecheck_test.go diff --git a/cmd/root/root.go b/cmd/root/root.go index 6b6de2a9baa..0fd5f2d0f91 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -81,6 +81,11 @@ func New(ctx context.Context) *cobra.Command { ctx = withInteractiveModeInUserAgent(ctx) ctx = InjectTestPidToUserAgent(ctx) cmd.SetContext(ctx) + + // Refresh the cached latest-version record in the background if it is + // stale. This never blocks the command; the upgrade notice is printed + // from the cache after the command succeeds. + startUpgradeCheck(ctx, cmd) return nil } @@ -155,6 +160,10 @@ Stack Trace: // Log exit status and error // We only log if logger initialization succeeded and is stored in command // context + if err == nil { + printUpgradeNotice(cmd.Context(), cmd) + } + if logger, ok := log.FromContext(cmd.Context()); ok { if err == nil { logger.Info("completed execution", diff --git a/cmd/root/upgrade_notice.go b/cmd/root/upgrade_notice.go new file mode 100644 index 00000000000..7e568af10d3 --- /dev/null +++ b/cmd/root/upgrade_notice.go @@ -0,0 +1,117 @@ +package root + +import ( + "context" + "fmt" + "path/filepath" + "time" + + "github.com/databricks/cli/internal/build" + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/dbr" + "github.com/databricks/cli/libs/env" + "github.com/databricks/cli/libs/flags" + "github.com/databricks/cli/libs/log" + "github.com/databricks/cli/libs/upgradecheck" + "github.com/spf13/cobra" +) + +const versionCheckCacheName = "cli-version-check.json" + +// startUpgradeCheck refreshes the cached latest-version record in the background +// when it is stale. It never blocks the command: the refresh runs in a goroutine +// and the upgrade notice (printed later by [printUpgradeNotice]) is rendered from +// whatever the cache holds when the command finishes. +func startUpgradeCheck(ctx context.Context, cmd *cobra.Command) { + if !upgradeCheckEnabled(ctx, cmd) { + return + } + cacheFile, err := versionCheckCacheFile(ctx) + if err != nil { + log.Debugf(ctx, "cli version check: %v", err) + return + } + if !upgradecheck.Stale(cacheFile, time.Now()) { + return + } + go func() { + if err := upgradecheck.Refresh(ctx, cacheFile); err != nil { + log.Debugf(ctx, "cli version check refresh failed: %v", err) + } + }() +} + +// printUpgradeNotice prints a short advisory to stderr when a newer CLI release +// is available, based on the cached latest version. It is a no-op unless the +// check is enabled for this invocation. Callers must only invoke it after the +// command has succeeded, so an upgrade hint is never stacked on top of an error. +func printUpgradeNotice(ctx context.Context, cmd *cobra.Command) { + if !upgradeCheckEnabled(ctx, cmd) { + return + } + cacheFile, err := versionCheckCacheFile(ctx) + if err != nil { + return + } + current := build.GetInfo().Version + latest, url, ok := upgradecheck.Outdated(cacheFile, current) + if !ok { + return + } + cmd.PrintErrln(cmdio.Yellow(ctx, upgradeNoticeMessage(current, latest, url))) +} + +// upgradeNoticeMessage formats the advisory shown when a newer release exists. +// The leading blank line separates it from the command's own output. +func upgradeNoticeMessage(current, latest, url string) string { + return fmt.Sprintf("\nA new release of the Databricks CLI is available: %s → %s\n%s", + trimV(current), trimV(latest), url) +} + +// upgradeCheckEnabled reports whether the outdated-version check should run for +// this invocation. It is suppressed in every context where the notice would be +// noise or where the user cannot act on it. +func upgradeCheckEnabled(ctx context.Context, cmd *cobra.Command) bool { + // Released builds only; there is nothing to upgrade a dev/snapshot build to. + // Returning early also avoids inspecting command flags for dev builds. + if !upgradecheck.IsReleaseVersion(build.GetInfo().Version) { + return false + } + return shouldNotify(notifyConditions{ + onRuntime: dbr.RunsOnRuntime(ctx), + textOutput: OutputType(cmd) == flags.OutputText, + isTTY: cmdio.IsOutputTTY(cmd.ErrOrStderr()), + // Explicit guard for CI runners that allocate a TTY. Virtually all CI + // providers set CI; see https://docs.github.com/actions/learn-github-actions/variables + isCI: env.Get(ctx, "CI") != "", + }) +} + +// notifyConditions captures the environment inputs that decide whether the +// outdated-version notice should run for a released build. +type notifyConditions struct { + onRuntime bool // on Databricks Runtime: version is platform-managed + textOutput bool // machine-readable output is kept clean for scripts + isTTY bool // interactive terminal: also excludes pipes/redirects/most CI + isCI bool // continuous integration +} + +// shouldNotify reports whether to run the version check for the given conditions. +func shouldNotify(c notifyConditions) bool { + return c.textOutput && c.isTTY && !c.onRuntime && !c.isCI +} + +func versionCheckCacheFile(ctx context.Context) (string, error) { + home, err := env.UserHomeDir(ctx) + if err != nil { + return "", err + } + return filepath.Join(home, ".databricks", versionCheckCacheName), nil +} + +func trimV(version string) string { + if len(version) > 0 && version[0] == 'v' { + return version[1:] + } + return version +} diff --git a/cmd/root/upgrade_notice_test.go b/cmd/root/upgrade_notice_test.go new file mode 100644 index 00000000000..f9c9a50b6bb --- /dev/null +++ b/cmd/root/upgrade_notice_test.go @@ -0,0 +1,48 @@ +package root + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestShouldNotify(t *testing.T) { + // The enabled baseline: interactive text output, released build, not CI, not on DBR. + enabled := notifyConditions{textOutput: true, isTTY: true} + + tests := []struct { + name string + c notifyConditions + want bool + }{ + {"enabled", enabled, true}, + {"on databricks runtime", with(enabled, func(c *notifyConditions) { c.onRuntime = true }), false}, + {"json output", with(enabled, func(c *notifyConditions) { c.textOutput = false }), false}, + {"not a tty", with(enabled, func(c *notifyConditions) { c.isTTY = false }), false}, + {"ci", with(enabled, func(c *notifyConditions) { c.isCI = true }), false}, + {"nothing set", notifyConditions{}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, shouldNotify(tt.c)) + }) + } +} + +func with(c notifyConditions, mut func(*notifyConditions)) notifyConditions { + mut(&c) + return c +} + +func TestUpgradeNoticeMessage(t *testing.T) { + got := upgradeNoticeMessage("0.230.0", "v0.245.0", "https://github.com/databricks/cli/releases/tag/v0.245.0") + want := "\nA new release of the Databricks CLI is available: 0.230.0 → 0.245.0\n" + + "https://github.com/databricks/cli/releases/tag/v0.245.0" + assert.Equal(t, want, got) +} + +func TestTrimV(t *testing.T) { + assert.Equal(t, "0.245.0", trimV("v0.245.0")) + assert.Equal(t, "0.245.0", trimV("0.245.0")) + assert.Empty(t, trimV("")) +} diff --git a/libs/upgradecheck/upgradecheck.go b/libs/upgradecheck/upgradecheck.go new file mode 100644 index 00000000000..2d4ae3ec5b7 --- /dev/null +++ b/libs/upgradecheck/upgradecheck.go @@ -0,0 +1,217 @@ +// Package upgradecheck implements a best-effort "your CLI is outdated" check. +// +// The design goal is that the check must never add latency to a command. Every +// invocation reads the latest known version from an on-disk cache (a synchronous +// local read, no network). The GitHub API is only contacted by [Refresh], which +// the caller runs in a background goroutine when the cache is [Stale]. The +// upgrade notice is always rendered from the cache, so even the run that +// refreshes it does not block on the network. +package upgradecheck + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "golang.org/x/mod/semver" +) + +const ( + // checkInterval throttles how often we hit the GitHub API. The latest + // version is served from the on-disk cache between refreshes. + checkInterval = 24 * time.Hour + + // fetchTimeout bounds the background refresh so it can never run unbounded. + fetchTimeout = 3 * time.Second + + // defaultBaseURL is the GitHub REST API root. Overridable in tests via [WithBaseURL]. + defaultBaseURL = "https://api.github.com" + + // releasePath is the "latest release" endpoint for the CLI repository. + releasePath = "/repos/databricks/cli/releases/latest" + + // Cache file is readable and writable by the owner only. + cacheFilePerm = 0o600 + cacheDirPerm = 0o755 +) + +// baseURLKey is the context key used to override the GitHub API root in tests. +type baseURLKey struct{} + +// WithBaseURL overrides the GitHub API root used by [Refresh]. Intended for tests. +func WithBaseURL(ctx context.Context, url string) context.Context { + return context.WithValue(ctx, baseURLKey{}, url) +} + +func baseURL(ctx context.Context) string { + if v, ok := ctx.Value(baseURLKey{}).(string); ok { + return v + } + return defaultBaseURL +} + +// release mirrors the subset of the GitHub release payload that we use. +type release struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` +} + +// cacheEntry is the on-disk record stored at the cache file path. +type cacheEntry struct { + CheckedAt time.Time `json:"checked_at"` + LatestVersion string `json:"latest_version"` + LatestURL string `json:"latest_url"` +} + +// Stale reports whether the cache should be refreshed: it is missing, unreadable, +// or older than [checkInterval]. An unreadable cache is treated as stale so a +// corrupt file self-heals on the next refresh. +func Stale(cacheFile string, now time.Time) bool { + entry, ok, err := readCache(cacheFile) + if err != nil || !ok { + return true + } + return now.Sub(entry.CheckedAt) > checkInterval +} + +// Refresh fetches the latest release from GitHub and writes it to the cache file. +// It is meant to be called from a background goroutine; the write is atomic so a +// cancelled or short-lived process can never leave a corrupt cache behind. +func Refresh(ctx context.Context, cacheFile string) error { + ctx, cancel := context.WithTimeout(ctx, fetchTimeout) + defer cancel() + + rel, err := fetchLatest(ctx) + if err != nil { + return err + } + + return writeCache(cacheFile, cacheEntry{ + CheckedAt: time.Now(), + LatestVersion: rel.TagName, + LatestURL: rel.HTMLURL, + }) +} + +// Outdated reports whether the cached latest version is newer than currentVersion. +// It returns the latest version and its release URL when an upgrade is available. +func Outdated(cacheFile, currentVersion string) (latestVersion, latestURL string, ok bool) { + entry, found, err := readCache(cacheFile) + if err != nil || !found { + return "", "", false + } + if !isNewer(entry.LatestVersion, currentVersion) { + return "", "", false + } + return entry.LatestVersion, entry.LatestURL, true +} + +func fetchLatest(ctx context.Context) (release, error) { + url := baseURL(ctx) + releasePath + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return release{}, err + } + // Recommended by GitHub for REST API requests. + // https://docs.github.com/en/rest/using-the-rest-api/getting-started-with-the-rest-api#about-the-rest-api + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return release{}, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return release{}, fmt.Errorf("github request failed: %s", resp.Status) + } + + var rel release + if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { + return release{}, fmt.Errorf("decode github response: %w", err) + } + return rel, nil +} + +func readCache(cacheFile string) (cacheEntry, bool, error) { + raw, err := os.ReadFile(cacheFile) + if errors.Is(err, fs.ErrNotExist) { + return cacheEntry{}, false, nil + } + if err != nil { + return cacheEntry{}, false, err + } + var entry cacheEntry + if err := json.Unmarshal(raw, &entry); err != nil { + return cacheEntry{}, false, err + } + return entry, true, nil +} + +// writeCache writes the cache entry atomically: it writes to a temporary file in +// the same directory and renames it into place, so a concurrent reader never +// observes a partially written file. +func writeCache(cacheFile string, entry cacheEntry) error { + raw, err := json.MarshalIndent(entry, "", " ") + if err != nil { + return err + } + + dir := filepath.Dir(cacheFile) + if err := os.MkdirAll(dir, cacheDirPerm); err != nil { + return err + } + + tmp, err := os.CreateTemp(dir, filepath.Base(cacheFile)+".*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) // no-op once the rename succeeds + + if _, err := tmp.Write(raw); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Chmod(tmpName, cacheFilePerm); err != nil { + return err + } + return os.Rename(tmpName, cacheFile) +} + +// IsReleaseVersion reports whether version is a published release: valid semver +// with no prerelease segment. Dev builds and goreleaser snapshots carry a "-dev" +// prerelease (e.g. "0.0.0-dev", "0.230.1-dev+abc123"), so they return false — +// there is no newer release to point such a build at. +func IsReleaseVersion(version string) bool { + v := normalize(version) + return semver.IsValid(v) && semver.Prerelease(v) == "" +} + +// isNewer reports whether latest is a strictly higher semantic version than +// current. Build versions have no "v" prefix while GitHub tags do, so both are +// normalized before comparison. Unparseable versions are treated as not newer. +func isNewer(latest, current string) bool { + l := normalize(latest) + c := normalize(current) + if !semver.IsValid(l) || !semver.IsValid(c) { + return false + } + return semver.Compare(l, c) > 0 +} + +func normalize(v string) string { + if v == "" || strings.HasPrefix(v, "v") { + return v + } + return "v" + v +} diff --git a/libs/upgradecheck/upgradecheck_test.go b/libs/upgradecheck/upgradecheck_test.go new file mode 100644 index 00000000000..a6c5e91e441 --- /dev/null +++ b/libs/upgradecheck/upgradecheck_test.go @@ -0,0 +1,113 @@ +package upgradecheck_test + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/databricks/cli/libs/upgradecheck" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newServer returns an httptest server that serves a single GitHub "latest +// release" payload, plus a context pointed at it. +func newServer(t *testing.T, tag, htmlURL string) string { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/repos/databricks/cli/releases/latest", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"tag_name":"` + tag + `","html_url":"` + htmlURL + `"}`)) + })) + t.Cleanup(srv.Close) + return srv.URL +} + +func TestStaleWhenCacheMissing(t *testing.T) { + cacheFile := filepath.Join(t.TempDir(), "cli-version-check.json") + assert.True(t, upgradecheck.Stale(cacheFile, time.Now())) +} + +func TestRefreshAndOutdated(t *testing.T) { + ctx := upgradecheck.WithBaseURL(t.Context(), newServer(t, "v0.245.0", "https://example.test/releases/tag/v0.245.0")) + cacheFile := filepath.Join(t.TempDir(), "cli-version-check.json") + + require.NoError(t, upgradecheck.Refresh(ctx, cacheFile)) + + // Cache is fresh right after a refresh. + assert.False(t, upgradecheck.Stale(cacheFile, time.Now())) + + latest, url, ok := upgradecheck.Outdated(cacheFile, "0.230.0") + assert.True(t, ok) + assert.Equal(t, "v0.245.0", latest) + assert.Equal(t, "https://example.test/releases/tag/v0.245.0", url) +} + +func TestOutdatedFalseWhenUpToDate(t *testing.T) { + ctx := upgradecheck.WithBaseURL(t.Context(), newServer(t, "v0.245.0", "https://example.test/x")) + cacheFile := filepath.Join(t.TempDir(), "cli-version-check.json") + require.NoError(t, upgradecheck.Refresh(ctx, cacheFile)) + + _, _, ok := upgradecheck.Outdated(cacheFile, "0.245.0") + assert.False(t, ok, "same version is not outdated") + + _, _, ok = upgradecheck.Outdated(cacheFile, "0.250.0") + assert.False(t, ok, "newer-than-latest local build is not outdated") +} + +func TestOutdatedFalseOnMissingCache(t *testing.T) { + cacheFile := filepath.Join(t.TempDir(), "cli-version-check.json") + _, _, ok := upgradecheck.Outdated(cacheFile, "0.230.0") + assert.False(t, ok) +} + +func TestStaleByInterval(t *testing.T) { + ctx := upgradecheck.WithBaseURL(t.Context(), newServer(t, "v0.245.0", "https://example.test/x")) + cacheFile := filepath.Join(t.TempDir(), "cli-version-check.json") + require.NoError(t, upgradecheck.Refresh(ctx, cacheFile)) + + // Just under and just over the 24h throttle. + assert.False(t, upgradecheck.Stale(cacheFile, time.Now().Add(23*time.Hour))) + assert.True(t, upgradecheck.Stale(cacheFile, time.Now().Add(25*time.Hour))) +} + +func TestRefreshErrorOnServerFailure(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(srv.Close) + ctx := upgradecheck.WithBaseURL(t.Context(), srv.URL) + cacheFile := filepath.Join(t.TempDir(), "cli-version-check.json") + + err := upgradecheck.Refresh(ctx, cacheFile) + require.Error(t, err) + // A failed refresh must not create a cache file. + _, statErr := os.Stat(cacheFile) + assert.True(t, os.IsNotExist(statErr)) +} + +func TestCorruptCacheIsStaleAndNotOutdated(t *testing.T) { + cacheFile := filepath.Join(t.TempDir(), "cli-version-check.json") + require.NoError(t, os.WriteFile(cacheFile, []byte("not json"), 0o600)) + + assert.True(t, upgradecheck.Stale(cacheFile, time.Now())) + _, _, ok := upgradecheck.Outdated(cacheFile, "0.230.0") + assert.False(t, ok) +} + +func TestIsReleaseVersion(t *testing.T) { + tests := map[string]bool{ + "0.230.0": true, + "v0.230.0": true, + "0.0.0-dev": false, + "0.230.1-dev+abc1": false, + "": false, + "not-a-version": false, + } + for version, want := range tests { + assert.Equal(t, want, upgradecheck.IsReleaseVersion(version), version) + } +} From 0af75c19c22ecd8631a7839db9145226d78cbcd3 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Tue, 9 Jun 2026 16:14:17 +0200 Subject: [PATCH 2/4] Add changelog entry Co-authored-by: Isaac --- NEXT_CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 16af8be7550..4a5ae1db4c2 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -5,6 +5,7 @@ ### Notable Changes ### CLI +* Recommend upgrading when a newer CLI release is available ([#5482](https://github.com/databricks/cli/pull/5482)). ### Bundles * Set the default `data_security_mode` to `DATA_SECURITY_MODE_AUTO` in bundle templates ([#5452](https://github.com/databricks/cli/pull/5452)). From e3b01c6269e9097f89e20709501065e7958f03ae Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Tue, 9 Jun 2026 18:24:32 +0200 Subject: [PATCH 3/4] Add install-aware upgrade hint; fix lint - Append an OS/install-method aware upgrade command to the notice: `brew upgrade databricks` for Homebrew installs (detected from the binary location), `winget upgrade Databricks.DatabricksCLI` on Windows, otherwise the install script. - Make the once-per-day cache TTL on the background refresh explicit. - A failed refresh (e.g. no network) stays silent; it is not surfaced. - Fix CI lint: use errors.Is(fs.ErrNotExist) / assert.ErrorIs in tests. Co-authored-by: Isaac --- cmd/root/upgrade_notice.go | 53 +++++++++++++++++++++++--- cmd/root/upgrade_notice_test.go | 16 +++++++- libs/upgradecheck/upgradecheck.go | 5 ++- libs/upgradecheck/upgradecheck_test.go | 3 +- 4 files changed, 66 insertions(+), 11 deletions(-) diff --git a/cmd/root/upgrade_notice.go b/cmd/root/upgrade_notice.go index 7e568af10d3..9c53bc277e3 100644 --- a/cmd/root/upgrade_notice.go +++ b/cmd/root/upgrade_notice.go @@ -3,7 +3,10 @@ package root import ( "context" "fmt" + "os" "path/filepath" + "runtime" + "strings" "time" "github.com/databricks/cli/internal/build" @@ -18,10 +21,15 @@ import ( const versionCheckCacheName = "cli-version-check.json" +// installScriptCommand upgrades a CLI installed via the official install script. +// https://docs.databricks.com/dev-tools/cli/install.html +const installScriptCommand = "curl -fsSL https://raw.githubusercontent.com/databricks/setup-cli/main/install.sh | sh" + // startUpgradeCheck refreshes the cached latest-version record in the background // when it is stale. It never blocks the command: the refresh runs in a goroutine // and the upgrade notice (printed later by [printUpgradeNotice]) is rendered from -// whatever the cache holds when the command finishes. +// whatever the cache holds when the command finishes. A failed refresh (e.g. no +// network) is intentionally silent; it is not surfaced as an error. func startUpgradeCheck(ctx context.Context, cmd *cobra.Command) { if !upgradeCheckEnabled(ctx, cmd) { return @@ -31,12 +39,14 @@ func startUpgradeCheck(ctx context.Context, cmd *cobra.Command) { log.Debugf(ctx, "cli version check: %v", err) return } + // Only contact GitHub when the cached record is older than the TTL, so the + // network is touched at most once per [upgradecheck] check interval. if !upgradecheck.Stale(cacheFile, time.Now()) { return } go func() { if err := upgradecheck.Refresh(ctx, cacheFile); err != nil { - log.Debugf(ctx, "cli version check refresh failed: %v", err) + log.Debugf(ctx, "cli version check refresh skipped: %v", err) } }() } @@ -58,14 +68,45 @@ func printUpgradeNotice(ctx context.Context, cmd *cobra.Command) { if !ok { return } - cmd.PrintErrln(cmdio.Yellow(ctx, upgradeNoticeMessage(current, latest, url))) + cmd.PrintErrln(cmdio.Yellow(ctx, upgradeNoticeMessage(current, latest, url, upgradeCommand()))) } // upgradeNoticeMessage formats the advisory shown when a newer release exists. // The leading blank line separates it from the command's own output. -func upgradeNoticeMessage(current, latest, url string) string { - return fmt.Sprintf("\nA new release of the Databricks CLI is available: %s → %s\n%s", - trimV(current), trimV(latest), url) +func upgradeNoticeMessage(current, latest, url, upgradeCmd string) string { + return fmt.Sprintf( + "\nA new release of the Databricks CLI is available: %s → %s\n%s\nTo upgrade, run: %s", + trimV(current), trimV(latest), url, upgradeCmd) +} + +// upgradeCommand returns the command that upgrades the current installation. It +// detects a Homebrew install from the binary location; otherwise it falls back +// to the documented per-OS installer: WinGet on Windows, the install script +// elsewhere. All are listed at https://docs.databricks.com/dev-tools/cli/install.html +func upgradeCommand() string { + switch { + case installedViaHomebrew(): + return "brew upgrade databricks" + case runtime.GOOS == "windows": + return "winget upgrade Databricks.DatabricksCLI" + default: + return installScriptCommand + } +} + +// installedViaHomebrew reports whether the running binary is a Homebrew install. +// Homebrew places formula binaries under a "Cellar" directory on every platform +// (e.g. /opt/homebrew/Cellar, /usr/local/Cellar, Linuxbrew's Cellar) and the +// command on PATH is a symlink into it, so symlinks are resolved before checking. +func installedViaHomebrew() bool { + exe, err := os.Executable() + if err != nil { + return false + } + if resolved, err := filepath.EvalSymlinks(exe); err == nil { + exe = resolved + } + return strings.Contains(filepath.ToSlash(exe), "/Cellar/") } // upgradeCheckEnabled reports whether the outdated-version check should run for diff --git a/cmd/root/upgrade_notice_test.go b/cmd/root/upgrade_notice_test.go index f9c9a50b6bb..584297490bc 100644 --- a/cmd/root/upgrade_notice_test.go +++ b/cmd/root/upgrade_notice_test.go @@ -35,12 +35,24 @@ func with(c notifyConditions, mut func(*notifyConditions)) notifyConditions { } func TestUpgradeNoticeMessage(t *testing.T) { - got := upgradeNoticeMessage("0.230.0", "v0.245.0", "https://github.com/databricks/cli/releases/tag/v0.245.0") + got := upgradeNoticeMessage("0.230.0", "v0.245.0", + "https://github.com/databricks/cli/releases/tag/v0.245.0", "brew upgrade databricks") want := "\nA new release of the Databricks CLI is available: 0.230.0 → 0.245.0\n" + - "https://github.com/databricks/cli/releases/tag/v0.245.0" + "https://github.com/databricks/cli/releases/tag/v0.245.0\n" + + "To upgrade, run: brew upgrade databricks" assert.Equal(t, want, got) } +func TestUpgradeCommand(t *testing.T) { + // The returned command must be one of the documented upgrade methods. + valid := map[string]bool{ + "brew upgrade databricks": true, + "winget upgrade Databricks.DatabricksCLI": true, + installScriptCommand: true, + } + assert.True(t, valid[upgradeCommand()], "unexpected upgrade command: %q", upgradeCommand()) +} + func TestTrimV(t *testing.T) { assert.Equal(t, "0.245.0", trimV("v0.245.0")) assert.Equal(t, "0.245.0", trimV("0.245.0")) diff --git a/libs/upgradecheck/upgradecheck.go b/libs/upgradecheck/upgradecheck.go index 2d4ae3ec5b7..846f9b1c469 100644 --- a/libs/upgradecheck/upgradecheck.go +++ b/libs/upgradecheck/upgradecheck.go @@ -24,8 +24,9 @@ import ( ) const ( - // checkInterval throttles how often we hit the GitHub API. The latest - // version is served from the on-disk cache between refreshes. + // checkInterval is the cache TTL: the GitHub API is refreshed at most once + // per day. Between refreshes the latest version is served from the on-disk + // cache, so the common path performs no network I/O. checkInterval = 24 * time.Hour // fetchTimeout bounds the background refresh so it can never run unbounded. diff --git a/libs/upgradecheck/upgradecheck_test.go b/libs/upgradecheck/upgradecheck_test.go index a6c5e91e441..91c1003a290 100644 --- a/libs/upgradecheck/upgradecheck_test.go +++ b/libs/upgradecheck/upgradecheck_test.go @@ -1,6 +1,7 @@ package upgradecheck_test import ( + "io/fs" "net/http" "net/http/httptest" "os" @@ -86,7 +87,7 @@ func TestRefreshErrorOnServerFailure(t *testing.T) { require.Error(t, err) // A failed refresh must not create a cache file. _, statErr := os.Stat(cacheFile) - assert.True(t, os.IsNotExist(statErr)) + assert.ErrorIs(t, statErr, fs.ErrNotExist) } func TestCorruptCacheIsStaleAndNotOutdated(t *testing.T) { From 0f498fbd0c98b7bc9c21abc01deb995d3a7f8161 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Tue, 9 Jun 2026 18:29:15 +0200 Subject: [PATCH 4/4] Fix misleading comment placement around upgrade notice Co-authored-by: Isaac --- cmd/root/root.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cmd/root/root.go b/cmd/root/root.go index 0fd5f2d0f91..06c49cdd310 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -157,13 +157,14 @@ Stack Trace: fmt.Fprintf(cmd.ErrOrStderr(), "Error: %s\n", err.Error()) } - // Log exit status and error - // We only log if logger initialization succeeded and is stored in command - // context + // On success, advise the user if a newer CLI release is available. if err == nil { printUpgradeNotice(cmd.Context(), cmd) } + // Log exit status and error + // We only log if logger initialization succeeded and is stored in command + // context if logger, ok := log.FromContext(cmd.Context()); ok { if err == nil { logger.Info("completed execution",