11package update
22
33import (
4+ "context"
45 "encoding/json"
56 "fmt"
67 "net/http"
8+ "os"
79 "strings"
810 "time"
911
@@ -25,39 +27,83 @@ type GitHubRelease struct {
2527 PublishedAt time.Time `json:"published_at"`
2628}
2729
28- // CheckForUpdates checks if there's a new version available
30+ // IsDevVersion reports whether a version string represents a local/dev build
31+ // 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.
41+ func IsDevVersion (v string ) bool {
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
57+ }
58+
59+ // CheckForUpdates checks if there's a new version available.
60+ // This is a thin wrapper around CheckForUpdatesContext that uses a default
61+ // 5s timeout for backward compatibility with callers that don't manage
62+ // their own context (e.g. `vers upgrade`).
2963func CheckForUpdates (currentVersion , repository string , verbose bool ) (bool , string , error ) {
30- // Skip check for dev versions
31- currentVersion = strings .TrimPrefix (currentVersion , "v" )
32- if currentVersion == "dev" || currentVersion == "unknown" {
64+ ctx , cancel := context .WithTimeout (context .Background (), 5 * time .Second )
65+ defer cancel ()
66+ return CheckForUpdatesContext (ctx , currentVersion , repository , verbose )
67+ }
68+
69+ // CheckForUpdatesContext checks if there's a new version available, honoring
70+ // the supplied context's deadline/cancellation.
71+ func CheckForUpdatesContext (ctx context.Context , currentVersion , repository string , verbose bool ) (bool , string , error ) {
72+ if IsDevVersion (currentVersion ) {
3373 if verbose {
34- fmt .Printf ("[DEBUG] Skipping update check for development version\n " )
74+ fmt .Printf ("[DEBUG] Skipping update check for development version %q \n " , currentVersion )
3575 }
3676 return false , "" , nil
3777 }
3878
39- // Get latest release
40- latest , err := GetLatestRelease (repository , false , verbose )
79+ latest , err := GetLatestReleaseContext (ctx , repository , false , verbose )
4180 if err != nil {
4281 if verbose {
4382 fmt .Printf ("[DEBUG] Failed to check for updates: %v\n " , err )
4483 }
45- return false , "" , nil // Don't error out - just skip the check
84+ return false , "" , err
4685 }
4786
87+ current := strings .TrimPrefix (currentVersion , "v" )
4888 latestVersion := strings .TrimPrefix (latest .TagName , "v" )
4989 if verbose {
50- fmt .Printf ("[DEBUG] Current: %s, Latest: %s\n " , currentVersion , latestVersion )
90+ fmt .Printf ("[DEBUG] Current: %s, Latest: %s\n " , current , latestVersion )
5191 }
5292
53- // Check if there's an update available
54- hasUpdate := currentVersion != latestVersion
55- return hasUpdate , latest .TagName , nil
93+ return current != latestVersion , latest .TagName , nil
5694}
5795
58- // GetLatestRelease fetches the latest release from GitHub
59- // If includePrerelease is true, it will return the latest release including prereleases
96+ // GetLatestRelease fetches the latest release from GitHub.
97+ // If includePrerelease is true, it will return the latest release including
98+ // prereleases. Uses a default 5s timeout.
6099func GetLatestRelease (repository string , includePrerelease bool , verbose bool ) (* GitHubRelease , error ) {
100+ ctx , cancel := context .WithTimeout (context .Background (), 5 * time .Second )
101+ defer cancel ()
102+ return GetLatestReleaseContext (ctx , repository , includePrerelease , verbose )
103+ }
104+
105+ // GetLatestReleaseContext is like GetLatestRelease but honors the supplied context.
106+ func GetLatestReleaseContext (ctx context.Context , repository string , includePrerelease bool , verbose bool ) (* GitHubRelease , error ) {
61107 // Extract owner/repo from Repository constant
62108 repoURL := strings .TrimPrefix (repository , "https://github.com/" )
63109
@@ -70,7 +116,13 @@ func GetLatestRelease(repository string, includePrerelease bool, verbose bool) (
70116 fmt .Printf ("[DEBUG] Fetching release info from: %s\n " , apiURL )
71117 }
72118
73- resp , err := http .Get (apiURL )
119+ req , err := http .NewRequestWithContext (ctx , http .MethodGet , apiURL , nil )
120+ if err != nil {
121+ return nil , fmt .Errorf ("failed to build release request: %w" , err )
122+ }
123+ req .Header .Set ("Accept" , "application/vnd.github+json" )
124+
125+ resp , err := http .DefaultClient .Do (req )
74126 if err != nil {
75127 return nil , fmt .Errorf ("failed to fetch release info: %w" , err )
76128 }
@@ -123,3 +175,163 @@ func UpdateCheckTime() {
123175 cliConfig .SetNextCheckTime ()
124176 config .SaveCLIConfig (cliConfig )
125177}
178+
179+ // isNewerSemver returns true if `latest` is a strictly higher version than
180+ // `current` using a tolerant lexical comparison after stripping a leading
181+ // "v". Falls back to plain string inequality if either side fails to parse
182+ // as dotted integers, which preserves the old behavior.
183+ func isNewerSemver (current , latest string ) bool {
184+ c := strings .TrimPrefix (current , "v" )
185+ l := strings .TrimPrefix (latest , "v" )
186+ if c == "" || l == "" || c == l {
187+ return false
188+ }
189+
190+ // Strip pre-release/build metadata for the numeric comparison.
191+ stripMeta := func (s string ) string {
192+ if i := strings .IndexAny (s , "-+" ); i >= 0 {
193+ return s [:i ]
194+ }
195+ return s
196+ }
197+
198+ cp := strings .Split (stripMeta (c ), "." )
199+ lp := strings .Split (stripMeta (l ), "." )
200+ parseInt := func (s string ) (int , bool ) {
201+ n := 0
202+ if s == "" {
203+ return 0 , false
204+ }
205+ for _ , r := range s {
206+ if r < '0' || r > '9' {
207+ return 0 , false
208+ }
209+ n = n * 10 + int (r - '0' )
210+ }
211+ return n , true
212+ }
213+
214+ for i := 0 ; i < len (cp ) || i < len (lp ); i ++ {
215+ var ci , li int
216+ var ok bool
217+ if i < len (cp ) {
218+ if ci , ok = parseInt (cp [i ]); ! ok {
219+ return c != l // unparseable -> fall back
220+ }
221+ }
222+ if i < len (lp ) {
223+ if li , ok = parseInt (lp [i ]); ! ok {
224+ return c != l
225+ }
226+ }
227+ if li > ci {
228+ return true
229+ }
230+ if li < ci {
231+ return false
232+ }
233+ }
234+ return false
235+ }
236+
237+ // MaybeNotifyUpdate prints an "update available" message to the given writer
238+ // when a newer release is known. It is designed to be called once during CLI
239+ // startup and is cheap on the hot path:
240+ //
241+ // - If a previously-cached LatestVersion is newer than `current`, the
242+ // message prints synchronously with no network I/O.
243+ // - Otherwise, if it's been longer than the configured check interval since
244+ // the last successful check, it performs a single bounded HTTP request
245+ // (capped at `timeout`) to refresh the cache. The cache (and NextCheck
246+ // timestamp) are only advanced on a successful response, so transient
247+ // network failures don't suppress the nag for an hour.
248+ //
249+ // Errors are intentionally swallowed — the update check must never break a
250+ // real command. When verbose is true, debug output is written to stderr.
251+ 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+ }
261+ if IsDevVersion (current ) {
262+ if verbose {
263+ fmt .Fprintf (os .Stderr , "[DEBUG] update: skipped (dev/pseudo version %q)\n " , current )
264+ }
265+ return
266+ }
267+
268+ cliConfig , err := config .LoadCLIConfig ()
269+ if err != nil {
270+ if verbose {
271+ fmt .Fprintf (os .Stderr , "[DEBUG] update: failed to load CLI config: %v\n " , err )
272+ }
273+ return
274+ }
275+
276+ // 1. Fast path: print from cache if we already know about a newer release.
277+ cached := cliConfig .UpdateCheck .LatestVersion
278+ printedCached := false
279+ if cached != "" && isNewerSemver (current , cached ) {
280+ printUpdateBanner (current , cached )
281+ printedCached = true
282+ }
283+
284+ if ! cliConfig .ShouldCheckForUpdate () {
285+ return
286+ }
287+
288+ // 2. Slow path: refresh from GitHub with a tight timeout.
289+ fetchCtx , cancel := context .WithTimeout (ctx , timeout )
290+ defer cancel ()
291+
292+ latest , err := GetLatestReleaseContext (fetchCtx , repository , false , verbose )
293+ if err != nil {
294+ if verbose {
295+ fmt .Fprintf (os .Stderr , "[DEBUG] update: refresh failed: %v\n " , err )
296+ }
297+ // Don't fully bump NextCheck on failure — try again soon (5min backoff)
298+ // so a flaky network at the moment of first launch doesn't suppress
299+ // the nag for the full check interval.
300+ cliConfig .UpdateCheck .NextCheck = time .Now ().Add (5 * time .Minute )
301+ _ = config .SaveCLIConfig (cliConfig )
302+ return
303+ }
304+
305+ cliConfig .UpdateCheck .LatestVersion = latest .TagName
306+ cliConfig .SetNextCheckTime ()
307+ if err := config .SaveCLIConfig (cliConfig ); err != nil && verbose {
308+ fmt .Fprintf (os .Stderr , "[DEBUG] update: failed to save CLI config: %v\n " , err )
309+ }
310+
311+ // If the refresh revealed a newer version that the fast path didn't
312+ // already print, print now.
313+ if ! printedCached && isNewerSemver (current , latest .TagName ) {
314+ printUpdateBanner (current , latest .TagName )
315+ }
316+ }
317+
318+ func printUpdateBanner (current , latest string ) {
319+ fmt .Fprintf (os .Stderr , "💡 vers update available: %s -> %s (run 'vers upgrade')\n \n " , current , latest )
320+ }
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+ }
0 commit comments