Skip to content

Commit ff9efbb

Browse files
committed
fix(update): suppress nag for pseudo-versions and add env-var escape hatch
CI integration test `TestRepoLifecycle` failed on PR #188: expected 'v1' in quiet tag list, got: 💡 vers update available: v0.0.0-20260506213344-eb1e5d25fa7c -> v0.10.0 (run 'vers upgrade') Two issues: 1. The integration test runner builds the CLI via `go build` against the PR merge commit, producing a Go module pseudo-version like `v0.0.0-<timestamp>-<commit>`. The previous `IsDevVersion` only matched `dev` / `dev-*` / `*-dirty`, so the pseudo-version sailed through, hit the live GitHub API, and printed the banner. 2. `testutil.RunVers` uses `cmd.CombinedOutput()`, so even though the banner correctly writes to stderr, it still ended up in the buffer that tests parse. Tests doing `strings.TrimSpace(out) != "v1"` are particularly fragile. Fixes: - IsDevVersion now also matches `v0.0.0-...` pseudo-versions. This protects `go install`, `go run`, and any module-graph build against an untagged commit — none of which represent a published release the user can "upgrade" to. - New `VERS_NO_UPDATE_CHECK` env var (also accepts `NO_UPDATE_NOTIFIER` for cross-tool consistency with npm) silences the check entirely before any other logic runs. Truthy parsing follows convention: unset/empty/0/false/no/off mean "don't suppress", anything else suppresses. - testutil.RunVers and RunVersInDir now set `VERS_NO_UPDATE_CHECK=1` unconditionally so future integration tests are immune to this class of contamination even if a real release ships during a CI run. New tests cover pseudo-version detection, both env-var names, and falsy-value rejection.
1 parent 9df98d4 commit ff9efbb

3 files changed

Lines changed: 126 additions & 6 deletions

File tree

