From cb3775d5602efe755b6f660739dfcf99e0bec835 Mon Sep 17 00:00:00 2001 From: "antoine.choimet" <12182686+achoimet@users.noreply.github.com.> Date: Tue, 29 Sep 2026 11:43:58 +0200 Subject: [PATCH 1/2] feat: tell of a newer release once a day, at a terminal npm used to tell people when a new version was out; a downloaded binary stays as it is, so users would not notice releases. At most once a day the CLI asks where GitHub's latest-release redirect points, which costs none of the API's rate limit, and caches the answer in ~/.steadybit. A newer release is shown on stderr after the command, with `brew upgrade steadybit` for Homebrew installs. It stays quiet where nobody would read it or where it would get in the way: when stderr is not a terminal, in CI, during completion, for builds that are not releases, and with STEADYBIT_NO_UPDATE_CHECK set. A command waits for the check at most a second, once a day, and offline the check is not retried until the next day. --- CHANGELOG.md | 8 ++ README.md | 3 + internal/cli/root.go | 18 ++++ internal/update/update.go | 155 +++++++++++++++++++++++++++++++++ internal/update/update_test.go | 105 ++++++++++++++++++++++ 5 files changed, 289 insertions(+) create mode 100644 internal/update/update.go create mode 100644 internal/update/update_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index a2723f4..aa2a373 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## v6.1.0 + +- 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 - `experiment apply` and `experiment run` no longer send a `version` from the file. The diff --git a/README.md b/README.md index 91871d1..e550133 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..2991a90 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -9,7 +9,9 @@ import ( "context" "errors" "fmt" + "io" "os" + "path/filepath" "strings" "github.com/spf13/cobra" @@ -18,6 +20,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 +112,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 +133,16 @@ 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. +func updateNotice(args []string) func(io.Writer) { + if len(args) > 0 && (strings.HasPrefix(args[0], "__complete") || args[0] == "completion") { + return func(io.Writer) {} + } + home, err := os.UserHomeDir() + if err != nil { + return func(io.Writer) {} + } + return update.Start(platform.CurrentVersion(), filepath.Join(home, ".steadybit", "update-check.json")) +} diff --git a/internal/update/update.go b/internal/update/update.go new file mode 100644 index 0000000..8faf8f1 --- /dev/null +++ b/internal/update/update.go @@ -0,0 +1,155 @@ +// 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 { + if os.Getenv("STEADYBIT_NO_UPDATE_CHECK") != "" { + return false + } + for _, ci := range []string{"CI", "JENKINS_URL", "TF_BUILD", "BUILDKITE"} { + if os.Getenv(ci) != "" { + return false + } + } + return term.IsTerminal(int(os.Stderr.Fd())) +} + +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 + +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) + if Now().Sub(known.CheckedAt) < every { + done <- known + } else { + go func() { + checked := state{CheckedAt: Now(), Latest: known.Latest} + if latest, err := fetchLatest(); 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() (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), patience) + defer cancel() + 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 +} + +func write(file string, s state) { + if content, err := json.Marshal(s); err == nil { + _ = os.MkdirAll(filepath.Dir(file), 0o755) + _ = os.WriteFile(file, content, 0o644) + } +} diff --git a/internal/update/update_test.go b/internal/update/update_test.go new file mode 100644 index 0000000..5d22975 --- /dev/null +++ b/internal/update/update_test.go @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package update + +import ( + "bytes" + "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 := LatestURL, Interactive, Now + LatestURL, Interactive = server.URL, func() bool { return true } + t.Cleanup(func() { LatestURL, Interactive, Now = original, originalInteractive, originalNow }) + 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") + LatestURL = "http://127.0.0.1:1/releases/latest" + 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) +} + +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")) +} From 3e5a3d36b78bdbf1dfd529cfdae0179530107724 Mon Sep 17 00:00:00 2001 From: "antoine.choimet" <12182686+achoimet@users.noreply.github.com.> Date: Tue, 29 Sep 2026 14:13:01 +0200 Subject: [PATCH 2/2] fix: problems found in the review of the update notice - The request gives up after 800 ms, earlier than the command's 1 s wait, so a request that runs out of time behind a firewall is still recorded before the command ends; before, every later command could wait again. - The cache is replaced through a renamed temporary file, so a command ending mid-write, or two running at once, never leave a broken file. - A check dated in the future, from a wrong clock or a copied cache, is due instead of trusted until the clock catches up. - Completion skips the check wherever global flags put it in the arguments (`--profile x completion zsh`, `-v __complete ...`), not only first. - STEADYBIT_NO_UPDATE_CHECK=0 or false no longer turns the check off. - The container image sets STEADYBIT_NO_UPDATE_CHECK: each run starts fresh, often in a pipeline whose CI variables are not passed in, and is updated by pulling an image, so the check would only ask GitHub every time and advise the wrong thing. - The cache path comes from the config package, which owns ~/.steadybit. - Tests no longer depend on how fast a refused connection fails, which on Windows takes longer than the check waits. --- Dockerfile | 4 +++ internal/cli/root.go | 19 ++++++++--- internal/cli/root_test.go | 19 +++++++++++ internal/config/config.go | 3 ++ internal/update/update.go | 60 ++++++++++++++++++++++++++-------- internal/update/update_test.go | 42 ++++++++++++++++++++++-- 6 files changed, 126 insertions(+), 21 deletions(-) create mode 100644 internal/cli/root_test.go 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/internal/cli/root.go b/internal/cli/root.go index 2991a90..571f4fe 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -11,7 +11,6 @@ import ( "fmt" "io" "os" - "path/filepath" "strings" "github.com/spf13/cobra" @@ -135,14 +134,24 @@ func Execute() int { } // 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. +// 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 len(args) > 0 && (strings.HasPrefix(args[0], "__complete") || args[0] == "completion") { + if completing(args) { return func(io.Writer) {} } - home, err := os.UserHomeDir() + cache, err := config.Path("update-check.json") if err != nil { return func(io.Writer) {} } - return update.Start(platform.CurrentVersion(), filepath.Join(home, ".steadybit", "update-check.json")) + 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 index 8faf8f1..f25b4fd 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -32,15 +32,22 @@ 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 { - if os.Getenv("STEADYBIT_NO_UPDATE_CHECK") != "" { - return false + 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 os.Getenv(ci) != "" { - return false + if getenv(ci) != "" { + return true } } - return term.IsTerminal(int(os.Stderr.Fd())) + return false } const every = 24 * time.Hour @@ -48,6 +55,14 @@ 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 { @@ -64,12 +79,16 @@ func Start(current, cacheFile string) (notice func(io.Writer)) { } known := read(cacheFile) done := make(chan state, 1) - if Now().Sub(known.CheckedAt) < every { + // 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} - if latest, err := fetchLatest(); err == nil { + 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 @@ -89,9 +108,7 @@ func Start(current, cacheFile string) (notice func(io.Writer)) { } } -func fetchLatest() (string, error) { - ctx, cancel := context.WithTimeout(context.Background(), patience) - defer cancel() +func fetchLatest(ctx context.Context) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodHead, LatestURL, nil) if err != nil { return "", err @@ -147,9 +164,26 @@ func read(file string) state { 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) { - if content, err := json.Marshal(s); err == nil { - _ = os.MkdirAll(filepath.Dir(file), 0o755) - _ = os.WriteFile(file, content, 0o644) + 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 index 5d22975..6b09e89 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -5,6 +5,8 @@ package update import ( "bytes" + "context" + "errors" "net/http" "net/http/httptest" "path/filepath" @@ -24,9 +26,9 @@ func github(t *testing.T, tag string) *atomic.Int32 { w.WriteHeader(http.StatusFound) })) t.Cleanup(server.Close) - original, originalInteractive, originalNow := LatestURL, Interactive, Now + original, originalInteractive, originalNow, originalFetch := LatestURL, Interactive, Now, fetch LatestURL, Interactive = server.URL, func() bool { return true } - t.Cleanup(func() { LatestURL, Interactive, Now = original, originalInteractive, originalNow }) + t.Cleanup(func() { LatestURL, Interactive, Now, fetch = original, originalInteractive, originalNow, originalFetch }) return &asked } @@ -87,7 +89,7 @@ func TestStaysQuietWhereNobodyWouldSeeIt(t *testing.T) { // Offline, the check is not retried on every command until the next day. func TestAnUnreachableGitHubIsNotAskedAgainThatDay(t *testing.T) { github(t, "v6.1.0") - LatestURL = "http://127.0.0.1:1/releases/latest" + 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)) @@ -96,6 +98,40 @@ func TestAnUnreachableGitHubIsNotAskedAgainThatDay(t *testing.T) { 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"))