Skip to content
1 change: 1 addition & 0 deletions .nextchanges/cli/stale-version-warning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* Warn when the CLI binary was built more than 6 months ago and recommend updating. ([#6898](https://github.com/databricks/cli/pull/6898))
2 changes: 2 additions & 0 deletions cmd/root/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ func New(ctx context.Context) *cobra.Command {
slog.String("version", build.GetInfo().Version),
slog.String("args", strings.Join(os.Args, ", ")))

warnIfStaleVersion(ctx)

// Configure our user agent with the command that's about to be executed.
ctx = withCommandInUserAgent(ctx, cmd)
ctx = withCommandExecIdInUserAgent(ctx)
Expand Down
57 changes: 57 additions & 0 deletions cmd/root/stale_version.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package root

import (
"context"
"fmt"
"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/versioncheck"
)

const (
staleVersionThreshold = 6 * 30 * 24 * time.Hour
installDocsURL = "https://docs.databricks.com/dev-tools/cli/install.html"
)

// staleVersionWarning returns the warning for a build older than the threshold, or "" otherwise.
// An empty upgradeCommand falls back to the install docs.
func staleVersionWarning(buildTime, now time.Time, upgradeCommand string) string {
// Builds without an embedded timestamp report the Unix epoch.
if buildTime.Unix() <= 0 || now.Sub(buildTime) < staleVersionThreshold {
return ""
}
msg := fmt.Sprintf("Warning: this version of the Databricks CLI was built on %s and is more than 6 months old. "+
"We strongly recommend updating to the latest version.\n", buildTime.UTC().Format(time.DateOnly))
if upgradeCommand != "" {
msg += "To upgrade, run: " + upgradeCommand + "\n"
} else {
msg += "See " + installDocsURL + " to upgrade.\n"
}
return msg + "To silence this warning, set " + versioncheck.DisableEnv + "=1.\n"
}

func skipStaleVersionWarning(ctx context.Context, info build.Info) bool {
if info.IsDevelopment() {
return true
}
if disabled, _ := env.GetBool(ctx, versioncheck.DisableEnv); disabled {
return true
}
// DBR installs the latest CLI by default, so skip the warning there.
return dbr.HasDetection(ctx) && dbr.RunsOnRuntime(ctx)
}

func warnIfStaleVersion(ctx context.Context) {
info := build.GetInfo()
if skipStaleVersionWarning(ctx, info) {
return
}
_, command := versioncheck.DetectInstallMethod(ctx)
if msg := staleVersionWarning(info.BuildTime, time.Now(), command); msg != "" {
cmdio.LogString(ctx, msg)
}
}
76 changes: 76 additions & 0 deletions cmd/root/stale_version_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package root

import (
"testing"
"time"

"github.com/databricks/cli/internal/build"
"github.com/databricks/cli/libs/dbr"
"github.com/databricks/cli/libs/env"
"github.com/databricks/cli/libs/versioncheck"
"github.com/stretchr/testify/assert"
)

func TestStaleVersionWarning(t *testing.T) {
now := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
stale := time.Date(2026, 1, 15, 12, 0, 0, 0, time.UTC)
header := "Warning: this version of the Databricks CLI was built on 2026-01-15 and is more than 6 months old. " +
"We strongly recommend updating to the latest version.\n"
footer := "To silence this warning, set DATABRICKS_CLI_DISABLE_UPDATE_CHECK=1.\n"

tests := []struct {
name string
buildTime time.Time
command string
want string
}{
{name: "no embedded timestamp", buildTime: time.Unix(0, 0), command: "brew upgrade databricks"},
{name: "recent build", buildTime: now.AddDate(0, -1, 0), command: "brew upgrade databricks"},
{name: "just under threshold", buildTime: now.Add(-staleVersionThreshold + time.Hour)},
{
name: "stale with detected install method",
buildTime: stale,
command: "brew upgrade databricks",
want: header + "To upgrade, run: brew upgrade databricks\n" + footer,
},
{
name: "stale with unknown install method",
buildTime: stale,
want: header + "See " + installDocsURL + " to upgrade.\n" + footer,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, staleVersionWarning(tc.buildTime, now, tc.command))
})
}
}