internal/update/update.go

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,31 @@ type GitHubRelease struct {
2929

3030
// IsDevVersion reports whether a version string represents a local/dev build
3131
// for which we should skip update checks entirely.
32+
//
33+
// This catches:
34+
// - the literal sentinels "dev" / "unknown" / "" set when ldflags weren't applied
35+
// - "dev-<sha>" / "*-dirty" produced by our init() fallback against `git describe`
36+
// - Go module *pseudo-versions* of the form "v0.0.0-<timestamp>-<commit>",
37+
// which are what `go install` and `go run` produce against an untagged
38+
// commit. Without this, integration test runs that build via the module
39+
// graph end up with a perfectly valid-looking semver and try to "upgrade"
40+
// to whatever real release is currently latest, contaminating test output.
3241
func IsDevVersion(v string) bool {
33-
v = strings.TrimPrefix(v, "v")
34-
return v == "" || v == "dev" || v == "unknown" || strings.HasPrefix(v, "dev-") || strings.Contains(v, "-dirty")
42+
stripped := strings.TrimPrefix(v, "v")
43+
if stripped == "" || stripped == "dev" || stripped == "unknown" {
44+
return true
45+
}
46+
if strings.HasPrefix(stripped, "dev-") || strings.Contains(stripped, "-dirty") {
47+
return true
48+
}
49+
// Go pseudo-versions all start with "0.0.0-" (untagged repo) or
50+
// "X.Y.Z-0.<timestamp>-<commit>" (post-tag). The first form is the
51+
// only one we hit in practice (the release pipeline sets a real
52+
// version via ldflags), so checking that prefix is sufficient.
53+
if strings.HasPrefix(stripped, "0.0.0-") {
54+
return true
55+
}
56+
return false
3557
}
3658

3759
// CheckForUpdates checks if there's a new version available.
@@ -227,7 +249,19 @@ func isNewerSemver(current, latest string) bool {
227249
// Errors are intentionally swallowed — the update check must never break a
228250
// real command. When verbose is true, debug output is written to stderr.
229251
func MaybeNotifyUpdate(ctx context.Context, current, repository string, timeout time.Duration, verbose bool) {
252+
// Escape hatches for CI / scripted use. Either of these silences the
253+
// nag and skips all network I/O. Mirrors NO_UPDATE_NOTIFIER (npm) and
254+
// HOMEBREW_NO_AUTO_UPDATE (brew), which are well-known patterns.
255+
if envFlagSet("VERS_NO_UPDATE_CHECK") || envFlagSet("NO_UPDATE_NOTIFIER") {
256+
if verbose {
257+
fmt.Fprintf(os.Stderr, "[DEBUG] update: skipped (VERS_NO_UPDATE_CHECK / NO_UPDATE_NOTIFIER set)\n")
258+
}
259+
return
260+
}
230261
if IsDevVersion(current) {
262+
if verbose {
263+
fmt.Fprintf(os.Stderr, "[DEBUG] update: skipped (dev/pseudo version %q)\n", current)
264+
}
231265
return
232266
}
233267

@@ -284,3 +318,20 @@ func MaybeNotifyUpdate(ctx context.Context, current, repository string, timeout
284318
func printUpdateBanner(current, latest string) {
285319
fmt.Fprintf(os.Stderr, "💡 vers update available: %s -> %s (run 'vers upgrade')\n\n", current, latest)
286320
}
321+
322+
// envFlagSet returns true if the env var is set to a "truthy" value.
323+
// Treats unset, empty string, "0", "false", "no", "off" (case-insensitive)
324+
// as false and anything else as true. This matches the convention used by
325+
// most CLI tools and avoids surprises like VERS_NO_UPDATE_CHECK=0 being
326+
// interpreted as "yes, suppress".
327+
func envFlagSet(name string) bool {
328+
v := strings.TrimSpace(os.Getenv(name))
329+
if v == "" {
330+
return false
331+
}
332+
switch strings.ToLower(v) {
333+
case "0", "false", "no", "off":
334+
return false
335+
}
336+
return true
337+
}

internal/update/update_test.go

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,14 @@ func TestIsNewerSemver(t *testing.T) {
4444
}
4545

4646
func TestIsDevVersion(t *testing.T) {
47-
dev := []string{"dev", "unknown", "dev-abc1234", "dev-abc1234-dirty", "v0.10.0-dirty", ""}
47+
dev := []string{
48+
"dev", "unknown", "dev-abc1234", "dev-abc1234-dirty",
49+
"v0.10.0-dirty", "",
50+
// Go module pseudo-versions, as produced by `go install` /
51+
// `go run` against an untagged commit.
52+
"v0.0.0-20260506213344-eb1e5d25fa7c",
53+
"v0.0.0-20260101000000-000000000000",
54+
}
4855
notDev := []string{"v0.10.0", "0.10.0", "v1.2.3", "v0.10.0-rc1"}
4956
for _, v := range dev {
5057
if !IsDevVersion(v) {
@@ -58,6 +65,66 @@ func TestIsDevVersion(t *testing.T) {
5865
}
5966
}
6067

68+
func TestMaybeNotifyUpdate_EnvVarSilencesCheck(t *testing.T) {
69+
withTempHome(t)
70+
71+
// Server that fails the test if hit.
72+
hits := 0
73+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
74+
hits++
75+
http.Error(w, "should not be called", 500)
76+
}))
77+
defer srv.Close()
78+
withRewrittenHTTP(t, strings.TrimPrefix(srv.URL, "http://"))
79+
80+
for _, varName := range []string{"VERS_NO_UPDATE_CHECK", "NO_UPDATE_NOTIFIER"} {
81+
t.Run(varName, func(t *testing.T) {
82+
t.Setenv(varName, "1")
83+
out := captureStderr(t, func() {
84+
MaybeNotifyUpdate(context.Background(), "v0.9.0", "https://github.com/hdresearch/vers-cli", 500*time.Millisecond, false)
85+
})
86+
if hits != 0 {
87+
t.Errorf("expected zero network calls when %s=1, got %d", varName, hits)
88+
}
89+
if out != "" {
90+
t.Errorf("expected silent output when %s=1, got %q", varName, out)
91+
}
92+
})
93+
}
94+
}
95+
96+
func TestMaybeNotifyUpdate_EnvVarFalsyValuesDoNotSilence(t *testing.T) {
97+
// "0", "false", "no", "off" should NOT silence the nag — only positive
98+
// truthy values do. This mirrors common CLI conventions.
99+
for _, val := range []string{"0", "false", "FALSE", "no", "off", ""} {
100+
t.Run("VERS_NO_UPDATE_CHECK="+val, func(t *testing.T) {
101+
withTempHome(t)
102+
t.Setenv("VERS_NO_UPDATE_CHECK", val)
103+
t.Setenv("NO_UPDATE_NOTIFIER", "")
104+
105+
cfg := &config.CLIConfig{
106+
UpdateCheck: config.UpdateCheckConfig{
107+
NextCheck: time.Now().Add(-1 * time.Hour),
108+
CheckInterval: 3600,
109+
},
110+
}
111+
_ = config.SaveCLIConfig(cfg)
112+
113+
hits := 0
114+
srv := fakeReleasesServer(t, "v0.10.0", &hits)
115+
defer srv.Close()
116+
withRewrittenHTTP(t, strings.TrimPrefix(srv.URL, "http://"))
117+
118+
out := captureStderr(t, func() {
119+
MaybeNotifyUpdate(context.Background(), "v0.9.0", "https://github.com/hdresearch/vers-cli", 1*time.Second, false)
120+
})
121+
if !strings.Contains(out, "v0.9.0 -> v0.10.0") {
122+
t.Errorf("expected banner with VERS_NO_UPDATE_CHECK=%q, got %q", val, out)
123+
}
124+
})
125+
}
126+
}
127+
61128
// withTempHome redirects $HOME to a temporary directory for the duration of
62129
// the test so config.LoadCLIConfig / SaveCLIConfig don't touch the user's
63130
// real ~/.vers/config.json. Returns the path to the temp config file.

test/testutil/helpers.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,10 @@ func RunVers(t TLike, timeout time.Duration, args ...string) (string, error) {
8585
defer cancel()
8686

8787
cmd := exec.CommandContext(ctx, BinPath, args...)
88-
// Inherit env so VERS_URL and VERS_API_KEY are visible
89-
cmd.Env = os.Environ()
88+
// Inherit env so VERS_URL and VERS_API_KEY are visible, but force
89+
// the update-check off so the "💡 vers update available" banner
90+
// can't contaminate command output that tests parse.
91+
cmd.Env = append(os.Environ(), "VERS_NO_UPDATE_CHECK=1")
9092
out, err := cmd.CombinedOutput()
9193
if ctx.Err() == context.DeadlineExceeded {
9294
return string(out), fmt.Errorf("command timed out: vers %s", strings.Join(args, " "))
@@ -107,7 +109,7 @@ func RunVersInDir(t TLike, dir string, timeout time.Duration, args ...string) (s
107109

108110
cmd := exec.CommandContext(ctx, absBin, args...)
109111
cmd.Dir = dir
110-
cmd.Env = os.Environ()
112+
cmd.Env = append(os.Environ(), "VERS_NO_UPDATE_CHECK=1")
111113
out, err := cmd.CombinedOutput()
112114
if ctx.Err() == context.DeadlineExceeded {
113115
return string(out), fmt.Errorf("command timed out: vers %s", strings.Join(args, " "))

0 commit comments

Comments
 (0)