diff --git a/Dockerfile b/Dockerfile
index 71814c26b..bb8102961 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -75,6 +75,10 @@ RUN apk add --no-cache ca-certificates docker-cli && \
ARG TARGETOS TARGETARCH
ENV DOCKER_MCP_IN_CONTAINER=1
ENV TERM=xterm-256color
+# Disable the once-a-day GitHub release check inside the container image:
+# images are tagged immutably and can't self-upgrade, so the hint would be
+# misleading and waste a network round-trip on every `run`.
+ENV DOCKER_AGENT_DISABLE_VERSION_CHECK=1
COPY --from=docker/mcp-gateway:v2 /docker-mcp /usr/local/lib/docker/cli-plugins/
COPY --from=builder-linux /binaries/docker-agent-$TARGETOS-$TARGETARCH /docker-agent
USER docker-agent
diff --git a/cmd/root/run.go b/cmd/root/run.go
index 4832ee2d4..831817a0a 100644
--- a/cmd/root/run.go
+++ b/cmd/root/run.go
@@ -29,6 +29,7 @@ import (
"github.com/docker/docker-agent/pkg/tui"
"github.com/docker/docker-agent/pkg/tui/styles"
"github.com/docker/docker-agent/pkg/userconfig"
+ "github.com/docker/docker-agent/pkg/version/check"
)
type runExecFlags struct {
@@ -145,6 +146,13 @@ func (f *runExecFlags) runRunCommand(cmd *cobra.Command, args []string) (command
}()
}
+ // Kick off a best-effort, background check for a newer release. Results
+ // are cached on disk so the TUI status bar (and a future `version` call)
+ // can surface an upgrade hint without blocking on a network call. Only
+ // `run`/`exec` triggers this; other subcommands never reach out to
+ // GitHub. Disabled by setting DOCKER_AGENT_DISABLE_VERSION_CHECK=1.
+ check.RefreshAsync(ctx)
+
if f.sandbox {
return runInSandbox(ctx, cmd, args, &f.runConfig, f.sandboxTemplate, f.sbx)
}
diff --git a/cmd/root/version.go b/cmd/root/version.go
index 61c509f4b..9c8bdc562 100644
--- a/cmd/root/version.go
+++ b/cmd/root/version.go
@@ -7,6 +7,7 @@ import (
"github.com/docker/docker-agent/pkg/cli"
"github.com/docker/docker-agent/pkg/telemetry"
"github.com/docker/docker-agent/pkg/version"
+ "github.com/docker/docker-agent/pkg/version/check"
)
func newVersionCmd() *cobra.Command {
@@ -33,4 +34,12 @@ func runVersionCommand(cmd *cobra.Command, args []string) {
}
out.Printf("%s version %s\n", commandName, version.Version)
out.Printf("Commit: %s\n", version.Commit)
+
+ // Best-effort upgrade hint based on the cached result of the last
+ // `docker agent run`. We never reach out to GitHub from this subcommand;
+ // if the cache is empty (e.g. `run` has never been used) the hint is
+ // simply not shown.
+ if latest := check.LatestCached(version.Version); latest != "" {
+ out.Printf("\nA newer version is available: %s\nRelease notes: https://github.com/docker/docker-agent/releases/tag/%s\n", latest, latest)
+ }
}
diff --git a/docs/configuration/overview/index.md b/docs/configuration/overview/index.md
index ff718448d..d58d1c6c0 100644
--- a/docs/configuration/overview/index.md
+++ b/docs/configuration/overview/index.md
@@ -161,6 +161,12 @@ API keys and secrets are read from environment variables — never stored in con
| `DOCKER_AGENT_AUTO_INSTALL` | Set to `false` to disable automatic tool installation |
| `DOCKER_AGENT_TOOLS_DIR` | Override the base directory for installed tools (default: `~/.cagent/tools/`) |
+**Update Notifications:**
+
+| Variable | Description |
+| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `DOCKER_AGENT_DISABLE_VERSION_CHECK` | Set to `1`/`true` to disable the once-a-day GitHub release check used to surface an upgrade hint in the TUI status bar and the `version` subcommand. Only `docker agent run` ever performs the check. |
+
⚠️ Important
diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go
index 016195a6d..d8b51c61b 100644
--- a/pkg/tui/tui.go
+++ b/pkg/tui/tui.go
@@ -305,7 +305,7 @@ func New(ctx context.Context, spawner SessionSpawner, initialApp *app.App, initi
m.chatPage = initialChatPage
// Initialize status bar (pass m as help provider)
- m.statusBar = statusbar.New(m, statusbar.WithTitle(m.appName+" "+m.appVersion))
+ m.statusBar = statusbar.New(m, statusbar.WithTitle(buildStatusBarTitle(m.appName, m.appVersion)))
// Add the initial session to the supervisor
sv.AddSession(ctx, initialApp, initialApp.Session(), initialWorkingDir, cleanup)
diff --git a/pkg/tui/upgrade_hint.go b/pkg/tui/upgrade_hint.go
new file mode 100644
index 000000000..5f5ea6ee2
--- /dev/null
+++ b/pkg/tui/upgrade_hint.go
@@ -0,0 +1,20 @@
+package tui
+
+import (
+ "github.com/docker/docker-agent/pkg/version/check"
+)
+
+// buildStatusBarTitle returns the right-side string of the status bar:
+// "
", optionally suffixed with "(update available: vX.Y.Z)"
+// when a newer release tag has been observed in the local cache.
+//
+// Only cached results are consulted so the TUI never blocks on I/O at
+// startup; the cache is refreshed in the background by `docker agent run`
+// (see cmd/root/run.go).
+func buildStatusBarTitle(appName, appVersion string) string {
+ base := appName + " " + appVersion
+ if latest := check.LatestCached(appVersion); latest != "" {
+ return base + " (update available: " + latest + ")"
+ }
+ return base
+}
diff --git a/pkg/tui/upgrade_hint_test.go b/pkg/tui/upgrade_hint_test.go
new file mode 100644
index 000000000..370c200b9
--- /dev/null
+++ b/pkg/tui/upgrade_hint_test.go
@@ -0,0 +1,35 @@
+package tui
+
+import (
+ "strings"
+ "testing"
+
+ "gotest.tools/v3/assert"
+
+ "github.com/docker/docker-agent/pkg/version/check"
+)
+
+func TestBuildStatusBarTitle(t *testing.T) {
+ t.Run("no upgrade", func(t *testing.T) {
+ check.SeedCacheForTest(t, "v1.0.0")
+ assert.Equal(t, "docker agent v1.0.0", buildStatusBarTitle("docker agent", "v1.0.0"))
+ })
+
+ t.Run("upgrade available", func(t *testing.T) {
+ check.SeedCacheForTest(t, "v1.2.3")
+ got := buildStatusBarTitle("docker agent", "v1.0.0")
+ assert.Assert(t, strings.Contains(got, "docker agent v1.0.0"))
+ assert.Assert(t, strings.Contains(got, "update available: v1.2.3"))
+ })
+
+ t.Run("dev build is silent", func(t *testing.T) {
+ check.SeedCacheForTest(t, "v1.2.3")
+ assert.Equal(t, "docker agent dev", buildStatusBarTitle("docker agent", "dev"))
+ })
+
+ t.Run("disabled is silent", func(t *testing.T) {
+ check.SeedCacheForTest(t, "v1.2.3")
+ t.Setenv(check.DisableEnvVar, "1")
+ assert.Equal(t, "docker agent v1.0.0", buildStatusBarTitle("docker agent", "v1.0.0"))
+ })
+}
diff --git a/pkg/version/check/check.go b/pkg/version/check/check.go
new file mode 100644
index 000000000..148f88912
--- /dev/null
+++ b/pkg/version/check/check.go
@@ -0,0 +1,255 @@
+package check
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/docker/docker-agent/pkg/paths"
+)
+
+// DisableEnvVar is the environment variable that disables the version check
+// when set to a truthy value (1, true, yes, on, …).
+const DisableEnvVar = "DOCKER_AGENT_DISABLE_VERSION_CHECK"
+
+const (
+ cacheTTL = 24 * time.Hour
+ fetchTimeout = 5 * time.Second
+ releasesURL = "https://api.github.com/repos/docker/docker-agent/releases/latest"
+ cacheFileName = "version-check.json"
+)
+
+// LatestCached returns the latest known release tag if it is strictly newer
+// than current, or "" otherwise.
+//
+// The function never reaches out to the network — it only consults the local
+// cache populated by [RefreshAsync]. It also returns "" when the check is
+// disabled or when current is "dev" (development build).
+func LatestCached(current string) string {
+ if disabled() || current == "" || current == "dev" {
+ return ""
+ }
+ entry, _ := readCache()
+ if !IsNewer(entry.LatestVersion, current) {
+ return ""
+ }
+ return entry.LatestVersion
+}
+
+// RefreshAsync triggers a background refresh of the on-disk cache when it is
+// stale, returning immediately. Errors are logged at debug level and
+// otherwise ignored.
+//
+// The returned channel is closed once the goroutine completes. Tests use it
+// to deterministically wait for completion; production callers can ignore it.
+func RefreshAsync(ctx context.Context) <-chan struct{} {
+ done := make(chan struct{})
+
+ if disabled() {
+ close(done)
+ return done
+ }
+ if entry, _ := readCache(); entry.fresh(time.Now()) {
+ close(done)
+ return done
+ }
+
+ go func() {
+ defer close(done)
+
+ fetchCtx, cancel := context.WithTimeout(ctx, fetchTimeout)
+ defer cancel()
+
+ tag, err := fetchLatestTag(fetchCtx, releasesURL)
+ if err != nil {
+ slog.Debug("Version check fetch failed", "error", err)
+ return
+ }
+ if err := writeCache(tag); err != nil {
+ slog.Debug("Version check cache write failed", "error", err)
+ }
+ }()
+
+ return done
+}
+
+// disabled reports whether the version check has been turned off via
+// [DisableEnvVar].
+func disabled() bool {
+ switch strings.ToLower(strings.TrimSpace(os.Getenv(DisableEnvVar))) {
+ case "1", "true", "yes", "on":
+ return true
+ }
+ return false
+}
+
+// fetchLatestTag returns the `tag_name` field of the latest stable release.
+// The endpoint is parameterised to keep the function unit-testable.
+func fetchLatestTag(ctx context.Context, url string) (string, error) {
+ // Use a custom client with timeout and redirect limit to prevent
+ // SSRF/redirect loops. The context timeout in RefreshAsync is a backstop;
+ // this client-level timeout ensures the request doesn't hang.
+ client := &http.Client{
+ Timeout: fetchTimeout,
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ if len(via) >= 3 {
+ return errors.New("stopped after 3 redirects")
+ }
+ return nil
+ },
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Accept", "application/vnd.github+json")
+ req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+ return "", fmt.Errorf("unexpected status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
+ }
+
+ var payload struct {
+ TagName string `json:"tag_name"`
+ }
+ if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&payload); err != nil {
+ return "", fmt.Errorf("decode release payload: %w", err)
+ }
+ if payload.TagName == "" {
+ return "", errors.New("release payload missing tag_name")
+ }
+ return payload.TagName, nil
+}
+
+// cacheEntry is the JSON payload persisted to disk.
+type cacheEntry struct {
+ LatestVersion string `json:"latest_version"`
+ CheckedAt int64 `json:"checked_at"`
+}
+
+// fresh reports whether the entry is still within [cacheTTL].
+func (e cacheEntry) fresh(now time.Time) bool {
+ return e.CheckedAt > 0 && now.Sub(time.Unix(e.CheckedAt, 0)) < cacheTTL
+}
+
+// cachePath returns the absolute path of the cache file.
+func cachePath() string {
+ return filepath.Join(paths.GetCacheDir(), cacheFileName)
+}
+
+// readCache returns the cached entry, or a zero entry if the file is missing
+// or unreadable. The file is small enough that we do not worry about partial
+// reads: if Unmarshal fails, callers simply see "no cache" for one call.
+func readCache() (cacheEntry, error) {
+ data, err := os.ReadFile(cachePath())
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return cacheEntry{}, nil
+ }
+ return cacheEntry{}, err
+ }
+ var entry cacheEntry
+ if err := json.Unmarshal(data, &entry); err != nil {
+ return cacheEntry{}, err
+ }
+ return entry, nil
+}
+
+// writeCache persists the given release tag along with the current timestamp.
+// It uses atomic write-and-rename to prevent corruption from concurrent writes.
+func writeCache(latest string) error {
+ if err := os.MkdirAll(paths.GetCacheDir(), 0o755); err != nil {
+ return fmt.Errorf("create cache dir: %w", err)
+ }
+ data, err := json.Marshal(cacheEntry{LatestVersion: latest, CheckedAt: time.Now().Unix()})
+ if err != nil {
+ return err
+ }
+
+ // Write to a temporary file first, then atomically rename it to prevent
+ // corruption if multiple processes write concurrently.
+ tmpPath := cachePath() + ".tmp"
+ if err := os.WriteFile(tmpPath, data, 0o600); err != nil {
+ return err
+ }
+ return os.Rename(tmpPath, cachePath())
+}
+
+// IsNewer reports whether the semver-like tag latest is strictly greater than
+// current. The comparison is intentionally tolerant:
+//
+// - A leading "v" is stripped from both sides.
+// - Build metadata ("+meta") is ignored.
+// - A pre-release ("-rc.1") sorts strictly older than the same release.
+// - Components that fail to parse as integers are treated as 0, so
+// malformed inputs simply do not trigger a notification.
+// - Empty strings or "dev" never compare as newer.
+func IsNewer(latest, current string) bool {
+ if latest == "" || current == "" || current == "dev" || latest == "dev" {
+ return false
+ }
+
+ la, lpre := splitVersion(latest)
+ cu, cpre := splitVersion(current)
+
+ if cmp := compareNumeric(la, cu); cmp != 0 {
+ return cmp > 0
+ }
+ // Equal numeric parts: a release outranks a pre-release of the same
+ // version (1.2.3 > 1.2.3-rc.1). Otherwise treat as equal.
+ return lpre == "" && cpre != ""
+}
+
+// splitVersion strips a leading "v", drops "+build" metadata, and splits off
+// any "-prerelease" suffix. For example "v1.2.3-rc.1+meta" → ("1.2.3", "rc.1").
+func splitVersion(v string) (numeric, pre string) {
+ v = strings.TrimPrefix(v, "v")
+ if i := strings.Index(v, "+"); i >= 0 {
+ v = v[:i]
+ }
+ if num, p, ok := strings.Cut(v, "-"); ok {
+ return num, p
+ }
+ return v, ""
+}
+
+// compareNumeric compares dotted numeric strings ("1.2.3") component by
+// component, returning -1, 0 or +1. Missing trailing components are treated
+// as zero so "1.2" == "1.2.0".
+func compareNumeric(a, b string) int {
+ ap := strings.Split(a, ".")
+ bp := strings.Split(b, ".")
+ for i := range max(len(ap), len(bp)) {
+ var ai, bi int
+ if i < len(ap) {
+ ai, _ = strconv.Atoi(ap[i])
+ }
+ if i < len(bp) {
+ bi, _ = strconv.Atoi(bp[i])
+ }
+ if ai != bi {
+ if ai > bi {
+ return 1
+ }
+ return -1
+ }
+ }
+ return 0
+}
diff --git a/pkg/version/check/check_test.go b/pkg/version/check/check_test.go
new file mode 100644
index 000000000..0e053babe
--- /dev/null
+++ b/pkg/version/check/check_test.go
@@ -0,0 +1,229 @@
+package check
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "sync"
+ "testing"
+ "time"
+
+ "gotest.tools/v3/assert"
+)
+
+func TestIsNewer(t *testing.T) {
+ tests := []struct {
+ name string
+ latest string
+ current string
+ want bool
+ }{
+ {"patch newer", "v1.2.4", "v1.2.3", true},
+ {"minor newer", "v1.3.0", "v1.2.9", true},
+ {"major newer", "v2.0.0", "v1.99.0", true},
+ {"equal", "v1.2.3", "v1.2.3", false},
+ {"older", "v1.2.2", "v1.2.3", false},
+ {"prefix v optional", "1.2.4", "v1.2.3", true},
+ {"missing components", "v1.3", "v1.2.9", true},
+ {"release beats prerelease", "v1.2.3", "v1.2.3-rc.1", true},
+ {"prerelease loses to release", "v1.2.3-rc.1", "v1.2.3", false},
+ {"build metadata ignored", "v1.2.4+abcdef", "v1.2.3", true},
+ {"both prerelease equal numeric", "v1.2.3-rc.2", "v1.2.3-rc.1", false},
+ {"empty latest", "", "v1.2.3", false},
+ {"empty current", "v1.2.3", "", false},
+ {"dev current never upgrades", "v1.2.3", "dev", false},
+ {"dev latest never upgrades", "dev", "v1.2.3", false},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ assert.Equal(t, tc.want, IsNewer(tc.latest, tc.current))
+ })
+ }
+}
+
+func TestFetchLatestTag(t *testing.T) {
+ tests := []struct {
+ name string
+ handler http.HandlerFunc
+ wantTag string
+ wantErrSub string
+ }{
+ {
+ name: "success",
+ handler: func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "application/vnd.github+json", r.Header.Get("Accept"))
+ _ = json.NewEncoder(w).Encode(map[string]any{"tag_name": "v9.9.9"})
+ },
+ wantTag: "v9.9.9",
+ },
+ {
+ name: "http error",
+ handler: func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "rate limited", http.StatusForbidden) },
+ wantErrSub: "unexpected status 403",
+ },
+ {
+ name: "malformed payload",
+ handler: func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("{not json")) },
+ wantErrSub: "decode release payload",
+ },
+ {
+ name: "missing tag",
+ handler: func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"foo":"bar"}`)) },
+ wantErrSub: "missing tag_name",
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ srv := httptest.NewServer(tc.handler)
+ t.Cleanup(srv.Close)
+
+ tag, err := fetchLatestTag(t.Context(), srv.URL)
+ if tc.wantErrSub != "" {
+ assert.ErrorContains(t, err, tc.wantErrSub)
+ return
+ }
+ assert.NilError(t, err)
+ assert.Equal(t, tc.wantTag, tag)
+ })
+ }
+}
+
+func TestCacheRoundTrip(t *testing.T) {
+ SeedCacheForTest(t, "")
+
+ assert.NilError(t, writeCache("v1.2.3"))
+
+ // Cache file ended up where we expect.
+ _, err := os.Stat(cachePath())
+ assert.NilError(t, err)
+
+ got, err := readCache()
+ assert.NilError(t, err)
+ assert.Equal(t, "v1.2.3", got.LatestVersion)
+ assert.Assert(t, got.fresh(time.Now()))
+}
+
+func TestReadCache_MissingReturnsZero(t *testing.T) {
+ SeedCacheForTest(t, "")
+
+ got, err := readCache()
+ assert.NilError(t, err)
+ assert.Equal(t, cacheEntry{}, got)
+}
+
+func TestReadCache_CorruptReturnsZero(t *testing.T) {
+ SeedCacheForTest(t, "")
+ assert.NilError(t, os.WriteFile(filepath.Join(filepath.Dir(cachePath()), cacheFileName), []byte("not-json"), 0o600))
+
+ got, _ := readCache()
+ assert.Equal(t, cacheEntry{}, got)
+}
+
+func TestCacheFreshness(t *testing.T) {
+ now := time.Now()
+ assert.Assert(t, cacheEntry{CheckedAt: now.Add(-1 * time.Hour).Unix()}.fresh(now), "1h-old entry should be fresh")
+ assert.Assert(t, !cacheEntry{CheckedAt: now.Add(-48 * time.Hour).Unix()}.fresh(now), "48h-old entry should be stale")
+ assert.Assert(t, !cacheEntry{}.fresh(now), "zero entry should be stale")
+}
+
+func TestLatestCached(t *testing.T) {
+ t.Run("empty cache returns empty", func(t *testing.T) {
+ SeedCacheForTest(t, "")
+ assert.Equal(t, "", LatestCached("v1.0.0"))
+ })
+
+ t.Run("cache newer than current returns latest", func(t *testing.T) {
+ SeedCacheForTest(t, "v9.9.9")
+ assert.Equal(t, "v9.9.9", LatestCached("v1.0.0"))
+ })
+
+ t.Run("cache older than current returns empty", func(t *testing.T) {
+ SeedCacheForTest(t, "v1.0.0")
+ assert.Equal(t, "", LatestCached("v9.9.9"))
+ })
+
+ t.Run("dev current never reports upgrade", func(t *testing.T) {
+ SeedCacheForTest(t, "v9.9.9")
+ assert.Equal(t, "", LatestCached("dev"))
+ })
+
+ t.Run("disabled returns empty even when cache has upgrade", func(t *testing.T) {
+ SeedCacheForTest(t, "v9.9.9")
+ t.Setenv(DisableEnvVar, "1")
+ assert.Equal(t, "", LatestCached("v1.0.0"))
+ })
+}
+
+func TestRefreshAsync_Disabled(t *testing.T) {
+ SeedCacheForTest(t, "")
+ t.Setenv(DisableEnvVar, "true")
+
+ <-RefreshAsync(t.Context())
+
+ got, err := readCache()
+ assert.NilError(t, err)
+ assert.Equal(t, "", got.LatestVersion)
+}
+
+func TestRefreshAsync_FreshCacheSkipsRefresh(t *testing.T) {
+ SeedCacheForTest(t, "v1.2.3")
+ before, err := readCache()
+ assert.NilError(t, err)
+
+ <-RefreshAsync(t.Context())
+
+ after, err := readCache()
+ assert.NilError(t, err)
+ assert.Equal(t, before, after, "fresh cache must not be touched")
+}
+
+func TestDisabled(t *testing.T) {
+ for _, val := range []string{"1", "true", "True", "YES", "on"} {
+ t.Run("truthy/"+val, func(t *testing.T) {
+ t.Setenv(DisableEnvVar, val)
+ assert.Assert(t, disabled())
+ })
+ }
+ for _, val := range []string{"", "0", "false", "no", "off", "anything-else"} {
+ t.Run("falsy/"+val, func(t *testing.T) {
+ t.Setenv(DisableEnvVar, val)
+ assert.Assert(t, !disabled())
+ })
+ }
+}
+
+func TestFetchLatestTag_RedirectLimit(t *testing.T) {
+ redirectCount := 0
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ redirectCount++
+ if redirectCount <= 5 {
+ http.Redirect(w, r, "/redirect", http.StatusFound)
+ return
+ }
+ _ = json.NewEncoder(w).Encode(map[string]any{"tag_name": "v1.0.0"})
+ }))
+ t.Cleanup(srv.Close)
+
+ _, err := fetchLatestTag(t.Context(), srv.URL)
+ assert.ErrorContains(t, err, "stopped after 3 redirects")
+}
+
+func TestRefreshAsync_ConcurrentCalls(t *testing.T) {
+ SeedCacheForTest(t, "")
+
+ const numCalls = 10
+ var wg sync.WaitGroup
+ wg.Add(numCalls)
+
+ for range numCalls {
+ go func() {
+ defer wg.Done()
+ <-RefreshAsync(t.Context())
+ }()
+ }
+
+ wg.Wait()
+ // If we get here without panicking or racing, the test passes
+}
diff --git a/pkg/version/check/testing.go b/pkg/version/check/testing.go
new file mode 100644
index 000000000..7fe0f9b97
--- /dev/null
+++ b/pkg/version/check/testing.go
@@ -0,0 +1,29 @@
+package check
+
+import (
+ "testing"
+
+ "github.com/docker/docker-agent/pkg/paths"
+)
+
+// SeedCacheForTest points the cache directory at a per-test temp dir and
+// pre-populates it with the given release tag (or leaves it empty if latest
+// is ""). It is intended for unit tests in other packages that want to
+// observe [LatestCached] returning a deterministic value without hitting the
+// network.
+//
+// The cache directory override is restored on test cleanup.
+func SeedCacheForTest(tb testing.TB, latest string) {
+ tb.Helper()
+
+ prev := paths.GetCacheDir()
+ paths.SetCacheDir(tb.TempDir())
+ tb.Cleanup(func() { paths.SetCacheDir(prev) })
+
+ if latest == "" {
+ return
+ }
+ if err := writeCache(latest); err != nil {
+ tb.Fatalf("seed version cache: %v", err)
+ }
+}