func TestSkipStaleVersionWarning(t *testing.T) {
release := build.Info{Version: "1.18.0"}
notDBR := dbr.Environment{}

tests := []struct {
name string
info build.Info
runtime dbr.Environment
env string
want bool
}{
{name: "release build", info: release, runtime: notDBR},
{name: "dev build", info: build.Info{Version: "1.19.0-dev+abc"}, runtime: notDBR, want: true},
{name: "snapshot build", info: build.Info{Version: "1.18.0", IsSnapshot: true}, runtime: notDBR, want: true},
{name: "silenced by env var", info: release, runtime: notDBR, env: "1", want: true},
{name: "env var set to false", info: release, runtime: notDBR, env: "false"},
{name: "on DBR", info: release, runtime: dbr.Environment{IsDbr: true, Version: "15.4"}, want: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ctx := dbr.MockRuntime(t.Context(), tc.runtime)
if tc.env != "" {
ctx = env.Set(ctx, versioncheck.DisableEnv, tc.env)
}
assert.Equal(t, tc.want, skipStaleVersionWarning(ctx, tc.info))
})
}
}
6 changes: 3 additions & 3 deletions libs/versioncheck/notice.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ const (
latestFingerprint = "latest-release"
notifiedFingerprint = "notified"

// disableEnv is an explicit opt-out for the passive notice.
disableEnv = "DATABRICKS_CLI_DISABLE_UPDATE_CHECK"
// DisableEnv is an explicit opt-out for the passive notice and the stale-version warning.
DisableEnv = "DATABRICKS_CLI_DISABLE_UPDATE_CHECK"

// cacheEnabledEnv mirrors libs/cache's env knob. We read it directly to
// skip the check entirely when caching is turned off: with the cache in
Expand Down Expand Up @@ -242,7 +242,7 @@ func notifyEnabled(ctx context.Context, cmd *cobra.Command) bool {
}

func gatherConditions(ctx context.Context, cmd *cobra.Command) notifyConditions {
optedOut, _ := env.GetBool(ctx, disableEnv)
optedOut, _ := env.GetBool(ctx, DisableEnv)
ci, _ := env.GetBool(ctx, ciEnv)
cacheDisabled := false
if enabled, ok := env.GetBool(ctx, cacheEnabledEnv); ok && !enabled {
Expand Down
14 changes: 7 additions & 7 deletions libs/versioncheck/notice_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,40 +75,40 @@ func TestGatherConditions(t *testing.T) {

t.Run("all clear for an interactive release build", func(t *testing.T) {
t.Setenv(ciEnv, "false")
t.Setenv(disableEnv, "false")
t.Setenv(DisableEnv, "false")
assert.True(t, shouldNotify(gatherConditions(interactiveContext(t), regularCmd(t))))
})

t.Run("CI env", func(t *testing.T) {
t.Setenv(ciEnv, "true")
t.Setenv(disableEnv, "false")
t.Setenv(DisableEnv, "false")
assert.True(t, gatherConditions(interactiveContext(t), regularCmd(t)).ci)
})

t.Run("opt-out env", func(t *testing.T) {
t.Setenv(ciEnv, "false")
t.Setenv(disableEnv, "true")
t.Setenv(DisableEnv, "true")
assert.True(t, gatherConditions(interactiveContext(t), regularCmd(t)).optedOut)
})

t.Run("cache disabled", func(t *testing.T) {
t.Setenv(ciEnv, "false")
t.Setenv(disableEnv, "false")
t.Setenv(DisableEnv, "false")
t.Setenv(cacheEnabledEnv, "false")
assert.True(t, gatherConditions(interactiveContext(t), regularCmd(t)).cacheDisabled)
})

t.Run("json output", func(t *testing.T) {
t.Setenv(ciEnv, "false")
t.Setenv(disableEnv, "false")
t.Setenv(DisableEnv, "false")
c := regularCmd(t)
require.NoError(t, c.Flags().Set("output", "json"))
assert.True(t, gatherConditions(interactiveContext(t), c).jsonOutput)
})

t.Run("non-interactive", func(t *testing.T) {
t.Setenv(ciEnv, "false")
t.Setenv(disableEnv, "false")
t.Setenv(DisableEnv, "false")
ctx, _ := cmdio.NewTestContextWithStdout(t.Context())
ctx = dbr.MockRuntime(ctx, dbr.Environment{})
assert.True(t, gatherConditions(ctx, regularCmd(t)).nonInteractive)
Expand All @@ -118,7 +118,7 @@ func TestGatherConditions(t *testing.T) {
// Help invocations skip PersistentPreRunE (no cmdio) and tests execute
// commands without root.Execute (no dbr detection); neither may panic.
t.Setenv(ciEnv, "false")
t.Setenv(disableEnv, "false")
t.Setenv(DisableEnv, "false")
c := gatherConditions(t.Context(), regularCmd(t))
assert.True(t, c.nonInteractive)
assert.False(t, c.onRuntime)
Expand Down
Loading