Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
27 changes: 27 additions & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"context"
"errors"
"fmt"
"io"
"os"
"strings"

Expand All @@ -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.
Expand Down Expand Up @@ -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())
Expand All @@ -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
}
19 changes: 19 additions & 0 deletions internal/cli/root_test.go
Original file line number Diff line number Diff line change
@@ -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"}))
}
3 changes: 3 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
189 changes: 189 additions & 0 deletions internal/update/update.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
Loading
Loading