diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ae569c..caa30f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,11 @@ run: `execution list --team ADM --state FAILED ERRORED --from 2026-09-28 --fail-on-match`. - Shell completion completes environment names for `--environment`. +- At a terminal, the CLI tells you once a day when a newer release is out, on stderr, with + `brew upgrade steadybit` when it was installed with Homebrew. It stays quiet in CI, when + stderr is not a terminal, for builds that are not releases, and with + `STEADYBIT_NO_UPDATE_CHECK` set. The check asks GitHub where its latest release is and + waits for the answer at most a second, once a day. ## v6.0.1 diff --git a/Dockerfile b/Dockerfile index 63dde46..cbe3398 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,4 +22,8 @@ RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath \ FROM alpine:3 RUN apk upgrade --no-cache COPY --from=builder /steadybit /usr/local/bin/steadybit +# A container is started fresh for each run, often in a pipeline whose CI variables are +# not passed in, and is updated by pulling a newer image, so the daily release check +# would only ask GitHub every time and give the wrong advice. +ENV STEADYBIT_NO_UPDATE_CHECK=1 ENTRYPOINT ["steadybit"] diff --git a/README.md b/README.md index 0ad53fe..34bc418 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,9 @@ Profiles in `~/.steadybit` keep working. Shell completion is available for bash, zsh, fish and PowerShell, see `steadybit completion --help`. +At a terminal, the CLI tells you once a day when a newer release is out. Set +`STEADYBIT_NO_UPDATE_CHECK=1` to turn that off; it is always off in CI. + ## Authorization You need an API access token. You can grab one via our [platform](https://platform.steadybit.com/settings/api-tokens) through the `Settings -> API Access Tokens` page. diff --git a/internal/cli/root.go b/internal/cli/root.go index d03f7bf..571f4fe 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -9,6 +9,7 @@ import ( "context" "errors" "fmt" + "io" "os" "strings" @@ -18,6 +19,7 @@ import ( "github.com/steadybit/cli/v6/internal/gitops" "github.com/steadybit/cli/v6/internal/output" "github.com/steadybit/cli/v6/internal/platform" + "github.com/steadybit/cli/v6/internal/update" ) // Laid out like the TypeScript CLI's help, which pipelines and the e2e suite read. @@ -109,6 +111,8 @@ func setUsage(cmd *cobra.Command) { } func Execute() int { + notice := updateNotice(os.Args[1:]) + defer notice(os.Stderr) root := newRoot() root.SetArgs(expandVariadic(root, os.Args[1:])) err := root.ExecuteContext(context.Background()) @@ -128,3 +132,26 @@ func Execute() int { } return 1 } + +// updateNotice starts the daily look for a newer release. Completion runs on every Tab and +// writes a script, so it neither waits for the look nor prints a notice, wherever global +// flags put its command in the arguments. +func updateNotice(args []string) func(io.Writer) { + if completing(args) { + return func(io.Writer) {} + } + cache, err := config.Path("update-check.json") + if err != nil { + return func(io.Writer) {} + } + return update.Start(platform.CurrentVersion(), cache) +} + +func completing(args []string) bool { + for _, arg := range args { + if arg == "completion" || strings.HasPrefix(arg, "__complete") { + return true + } + } + return false +} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go new file mode 100644 index 0000000..9bc0e73 --- /dev/null +++ b/internal/cli/root_test.go @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package cli + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// Completion never waits for the release check, wherever global flags put it. +func TestCompletionSkipsTheUpdateCheck(t *testing.T) { + assert.True(t, completing([]string{"completion", "zsh"})) + assert.True(t, completing([]string{"--profile", "prod", "completion", "zsh"})) + assert.True(t, completing([]string{"-v", "__complete", "experiment", "get", "-k", ""})) + assert.True(t, completing([]string{"__completeNoDesc", "team"})) + assert.False(t, completing([]string{"experiment", "run", "-k", "ADM-1"})) +} diff --git a/internal/config/config.go b/internal/config/config.go index 0d4b4e7..9ab85ec 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -35,6 +35,9 @@ func dir() (string, error) { return filepath.Join(home, ".steadybit"), nil } +// Path is where the CLI keeps a file of its own state, next to the profiles. +func Path(name string) (string, error) { return file(name) } + func file(name string) (string, error) { d, err := dir() if err != nil { diff --git a/internal/update/update.go b/internal/update/update.go new file mode 100644 index 0000000..f25b4fd --- /dev/null +++ b/internal/update/update.go @@ -0,0 +1,189 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +// Package update tells someone at a terminal, at most once a day, that a newer release +// of the CLI exists. With npm gone, nothing else would: a downloaded binary stays as it is. +package update + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "golang.org/x/term" +) + +// LatestURL redirects to the tag of the latest release. Asking where it redirects costs +// none of the GitHub API's rate limit. Tests point it elsewhere. +var LatestURL = "https://github.com/steadybit/cli/releases/latest" + +// Now is the clock the daily check is measured against. Tests replace it. +var Now = time.Now + +// Interactive reports whether a notice would be seen, and not end up in a CI log or in +// output a script reads. Tests replace it. +var Interactive = func() bool { + return !turnedOff(os.Getenv) && term.IsTerminal(int(os.Stderr.Fd())) +} + +// turnedOff is whether the environment rules the check out: asked to, or in CI. +func turnedOff(getenv func(string) string) bool { + switch strings.ToLower(getenv("STEADYBIT_NO_UPDATE_CHECK")) { + case "", "0", "false": + default: + return true + } + for _, ci := range []string{"CI", "JENKINS_URL", "TF_BUILD", "BUILDKITE"} { + if getenv(ci) != "" { + return true + } + } + return false +} + +const every = 24 * time.Hour + +// How long a command waits, once a day, for the check before it ends without it. +const patience = time.Second + +// The request gives up earlier than the command waits, so that even a request that runs +// out of time leaves room to record the check before the command ends. +const fetchTimeout = 800 * time.Millisecond + +// fetch asks where the latest release is. Tests replace it, so that none depends on how +// fast a refused connection fails, which differs between systems. +var fetch = fetchLatest + +var release = regexp.MustCompile(`^(\d+)\.(\d+)\.(\d+)$`) + +type state struct { + CheckedAt time.Time `json:"checkedAt"` + Latest string `json:"latest"` +} + +// Start checks for a newer release when a check is due, while the command runs. The +// function it returns prints the notice, after the command, from what is known by then. +// A build that is not a release, such as one from `go install ...@main`, is never told. +func Start(current, cacheFile string) (notice func(io.Writer)) { + if !release.MatchString(current) || !Interactive() { + return func(io.Writer) {} + } + known := read(cacheFile) + done := make(chan state, 1) + // A check dated in the future, from a wrong clock or a copied cache, is due: without + // that it would not be repeated until the clock caught up. + if since := Now().Sub(known.CheckedAt); since >= 0 && since < every { + done <- known + } else { + go func() { + checked := state{CheckedAt: Now(), Latest: known.Latest} + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + if latest, err := fetch(ctx); err == nil { + checked.Latest = latest + } + // Written even when offline, so an unreachable GitHub is not asked again + // before tomorrow. + write(cacheFile, checked) + done <- checked + }() + } + return func(w io.Writer) { + select { + case known = <-done: + case <-time.After(patience): + } + if newer(known.Latest, current) { + fmt.Fprintf(w, "\nA new release of the Steadybit CLI is available: %s → %s\n%s\n", current, known.Latest, howToUpdate()) + } + } +} + +func fetchLatest(ctx context.Context) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodHead, LatestURL, nil) + if err != nil { + return "", err + } + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + resp, err := client.Do(req) + if err != nil { + return "", err + } + _ = resp.Body.Close() + tag := strings.TrimPrefix(path.Base(resp.Header.Get("Location")), "v") + if !release.MatchString(tag) { + return "", fmt.Errorf("no release tag in %q", resp.Header.Get("Location")) + } + return tag, nil +} + +// newer compares release versions by number, so that 6.10.0 is after 6.9.0. +func newer(latest, current string) bool { + l, c := release.FindStringSubmatch(latest), release.FindStringSubmatch(current) + if l == nil || c == nil { + return false + } + for i := 1; i <= 3; i++ { + ln, _ := strconv.Atoi(l[i]) + cn, _ := strconv.Atoi(c[i]) + if ln != cn { + return ln > cn + } + } + return false +} + +// howToUpdate names the way this binary was installed, when that can be told from where +// it lives. +func howToUpdate() string { + if executable, err := os.Executable(); err == nil { + if resolved, err := filepath.EvalSymlinks(executable); err == nil { + executable = resolved + } + if strings.Contains(executable, string(filepath.Separator)+"Caskroom"+string(filepath.Separator)) { + return "Update with: brew upgrade steadybit" + } + } + return "Get it from " + LatestURL +} + +func read(file string) state { + var s state + if content, err := os.ReadFile(file); err == nil { + _ = json.Unmarshal(content, &s) + } + return s +} + +// write replaces the cache in one step, through a file renamed over it, so that a command +// ending mid-write, or two running at once, never leave half a file behind. +func write(file string, s state) { + content, err := json.Marshal(s) + if err != nil { + return + } + dir := filepath.Dir(file) + if os.MkdirAll(dir, 0o755) != nil { + return + } + tmp, err := os.CreateTemp(dir, filepath.Base(file)+".*") + if err != nil { + return + } + _, err = tmp.Write(content) + if closeErr := tmp.Close(); err == nil { + err = closeErr + } + if err != nil || os.Rename(tmp.Name(), file) != nil { + _ = os.Remove(tmp.Name()) + } +} diff --git a/internal/update/update_test.go b/internal/update/update_test.go new file mode 100644 index 0000000..6b09e89 --- /dev/null +++ b/internal/update/update_test.go @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package update + +import ( + "bytes" + "context" + "errors" + "net/http" + "net/http/httptest" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +// github answers like the releases page: a redirect to the latest tag. +func github(t *testing.T, tag string) *atomic.Int32 { + var asked atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + asked.Add(1) + w.Header().Set("Location", "https://github.com/steadybit/cli/releases/tag/"+tag) + w.WriteHeader(http.StatusFound) + })) + t.Cleanup(server.Close) + original, originalInteractive, originalNow, originalFetch := LatestURL, Interactive, Now, fetch + LatestURL, Interactive = server.URL, func() bool { return true } + t.Cleanup(func() { LatestURL, Interactive, Now, fetch = original, originalInteractive, originalNow, originalFetch }) + return &asked +} + +func notice(current, cache string) string { + var out bytes.Buffer + Start(current, cache)(&out) + return out.String() +} + +func TestTellsOfANewerRelease(t *testing.T) { + github(t, "v6.10.0") + cache := filepath.Join(t.TempDir(), "update-check.json") + + out := notice("6.9.1", cache) + + assert.Contains(t, out, "A new release of the Steadybit CLI is available: 6.9.1 → 6.10.0\n") + assert.Contains(t, out, "Get it from ") +} + +func TestAsksAtMostOnceADay(t *testing.T) { + asked := github(t, "v6.1.0") + cache := filepath.Join(t.TempDir(), "update-check.json") + now := time.Date(2026, 9, 29, 9, 0, 0, 0, time.UTC) + Now = func() time.Time { return now } + + first := notice("6.0.1", cache) + now = now.Add(23 * time.Hour) + second := notice("6.0.1", cache) + now = now.Add(2 * time.Hour) + notice("6.0.1", cache) + + // The second knew of 6.1.0 from the first, without asking. + assert.Contains(t, first, "6.0.1 → 6.1.0") + assert.Contains(t, second, "6.0.1 → 6.1.0") + assert.EqualValues(t, 2, asked.Load()) +} + +func TestSaysNothingWhenUpToDateOrNotARelease(t *testing.T) { + asked := github(t, "v6.0.1") + dir := t.TempDir() + + assert.Empty(t, notice("6.0.1", filepath.Join(dir, "a.json"))) + assert.Empty(t, notice("6.1.0", filepath.Join(dir, "b.json"))) + // go install ...@main and local builds are not releases, and are left alone. + assert.Empty(t, notice("6.0.0-20260928130623-cbee9e0fadeb", filepath.Join(dir, "c.json"))) + assert.Empty(t, notice("dev", filepath.Join(dir, "d.json"))) + assert.EqualValues(t, 2, asked.Load()) +} + +func TestStaysQuietWhereNobodyWouldSeeIt(t *testing.T) { + asked := github(t, "v7.0.0") + Interactive = func() bool { return false } + + assert.Empty(t, notice("6.0.1", filepath.Join(t.TempDir(), "update-check.json"))) + assert.Zero(t, asked.Load()) +} + +// Offline, the check is not retried on every command until the next day. +func TestAnUnreachableGitHubIsNotAskedAgainThatDay(t *testing.T) { + github(t, "v6.1.0") + fetch = func(context.Context) (string, error) { return "", errors.New("offline") } + cache := filepath.Join(t.TempDir(), "update-check.json") + + assert.Empty(t, notice("6.0.1", cache)) + checked := read(cache) + assert.False(t, checked.CheckedAt.IsZero()) + assert.Empty(t, checked.Latest) +} + +// Behind a firewall that drops the request, it runs out of time; the check is still +// recorded before the command ends, or every command would wait again. +func TestARequestThatTimesOutIsStillRecorded(t *testing.T) { + github(t, "v6.1.0") + fetch = func(ctx context.Context) (string, error) { + <-ctx.Done() + return "", ctx.Err() + } + cache := filepath.Join(t.TempDir(), "update-check.json") + + notice("6.0.1", cache) + + assert.False(t, read(cache).CheckedAt.IsZero()) +} + +// A check dated in the future comes from a wrong clock; it is repeated, not trusted. +func TestAFutureCheckIsDue(t *testing.T) { + asked := github(t, "v6.1.0") + cache := filepath.Join(t.TempDir(), "update-check.json") + write(cache, state{CheckedAt: time.Now().Add(365 * 24 * time.Hour), Latest: "6.0.1"}) + + assert.Contains(t, notice("6.0.1", cache), "6.0.1 → 6.1.0") + assert.EqualValues(t, 1, asked.Load()) +} + +func TestTheVariableTurnsTheCheckOffUnlessItSaysNo(t *testing.T) { + for value, off := range map[string]bool{"": false, "0": false, "false": false, "FALSE": false, "1": true, "true": true, "yes": true} { + env := map[string]string{"STEADYBIT_NO_UPDATE_CHECK": value} + assert.Equal(t, off, turnedOff(func(k string) string { return env[k] }), value) + } + ci := map[string]string{"GITHUB_ACTIONS": "true", "CI": "true"} + assert.True(t, turnedOff(func(k string) string { return ci[k] })) +} + +func TestComparesVersionsByNumber(t *testing.T) { + assert.True(t, newer("6.10.0", "6.9.9")) + assert.True(t, newer("7.0.0", "6.99.99")) + assert.False(t, newer("6.0.1", "6.0.1")) + assert.False(t, newer("6.0.0", "6.0.1")) + assert.False(t, newer("", "6.0.1")) +}