rickub/clipublic Fork
⑂ b4bb81e
Commits
⬇ Clone ▾
git clone https://git.rickub.com/rickub/cli.git
git clone ssh://git@rickub.com/rickub/cli.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

feat: warn when the CLI is older than the newest release on its hostUnverified

Implements the staleness signal of rickub/rickub issue 305: a stale
binary describes an old product as current, which is how two false bug
reports were filed in one night against defects main had already fixed.

Every command (version included) starts with a throttled best-effort
probe: GET /rickub/cli/releases/latest on the effective host, the
documented 302 whose Location carries the newest release tag (the same
redirect install scripts resolve latest with - no token, no HTML
scraping). When that release is strictly newer than the running
version, a one-line notice names both versions and the upgrade URL, on
stderr so JSON output and pipes stay clean.

Once per day per host, via a stamp beside the config file. Dev builds
(Version dev) never probe. Any failure - no releases, unreachable host,
exotic tag - is silent; an unparsable version never warns. Semver
ordering keeps an rc of the running version from nagging stable users.

Bite-proven by removing the PersistentPreRunE hook (notice and throttle
tests fail) and end-to-end with real binaries: a 0.1.0 build against a
host whose latest is v0.2.0 prints the notice on stderr while stdout
keeps the plain version line; a 0.2.0 build stays silent.
Olivier Girardot committed 2026-10-11T19:17:23+02:00 Browse files
b4bb81e parent: 507eaf7
modified cmd/root.go +8 -0
@@ -37,6 +37,14 @@ Authenticate once with "rickub auth login", then drive repos, merge requests,
3737 CI runs, orgs, and code browsing from your terminal.`,
3838 SilenceUsage: true,
3939 SilenceErrors: true,
40+ // Every command starts with the (throttled, best-effort) staleness probe of
41+ // cmd/update_check.go — a stale CLI describes an old product as current
42+ // (rickub/rickub#305), and the notice has to appear on the commands people
43+ // actually run, not only on `rickub version`.
44+ PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
45+ maybeWarnAboutStaleCLI(cmd)
46+ return nil
47+ },
4048 }
4149
4250 // Execute runs the root command. main() maps the returned error to an exit code.
@@ -37,6 +37,14 @@ Authenticate once with "rickub auth login", then drive repos, merge requests,
37 CI runs, orgs, and code browsing from your terminal.`,37 CI runs, orgs, and code browsing from your terminal.`,
38 SilenceUsage: true,38 SilenceUsage: true,
39 SilenceErrors: true,39 SilenceErrors: true,
40+ // Every command starts with the (throttled, best-effort) staleness probe of
41+ // cmd/update_check.go — a stale CLI describes an old product as current
42+ // (rickub/rickub#305), and the notice has to appear on the commands people
43+ // actually run, not only on `rickub version`.
44+ PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
45+ maybeWarnAboutStaleCLI(cmd)
46+ return nil
47+ },
40 }48 }
41 49
42 // Execute runs the root command. main() maps the returned error to an exit code.50 // Execute runs the root command. main() maps the returned error to an exit code.
added cmd/update_check.go +298 -0
new file mode 100644
@@ -0,0 +1,298 @@
1+package cmd
2+
3+import (
4+ "context"
5+ "fmt"
6+ "net/http"
7+ "net/url"
8+ "os"
9+ "path/filepath"
10+ "strconv"
11+ "strings"
12+ "time"
13+
14+ "rickub.com/rickub/cli/internal/config"
15+
16+ "github.com/spf13/cobra"
17+)
18+
19+// --- CLI staleness detection (rickub/rickub issue 305) -------------------------
20+//
21+// A stale CLI does not fail loudly — it describes an OLD version of the product
22+// as if it were current, and everything downstream inherits that: issues filed
23+// against defects already fixed (#294, #279), agents concluding a shipped
24+// feature is missing, hours re-diagnosing what was diagnosed. It is also
25+// self-concealing: "does my CLI support X?" answers truthfully about the BINARY,
26+// which is the wrong question, and nothing prompts you to ask which build you
27+// are holding.
28+//
29+// The honest, minimal signal: the host the CLI already talks to names the newest
30+// rickub/cli release — GET /rickub/cli/releases/latest is a documented 302 whose
31+// Location carries the tag (the same redirect the install script resolves
32+// "latest" with; no token, no HTML scraping). When that release is strictly
33+// NEWER than the running binary, print a one-line notice naming both versions.
34+//
35+// The check is throttled to once per day per host (a stamp beside the config
36+// file), always best-effort — any failure is silent and never blocks the real
37+// command — and skipped entirely for dev builds (Version "dev"), which carry no
38+// release identity to compare.
39+
40+const (
41+ // updateCheckOwner/Repo: the CLI's own distribution repository, served by
42+ // every host that mirrors it. A host without it 404s and the check stays
43+ // quiet — nothing to compare, nothing to say.
44+ updateCheckOwner = "rickub"
45+ updateCheckRepo = "cli"
46+ // updateCheckTimeout bounds the probe; it runs BEFORE the real command, so
47+ // a slow host must not tax every invocation (once a day, this is the ceiling).
48+ updateCheckTimeout = 2 * time.Second
49+ // updateCheckInterval is how often the probe repeats per host.
50+ updateCheckInterval = 24 * time.Hour
51+)
52+
53+// maybeWarnAboutStaleCLI is hooked into the root command's PersistentPreRun:
54+// every command (version included) gets the notice at most once a day. It never
55+// returns an error and never writes to stdout — the notice goes to stderr, so
56+// --json output stays parseable and pipes stay clean.
57+func maybeWarnAboutStaleCLI(cmd *cobra.Command) {
58+ if Version == "dev" {
59+ return // a build from source has no release identity to compare
60+ }
61+ cfg, err := loadConfig()
62+ if err != nil {
63+ return
64+ }
65+ host := config.ResolveHost(flagHost, cfg)
66+ stampPath, err := updateStampPath(host)
67+ if err != nil {
68+ return
69+ }
70+ if stampIsFresh(stampPath) {
71+ return
72+ }
73+ // An attempted check (whatever the outcome) suppresses the next for a day:
74+ // a host that cannot answer today should not be probed on every command.
75+ writeUpdateStamp(stampPath)
76+ tag := resolveLatestCLITag(cmd.Context(), host)
77+ if !newerThan(tag, Version) {
78+ return
79+ }
80+ base := strings.TrimSuffix(config.NormalizeHost(host), "/")
81+ fmt.Fprintf(cmd.ErrOrStderr(),
82+ "note: this rickub CLI is %s but the newest release on %s is %s — some bugs you see may already be fixed; upgrade: %s/%s/%s/releases/%s\n",
83+ Version, base, tag, base, updateCheckOwner, updateCheckRepo, tag)
84+}
85+
86+// resolveLatestCLITag asks host for the newest rickub/cli release tag via the
87+// documented /releases/latest redirect, returning "" on ANY failure (no
88+// redirect, no releases, unreachable host, unparsable Location). Redirects are
89+// not followed: the Location header is the answer.
90+func resolveLatestCLITag(ctx context.Context, host string) string {
91+ base := strings.TrimSuffix(config.NormalizeHost(host), "/")
92+ if base == "" {
93+ return ""
94+ }
95+ req, err := http.NewRequestWithContext(ctx, http.MethodGet,
96+ base+"/"+updateCheckOwner+"/"+updateCheckRepo+"/releases/latest", nil)
97+ if err != nil {
98+ return ""
99+ }
100+ client := &http.Client{
101+ Timeout: updateCheckTimeout,
102+ CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
103+ return http.ErrUseLastResponse
104+ },
105+ }
106+ resp, err := client.Do(req)
107+ if err != nil {
108+ return ""
109+ }
110+ defer func() { _ = resp.Body.Close() }()
111+ if resp.StatusCode != http.StatusFound && resp.StatusCode != http.StatusMovedPermanently {
112+ return ""
113+ }
114+ return tagFromReleaseLocation(resp.Header.Get("Location"))
115+}
116+
117+// tagFromReleaseLocation extracts the release tag from a /releases/latest
118+// Location value: "/rickub/cli/releases/v0.2.0" (relative, what the host
119+// writes) or "https://host/rickub/cli/releases/v0.1.0-rc.9" (absolute). The tag
120+// is the whole tail after "/releases/" — tags may contain slashes, and the
121+// redirect builds the path from the tag verbatim. Anything else returns "".
122+func tagFromReleaseLocation(loc string) string {
123+ if loc == "" {
124+ return ""
125+ }
126+ u, err := url.Parse(loc)
127+ if err != nil {
128+ return ""
129+ }
130+ parts := strings.Split(strings.Trim(u.Path, "/"), "/")
131+ const min = 4 // owner, repo, "releases", tag...
132+ if len(parts) < min || parts[2] != "releases" {
133+ return ""
134+ }
135+ tag := strings.Join(parts[3:], "/")
136+ if tag == "" || !looksLikeTag(tag) {
137+ return ""
138+ }
139+ decoded, err := url.PathUnescape(tag)
140+ if err != nil {
141+ return ""
142+ }
143+ return decoded
144+}
145+
146+// looksLikeTag guards the tail-is-the-tag reading against a Location pointing
147+// at a different page under /releases/ (the list page, "latest" itself, an
148+// asset download). A release tag names a version; it may carry slashes, but its
149+// first segment is never one of the reserved page names.
150+func looksLikeTag(tag string) bool {
151+ switch strings.Split(tag, "/")[0] {
152+ case "new", "latest", "download", "edit", "release-assets":
153+ return false
154+ }
155+ return true
156+}
157+
158+// ---- version comparison -------------------------------------------------------
159+//
160+// Release tags are v-prefixed semver ("v0.2.0", "v0.1.0-rc.11"); the stamped
161+// Version is bare ("0.2.0", "0.1.0-rc.11"). Comparison follows semver ordering:
162+// numeric major.minor.patch (missing components are 0), then prerelease — where
163+// a version WITHOUT a prerelease is newer than the same version WITH one, and
164+// prerelease identifiers compare numerically when both are numeric, otherwise
165+// byte-wise (semver spec rule 12, the parts rc.N needs).
166+
167+// newerThan reports whether latest is a strictly newer version than current.
168+// Anything unparsable is NOT newer: an exotic tag never produces a notice.
169+func newerThan(latest, current string) bool {
170+ return compareVersions(latest, current) > 0
171+}
172+
173+// compareVersions returns -1, 0 or 1 comparing two v-prefixed-or-bare semver
174+// strings. Two unparsable, or an unparsable against a parsable, compare equal
175+// (0) — the caller's contract is "strictly newer means warn", so unknowns never
176+// warn.
177+func compareVersions(a, b string) int {
178+ ac, ap, aok := splitVersion(a)
179+ bc, bp, bok := splitVersion(b)
180+ if !aok || !bok {
181+ return 0
182+ }
183+ for i := range 3 {
184+ if ac[i] != bc[i] {
185+ if ac[i] < bc[i] {
186+ return -1
187+ }
188+ return 1
189+ }
190+ }
191+ // Same numbers: no prerelease wins over any prerelease.
192+ if ap == "" && bp == "" {
193+ return 0
194+ }
195+ if ap == "" {
196+ return 1
197+ }
198+ if bp == "" {
199+ return -1
200+ }
201+ return comparePrerelease(ap, bp)
202+}
203+
204+// splitVersion parses "v1.2.3-rc.4" into ([3]int{1,2,3}, "rc.4"); ok is false
205+// for a version it does not understand.
206+func splitVersion(v string) (core [3]int, pre string, ok bool) {
207+ v = strings.TrimPrefix(strings.TrimSpace(v), "v")
208+ coreStr, pre, _ := strings.Cut(v, "-")
209+ parts := strings.Split(coreStr, ".")
210+ if len(parts) == 0 || len(parts) > 3 {
211+ return [3]int{}, "", false
212+ }
213+ for i, p := range parts {
214+ n, err := strconv.Atoi(p)
215+ if err != nil || n < 0 || (len(p) > 1 && p[0] == '0') {
216+ return [3]int{}, "", false // leading zeros / non-numeric: not semver
217+ }
218+ core[i] = n
219+ }
220+ return core, pre, true
221+}
222+
223+// comparePrerelease orders two dot-separated prerelease strings per semver: each
224+// identifier numerically when both are all-digits, else byte-wise; a shorter
225+// list is LOWER when every shared identifier is equal.
226+func comparePrerelease(a, b string) int {
227+ as, bs := strings.Split(a, "."), strings.Split(b, ".")
228+ for i := 0; i < len(as) && i < len(bs); i++ {
229+ an, aerr := strconv.Atoi(as[i])
230+ bn, berr := strconv.Atoi(bs[i])
231+ if aerr == nil && berr == nil {
232+ if an != bn {
233+ if an < bn {
234+ return -1
235+ }
236+ return 1
237+ }
238+ continue
239+ }
240+ if as[i] != bs[i] {
241+ if as[i] < bs[i] {
242+ return -1
243+ }
244+ return 1
245+ }
246+ }
247+ switch {
248+ case len(as) < len(bs):
249+ return -1
250+ case len(as) > len(bs):
251+ return 1
252+ }
253+ return 0
254+}
255+
256+// ---- the once-a-day stamp -----------------------------------------------------
257+
258+// updateStampPath names the stamp file beside the config file, one per host:
259+// <configdir>/rickub/update-check/<normalized host>. The host is normalized and
260+// path-safe (slashes and colons folded to underscores) so two hosts never share
261+// a stamp.
262+func updateStampPath(host string) (string, error) {
263+ cfgPath, err := config.Path()
264+ if err != nil {
265+ return "", err
266+ }
267+ key := config.NormalizeHost(host)
268+ if key == "" {
269+ key = "default"
270+ }
271+ key = strings.NewReplacer("/", "_", ":", "_").Replace(key)
272+ return filepath.Join(filepath.Dir(cfgPath), "update-check", key), nil
273+}
274+
275+// stampIsFresh reports whether the stamp at path was written within the check
276+// interval. A missing or unreadable stamp is stale (check again); a future
277+// stamp (clock moved back) is treated as fresh so it cannot pinball the probe.
278+func stampIsFresh(path string) bool {
279+ data, err := os.ReadFile(path)
280+ if err != nil {
281+ return false
282+ }
283+ stamped, err := time.Parse(time.RFC3339, strings.TrimSpace(string(data)))
284+ if err != nil {
285+ return false
286+ }
287+ return time.Since(stamped) < updateCheckInterval || stamped.After(time.Now())
288+}
289+
290+// writeUpdateStamp records a successful attempt, best-effort: a stamp that
291+// cannot be written simply means the check may repeat — never a user-visible
292+// error.
293+func writeUpdateStamp(path string) {
294+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
295+ return
296+ }
297+ _ = os.WriteFile(path, []byte(time.Now().UTC().Format(time.RFC3339)), 0o600)
298+}
new file mode 100644
@@ -0,0 +1,298 @@
1+package cmd
2+
3+import (
4+ "context"
5+ "fmt"
6+ "net/http"
7+ "net/url"
8+ "os"
9+ "path/filepath"
10+ "strconv"
11+ "strings"
12+ "time"
13+
14+ "rickub.com/rickub/cli/internal/config"
15+
16+ "github.com/spf13/cobra"
17+)
18+
19+// --- CLI staleness detection (rickub/rickub issue 305) -------------------------
20+//
21+// A stale CLI does not fail loudly — it describes an OLD version of the product
22+// as if it were current, and everything downstream inherits that: issues filed
23+// against defects already fixed (#294, #279), agents concluding a shipped
24+// feature is missing, hours re-diagnosing what was diagnosed. It is also
25+// self-concealing: "does my CLI support X?" answers truthfully about the BINARY,
26+// which is the wrong question, and nothing prompts you to ask which build you
27+// are holding.
28+//
29+// The honest, minimal signal: the host the CLI already talks to names the newest
30+// rickub/cli release — GET /rickub/cli/releases/latest is a documented 302 whose
31+// Location carries the tag (the same redirect the install script resolves
32+// "latest" with; no token, no HTML scraping). When that release is strictly
33+// NEWER than the running binary, print a one-line notice naming both versions.
34+//
35+// The check is throttled to once per day per host (a stamp beside the config
36+// file), always best-effort — any failure is silent and never blocks the real
37+// command — and skipped entirely for dev builds (Version "dev"), which carry no
38+// release identity to compare.
39+
40+const (
41+ // updateCheckOwner/Repo: the CLI's own distribution repository, served by
42+ // every host that mirrors it. A host without it 404s and the check stays
43+ // quiet — nothing to compare, nothing to say.
44+ updateCheckOwner = "rickub"
45+ updateCheckRepo = "cli"
46+ // updateCheckTimeout bounds the probe; it runs BEFORE the real command, so
47+ // a slow host must not tax every invocation (once a day, this is the ceiling).
48+ updateCheckTimeout = 2 * time.Second
49+ // updateCheckInterval is how often the probe repeats per host.
50+ updateCheckInterval = 24 * time.Hour
51+)
52+
53+// maybeWarnAboutStaleCLI is hooked into the root command's PersistentPreRun:
54+// every command (version included) gets the notice at most once a day. It never
55+// returns an error and never writes to stdout — the notice goes to stderr, so
56+// --json output stays parseable and pipes stay clean.
57+func maybeWarnAboutStaleCLI(cmd *cobra.Command) {
58+ if Version == "dev" {
59+ return // a build from source has no release identity to compare
60+ }
61+ cfg, err := loadConfig()
62+ if err != nil {
63+ return
64+ }
65+ host := config.ResolveHost(flagHost, cfg)
66+ stampPath, err := updateStampPath(host)
67+ if err != nil {
68+ return
69+ }
70+ if stampIsFresh(stampPath) {
71+ return
72+ }
73+ // An attempted check (whatever the outcome) suppresses the next for a day:
74+ // a host that cannot answer today should not be probed on every command.
75+ writeUpdateStamp(stampPath)
76+ tag := resolveLatestCLITag(cmd.Context(), host)
77+ if !newerThan(tag, Version) {
78+ return
79+ }
80+ base := strings.TrimSuffix(config.NormalizeHost(host), "/")
81+ fmt.Fprintf(cmd.ErrOrStderr(),
82+ "note: this rickub CLI is %s but the newest release on %s is %s — some bugs you see may already be fixed; upgrade: %s/%s/%s/releases/%s\n",
83+ Version, base, tag, base, updateCheckOwner, updateCheckRepo, tag)
84+}
85+
86+// resolveLatestCLITag asks host for the newest rickub/cli release tag via the
87+// documented /releases/latest redirect, returning "" on ANY failure (no
88+// redirect, no releases, unreachable host, unparsable Location). Redirects are
89+// not followed: the Location header is the answer.
90+func resolveLatestCLITag(ctx context.Context, host string) string {
91+ base := strings.TrimSuffix(config.NormalizeHost(host), "/")
92+ if base == "" {
93+ return ""
94+ }
95+ req, err := http.NewRequestWithContext(ctx, http.MethodGet,
96+ base+"/"+updateCheckOwner+"/"+updateCheckRepo+"/releases/latest", nil)
97+ if err != nil {
98+ return ""
99+ }
100+ client := &http.Client{
101+ Timeout: updateCheckTimeout,
102+ CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
103+ return http.ErrUseLastResponse
104+ },
105+ }
106+ resp, err := client.Do(req)
107+ if err != nil {
108+ return ""
109+ }
110+ defer func() { _ = resp.Body.Close() }()
111+ if resp.StatusCode != http.StatusFound && resp.StatusCode != http.StatusMovedPermanently {
112+ return ""
113+ }
114+ return tagFromReleaseLocation(resp.Header.Get("Location"))
115+}
116+
117+// tagFromReleaseLocation extracts the release tag from a /releases/latest
118+// Location value: "/rickub/cli/releases/v0.2.0" (relative, what the host
119+// writes) or "https://host/rickub/cli/releases/v0.1.0-rc.9" (absolute). The tag
120+// is the whole tail after "/releases/" — tags may contain slashes, and the
121+// redirect builds the path from the tag verbatim. Anything else returns "".
122+func tagFromReleaseLocation(loc string) string {
123+ if loc == "" {
124+ return ""
125+ }
126+ u, err := url.Parse(loc)
127+ if err != nil {
128+ return ""
129+ }
130+ parts := strings.Split(strings.Trim(u.Path, "/"), "/")
131+ const min = 4 // owner, repo, "releases", tag...
132+ if len(parts) < min || parts[2] != "releases" {
133+ return ""
134+ }
135+ tag := strings.Join(parts[3:], "/")
136+ if tag == "" || !looksLikeTag(tag) {
137+ return ""
138+ }
139+ decoded, err := url.PathUnescape(tag)
140+ if err != nil {
141+ return ""
142+ }
143+ return decoded
144+}
145+
146+// looksLikeTag guards the tail-is-the-tag reading against a Location pointing
147+// at a different page under /releases/ (the list page, "latest" itself, an
148+// asset download). A release tag names a version; it may carry slashes, but its
149+// first segment is never one of the reserved page names.
150+func looksLikeTag(tag string) bool {
151+ switch strings.Split(tag, "/")[0] {
152+ case "new", "latest", "download", "edit", "release-assets":
153+ return false
154+ }
155+ return true
156+}
157+
158+// ---- version comparison -------------------------------------------------------
159+//
160+// Release tags are v-prefixed semver ("v0.2.0", "v0.1.0-rc.11"); the stamped
161+// Version is bare ("0.2.0", "0.1.0-rc.11"). Comparison follows semver ordering:
162+// numeric major.minor.patch (missing components are 0), then prerelease — where
163+// a version WITHOUT a prerelease is newer than the same version WITH one, and
164+// prerelease identifiers compare numerically when both are numeric, otherwise
165+// byte-wise (semver spec rule 12, the parts rc.N needs).
166+
167+// newerThan reports whether latest is a strictly newer version than current.
168+// Anything unparsable is NOT newer: an exotic tag never produces a notice.
169+func newerThan(latest, current string) bool {
170+ return compareVersions(latest, current) > 0
171+}
172+
173+// compareVersions returns -1, 0 or 1 comparing two v-prefixed-or-bare semver
174+// strings. Two unparsable, or an unparsable against a parsable, compare equal
175+// (0) — the caller's contract is "strictly newer means warn", so unknowns never
176+// warn.
177+func compareVersions(a, b string) int {
178+ ac, ap, aok := splitVersion(a)
179+ bc, bp, bok := splitVersion(b)
180+ if !aok || !bok {
181+ return 0
182+ }
183+ for i := range 3 {
184+ if ac[i] != bc[i] {
185+ if ac[i] < bc[i] {
186+ return -1
187+ }
188+ return 1
189+ }
190+ }
191+ // Same numbers: no prerelease wins over any prerelease.
192+ if ap == "" && bp == "" {
193+ return 0
194+ }
195+ if ap == "" {
196+ return 1
197+ }
198+ if bp == "" {
199+ return -1
200+ }
201+ return comparePrerelease(ap, bp)
202+}
203+
204+// splitVersion parses "v1.2.3-rc.4" into ([3]int{1,2,3}, "rc.4"); ok is false
205+// for a version it does not understand.
206+func splitVersion(v string) (core [3]int, pre string, ok bool) {
207+ v = strings.TrimPrefix(strings.TrimSpace(v), "v")
208+ coreStr, pre, _ := strings.Cut(v, "-")
209+ parts := strings.Split(coreStr, ".")
210+ if len(parts) == 0 || len(parts) > 3 {
211+ return [3]int{}, "", false
212+ }
213+ for i, p := range parts {
214+ n, err := strconv.Atoi(p)
215+ if err != nil || n < 0 || (len(p) > 1 && p[0] == '0') {
216+ return [3]int{}, "", false // leading zeros / non-numeric: not semver
217+ }
218+ core[i] = n
219+ }
220+ return core, pre, true
221+}
222+
223+// comparePrerelease orders two dot-separated prerelease strings per semver: each
224+// identifier numerically when both are all-digits, else byte-wise; a shorter
225+// list is LOWER when every shared identifier is equal.
226+func comparePrerelease(a, b string) int {
227+ as, bs := strings.Split(a, "."), strings.Split(b, ".")
228+ for i := 0; i < len(as) && i < len(bs); i++ {
229+ an, aerr := strconv.Atoi(as[i])
230+ bn, berr := strconv.Atoi(bs[i])
231+ if aerr == nil && berr == nil {
232+ if an != bn {
233+ if an < bn {
234+ return -1
235+ }
236+ return 1
237+ }
238+ continue
239+ }
240+ if as[i] != bs[i] {
241+ if as[i] < bs[i] {
242+ return -1
243+ }
244+ return 1
245+ }
246+ }
247+ switch {
248+ case len(as) < len(bs):
249+ return -1
250+ case len(as) > len(bs):
251+ return 1
252+ }
253+ return 0
254+}
255+
256+// ---- the once-a-day stamp -----------------------------------------------------
257+
258+// updateStampPath names the stamp file beside the config file, one per host:
259+// <configdir>/rickub/update-check/<normalized host>. The host is normalized and
260+// path-safe (slashes and colons folded to underscores) so two hosts never share
261+// a stamp.
262+func updateStampPath(host string) (string, error) {
263+ cfgPath, err := config.Path()
264+ if err != nil {
265+ return "", err
266+ }
267+ key := config.NormalizeHost(host)
268+ if key == "" {
269+ key = "default"
270+ }
271+ key = strings.NewReplacer("/", "_", ":", "_").Replace(key)
272+ return filepath.Join(filepath.Dir(cfgPath), "update-check", key), nil
273+}
274+
275+// stampIsFresh reports whether the stamp at path was written within the check
276+// interval. A missing or unreadable stamp is stale (check again); a future
277+// stamp (clock moved back) is treated as fresh so it cannot pinball the probe.
278+func stampIsFresh(path string) bool {
279+ data, err := os.ReadFile(path)
280+ if err != nil {
281+ return false
282+ }
283+ stamped, err := time.Parse(time.RFC3339, strings.TrimSpace(string(data)))
284+ if err != nil {
285+ return false
286+ }
287+ return time.Since(stamped) < updateCheckInterval || stamped.After(time.Now())
288+}
289+
290+// writeUpdateStamp records a successful attempt, best-effort: a stamp that
291+// cannot be written simply means the check may repeat — never a user-visible
292+// error.
293+func writeUpdateStamp(path string) {
294+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
295+ return
296+ }
297+ _ = os.WriteFile(path, []byte(time.Now().UTC().Format(time.RFC3339)), 0o600)
298+}
added cmd/update_check_test.go +372 -0
new file mode 100644
@@ -0,0 +1,372 @@
1+package cmd
2+
3+import (
4+ "net/http"
5+ "net/http/httptest"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "rickub.com/rickub/cli/internal/config"
13+)
14+
15+// Tests for the CLI staleness notice (rickub/rickub#305).
16+//
17+// The notice is the one-line signal that a binary older than the newest release
18+// on its host keeps describing an old product as current — the failure mode that
19+// manufactured the false bug reports #294 and #279. Each behavior test below
20+// fails on the pre-change code (no notice, no probe) and on the two plausible
21+// regressions: the comparator flipping (nagging on fresh binaries, silence on
22+// stale ones) and the probe losing its throttle (a request on every command).
23+
24+// stampAge rewinds the stamp for host by d, so a test can simulate "checked
25+// yesterday" without sleeping. It fails the test when no stamp exists — a probe
26+// that never wrote its stamp is itself a bug (it would hammer the host).
27+func stampAge(t *testing.T, host string, d time.Duration) {
28+ t.Helper()
29+ path, err := updateStampPath(host)
30+ if err != nil {
31+ t.Fatalf("stamp path: %v", err)
32+ }
33+ if err := os.WriteFile(path, []byte(time.Now().UTC().Add(-d).Format(time.RFC3339)), 0o600); err != nil {
34+ t.Fatalf("rewind stamp: %v", err)
35+ }
36+}
37+
38+// updateServer stands up a host whose /rickub/cli/releases/latest redirects to
39+// tag, counting probes. It answers nothing else — a probe that wanders off its
40+// endpoint shows up as a failed assertion or an empty tag, never as a pass.
41+func updateServer(t *testing.T, tag string, probes *int) *httptest.Server {
42+ t.Helper()
43+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
44+ if r.URL.Path == "/"+updateCheckOwner+"/"+updateCheckRepo+"/releases/latest" {
45+ if probes != nil {
46+ *probes++
47+ }
48+ http.Redirect(w, r, "/"+updateCheckOwner+"/"+updateCheckRepo+"/releases/"+tag, http.StatusFound)
49+ return
50+ }
51+ http.NotFound(w, r)
52+ }))
53+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
54+ t.Setenv("RICKUB_HOST", srv.URL)
55+ t.Setenv("RICKUB_TOKEN", "rickub_pat_test")
56+ return srv
57+}
58+
59+// withVersion pins cmd.Version for one test and restores it after.
60+func withVersion(t *testing.T, v string) {
61+ t.Helper()
62+ old := Version
63+ Version = v
64+ t.Cleanup(func() { Version = old })
65+}
66+
67+// TestStaleCLINoticeNamesBothVersions is the acceptance core of rickub/rickub#305:
68+// a CLI older than the newest release on its host prints a visible one-line
69+// warning naming BOTH versions, on stderr (stdout stays machine-parseable).
70+func TestStaleCLINoticeNamesBothVersions(t *testing.T) {
71+ withVersion(t, "0.1.0")
72+ srv := updateServer(t, "v0.2.0", nil)
73+ defer srv.Close()
74+
75+ out, errOut, err := execute(t, "version")
76+ if err != nil {
77+ t.Fatalf("execute: %v", err)
78+ }
79+ if !strings.Contains(errOut, "0.1.0") || !strings.Contains(errOut, "v0.2.0") {
80+ t.Fatalf("notice must name both versions (running 0.1.0, available v0.2.0); stderr = %q", errOut)
81+ }
82+ if !strings.Contains(errOut, "upgrade") {
83+ t.Fatalf("notice must say how to move on; stderr = %q", errOut)
84+ }
85+ if !strings.Contains(out, "rickub 0.1.0") {
86+ t.Fatalf("stdout must keep the plain version line, got %q", out)
87+ }
88+ if strings.Contains(out, "note:") {
89+ t.Fatalf("notice leaked into stdout: %q", out)
90+ }
91+}
92+
93+// TestFreshCLIIsQuiet: a binary at (or past) the newest release prints nothing —
94+// the notice is a staleness signal, not a check-in.
95+func TestFreshCLIIsQuiet(t *testing.T) {
96+ withVersion(t, "0.2.0")
97+ srv := updateServer(t, "v0.2.0", nil)
98+ defer srv.Close()
99+
100+ _, errOut, err := execute(t, "version")
101+ if err != nil {
102+ t.Fatalf("execute: %v", err)
103+ }
104+ if strings.Contains(errOut, "note:") {
105+ t.Fatalf("fresh binary was nagged: %q", errOut)
106+ }
107+}
108+
109+// TestRCOfCurrentVersionIsNotNewer: the host's "latest" includes prereleases
110+// (most-recently-created), and v0.2.0-rc.7 is NOT newer than a running 0.2.0 —
111+// semver orders the prerelease below its release. Nagging every stable user
112+// about each rc would teach people to ignore the notice.
113+func TestRCOfCurrentVersionIsNotNewer(t *testing.T) {
114+ withVersion(t, "0.2.0")
115+ srv := updateServer(t, "v0.2.0-rc.7", nil)
116+ defer srv.Close()
117+
118+ _, errOut, err := execute(t, "version")
119+ if err != nil {
120+ t.Fatalf("execute: %v", err)
121+ }
122+ if strings.Contains(errOut, "note:") {
123+ t.Fatalf("rc-of-running-version produced a notice: %q", errOut)
124+ }
125+}
126+
127+// TestProbeIsThrottledPerHost: two commands in one day issue ONE probe; a day
128+// later the probe repeats. The stamp is what keeps the check off every command —
129+// without it the CLI adds a network round-trip to every invocation.
130+func TestProbeIsThrottledPerHost(t *testing.T) {
131+ withVersion(t, "0.1.0")
132+ var probes int
133+ srv := updateServer(t, "v0.2.0", &probes)
134+ defer srv.Close()
135+
136+ if _, _, err := execute(t, "version"); err != nil {
137+ t.Fatalf("execute: %v", err)
138+ }
139+ if _, _, err := execute(t, "version"); err != nil {
140+ t.Fatalf("execute: %v", err)
141+ }
142+ if probes != 1 {
143+ t.Fatalf("probes = %d after two same-day commands, want 1", probes)
144+ }
145+ stampAge(t, srv.URL, 25*time.Hour) // yesterday's check has aged out
146+ if _, _, err := execute(t, "version"); err != nil {
147+ t.Fatalf("execute: %v", err)
148+ }
149+ if probes != 2 {
150+ t.Fatalf("probes = %d after the stamp aged out, want 2", probes)
151+ }
152+}
153+
154+// TestDevBuildSkipsTheProbe: Version "dev" (a build from source, every `go
155+// test`/`go run`) never probes — it has no release identity, and the check must
156+// not add requests to test runs and local development.
157+func TestDevBuildSkipsTheProbe(t *testing.T) {
158+ withVersion(t, "dev")
159+ var probes int
160+ srv := updateServer(t, "v9.9.9", &probes)
161+ defer srv.Close()
162+
163+ if _, _, err := execute(t, "version"); err != nil {
164+ t.Fatalf("execute: %v", err)
165+ }
166+ if probes != 0 {
167+ t.Fatalf("dev build probed the host %d times, want 0", probes)
168+ }
169+}
170+
171+// TestProbeFailuresAreSilent: a host without the CLI repo (404), a non-redirect
172+// answer, a Location that is not a release tag, and an unreachable host each
173+// leave the command succeeding with no notice — best-effort means a failed check
174+// never becomes the user's problem.
175+func TestProbeFailuresAreSilent(t *testing.T) {
176+ withVersion(t, "0.1.0")
177+
178+ cases := []struct {
179+ name string
180+ handler http.HandlerFunc
181+ }{
182+ {"no releases on host", func(w http.ResponseWriter, r *http.Request) {
183+ http.NotFound(w, r)
184+ }},
185+ {"not a redirect", func(w http.ResponseWriter, _ *http.Request) {
186+ _, _ = w.Write([]byte(`{"ok":true}`))
187+ }},
188+ {"location is the releases list page", func(w http.ResponseWriter, r *http.Request) {
189+ http.Redirect(w, r, "/"+updateCheckOwner+"/"+updateCheckRepo+"/releases", http.StatusFound)
190+ }},
191+ {"location names a non-version tag", func(w http.ResponseWriter, r *http.Request) {
192+ http.Redirect(w, r, "/"+updateCheckOwner+"/"+updateCheckRepo+"/releases/runner-abc123", http.StatusFound)
193+ }},
194+ }
195+ for _, tc := range cases {
196+ t.Run(tc.name, func(t *testing.T) {
197+ srv := httptest.NewServer(tc.handler)
198+ defer srv.Close()
199+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
200+ t.Setenv("RICKUB_HOST", srv.URL)
201+
202+ _, errOut, err := execute(t, "version")
203+ if err != nil {
204+ t.Fatalf("execute: %v", err)
205+ }
206+ if strings.Contains(errOut, "note:") {
207+ t.Fatalf("failed probe produced a notice: %q", errOut)
208+ }
209+ })
210+ }
211+
212+ // An unreachable host: point at a port that is closed.
213+ t.Run("unreachable host", func(t *testing.T) {
214+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
215+ t.Setenv("RICKUB_HOST", "http://127.0.0.1:1")
216+ _, errOut, err := execute(t, "version")
217+ if err != nil {
218+ t.Fatalf("execute: %v", err)
219+ }
220+ if strings.Contains(errOut, "note:") {
221+ t.Fatalf("unreachable host produced a notice: %q", errOut)
222+ }
223+ })
224+}
225+
226+// TestStampIsPerHost: a stamp for one host must not silence the check against
227+// another — switching --host (or RICKUB_HOST) re-checks the host you actually
228+// asked about.
229+func TestStampIsPerHost(t *testing.T) {
230+ withVersion(t, "0.1.0")
231+ var probesA, probesB int
232+ srvA := updateServer(t, "v0.2.0", &probesA)
233+ defer srvA.Close()
234+ if _, _, err := execute(t, "version"); err != nil {
235+ t.Fatalf("execute: %v", err)
236+ }
237+ if probesA != 1 {
238+ t.Fatalf("probesA = %d, want 1", probesA)
239+ }
240+
241+ srvB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
242+ if r.URL.Path == "/"+updateCheckOwner+"/"+updateCheckRepo+"/releases/latest" {
243+ probesB++
244+ http.Redirect(w, r, "/"+updateCheckOwner+"/"+updateCheckRepo+"/releases/v0.3.0", http.StatusFound)
245+ return
246+ }
247+ http.NotFound(w, r)
248+ }))
249+ defer srvB.Close()
250+
251+ _, errOut, err := execute(t, "--host", srvB.URL, "version")
252+ if err != nil {
253+ t.Fatalf("execute: %v", err)
254+ }
255+ if probesB != 1 {
256+ t.Fatalf("probesB = %d, want 1 (host B must not inherit host A's stamp)", probesB)
257+ }
258+ if !strings.Contains(errOut, "v0.3.0") {
259+ t.Fatalf("notice must reflect host B's release; stderr = %q", errOut)
260+ }
261+}
262+
263+// TestCompareVersions pins the ordering rules the notice depends on.
264+func TestCompareVersions(t *testing.T) {
265+ cases := []struct {
266+ a, b string
267+ want int
268+ }{
269+ {"v0.2.0", "0.1.0", 1},
270+ {"0.1.0", "v0.2.0", -1},
271+ {"v0.2.0", "v0.2.0", 0},
272+ {"0.2", "0.1.9", 1}, // missing components are zero
273+ {"0.2.0", "0.2.0-rc.1", 1}, // release > its prerelease
274+ {"0.2.0-rc.11", "0.2.0-rc.9", 1}, // numeric identifiers, not lexical
275+ {"0.2.0-rc.2", "0.2.0-rc.2", 0}, //
276+ {"0.2.0-rc.1", "0.2.0-beta", 1}, // rc > beta byte-wise
277+ {"0.2.0-rc.1", "0.2.0-rc.1.1", -1}, //
278+ {"runner-abc123", "0.1.0", 0}, // unparsable never compares
279+ {"0.1.0", "runner-abc123", 0}, //
280+ {"", "0.1.0", 0}, //
281+ }
282+ for _, tc := range cases {
283+ if got := compareVersions(tc.a, tc.b); got != tc.want {
284+ t.Errorf("compareVersions(%q, %q) = %d, want %d", tc.a, tc.b, got, tc.want)
285+ }
286+ }
287+}
288+
289+// TestTagFromReleaseLocation pins the Location shapes the host writes and the
290+// ones it must not be confused by.
291+func TestTagFromReleaseLocation(t *testing.T) {
292+ cases := []struct {
293+ loc string
294+ want string
295+ }{
296+ {"/rickub/cli/releases/v0.2.0", "v0.2.0"},
297+ {"https://host.example/rickub/cli/releases/v0.1.0-rc.9", "v0.1.0-rc.9"},
298+ {"/rickub/cli/releases/latest", ""}, // redirect to itself
299+ {"/rickub/cli/releases", ""}, // the list page
300+ {"/rickub/cli/releases/download/v0.2.0/rickub_linux_amd64.tar.gz", ""},
301+ {"/somewhere/else entirely", ""},
302+ {"", ""},
303+ }
304+ for _, tc := range cases {
305+ if got := tagFromReleaseLocation(tc.loc); got != tc.want {
306+ t.Errorf("tagFromReleaseLocation(%q) = %q, want %q", tc.loc, got, tc.want)
307+ }
308+ }
309+}
310+
311+// TestUpdateStampPathIsPerHostAndSane: the stamp lands in its own directory
312+// beside the config file, with a path-safe host key — two hosts, two stamps, no
313+// clobbering, and never inside the config file itself.
314+func TestUpdateStampPathIsPerHostAndSane(t *testing.T) {
315+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
316+ a, err := updateStampPath("https://rickub.com")
317+ if err != nil {
318+ t.Fatalf("stamp path: %v", err)
319+ }
320+ b, err := updateStampPath("https://dev.rickub.com/")
321+ if err != nil {
322+ t.Fatalf("stamp path: %v", err)
323+ }
324+ if a == b {
325+ t.Fatalf("two hosts share a stamp: %q", a)
326+ }
327+ cfgPath, err := config.Path()
328+ if err != nil {
329+ t.Fatalf("config path: %v", err)
330+ }
331+ if a == cfgPath || filepath.Dir(a) == cfgPath {
332+ t.Fatalf("stamp %q must not be (or sit inside) the config file %q", a, cfgPath)
333+ }
334+ if base := filepath.Base(a); !strings.HasSuffix(base, "rickub.com") {
335+ t.Errorf("stamp base %q loses the host identity", base)
336+ }
337+ if dir := filepath.Dir(a); !strings.HasSuffix(filepath.Base(dir), "update-check") {
338+ t.Errorf("stamp directory %q is not the dedicated update-check dir", dir)
339+ }
340+}
341+
342+// TestStampFreshnessWindow pins the window: fresh inside the interval, stale
343+// after it, and a stamp in the future (clock jumped back) reads fresh so the
344+// probe cannot rapid-fire.
345+func TestStampFreshnessWindow(t *testing.T) {
346+ path := filepath.Join(t.TempDir(), "stamp")
347+ writeUpdateStamp(path)
348+ if !stampIsFresh(path) {
349+ t.Fatal("a just-written stamp must be fresh")
350+ }
351+ if err := os.WriteFile(path, []byte(time.Now().UTC().Add(-updateCheckInterval-time.Hour).Format(time.RFC3339)), 0o600); err != nil {
352+ t.Fatalf("age stamp: %v", err)
353+ }
354+ if stampIsFresh(path) {
355+ t.Fatal("a stamp older than the interval must be stale")
356+ }
357+ if err := os.WriteFile(path, []byte(time.Now().UTC().Add(time.Hour).Format(time.RFC3339)), 0o600); err != nil {
358+ t.Fatalf("future stamp: %v", err)
359+ }
360+ if !stampIsFresh(path) {
361+ t.Fatal("a future stamp (clock moved back) must read fresh, not pinball the probe")
362+ }
363+ if stampIsFresh(filepath.Join(filepath.Dir(path), "absent")) {
364+ t.Fatal("an absent stamp must read stale")
365+ }
366+ if err := os.WriteFile(path, []byte("not a timestamp"), 0o600); err != nil {
367+ t.Fatalf("garbage stamp: %v", err)
368+ }
369+ if stampIsFresh(path) {
370+ t.Fatal("an unparsable stamp must read stale")
371+ }
372+}
new file mode 100644
@@ -0,0 +1,372 @@
1+package cmd
2+
3+import (
4+ "net/http"
5+ "net/http/httptest"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "rickub.com/rickub/cli/internal/config"
13+)
14+
15+// Tests for the CLI staleness notice (rickub/rickub#305).
16+//
17+// The notice is the one-line signal that a binary older than the newest release
18+// on its host keeps describing an old product as current — the failure mode that
19+// manufactured the false bug reports #294 and #279. Each behavior test below
20+// fails on the pre-change code (no notice, no probe) and on the two plausible
21+// regressions: the comparator flipping (nagging on fresh binaries, silence on
22+// stale ones) and the probe losing its throttle (a request on every command).
23+
24+// stampAge rewinds the stamp for host by d, so a test can simulate "checked
25+// yesterday" without sleeping. It fails the test when no stamp exists — a probe
26+// that never wrote its stamp is itself a bug (it would hammer the host).
27+func stampAge(t *testing.T, host string, d time.Duration) {
28+ t.Helper()
29+ path, err := updateStampPath(host)
30+ if err != nil {
31+ t.Fatalf("stamp path: %v", err)
32+ }
33+ if err := os.WriteFile(path, []byte(time.Now().UTC().Add(-d).Format(time.RFC3339)), 0o600); err != nil {
34+ t.Fatalf("rewind stamp: %v", err)
35+ }
36+}
37+
38+// updateServer stands up a host whose /rickub/cli/releases/latest redirects to
39+// tag, counting probes. It answers nothing else — a probe that wanders off its
40+// endpoint shows up as a failed assertion or an empty tag, never as a pass.
41+func updateServer(t *testing.T, tag string, probes *int) *httptest.Server {
42+ t.Helper()
43+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
44+ if r.URL.Path == "/"+updateCheckOwner+"/"+updateCheckRepo+"/releases/latest" {
45+ if probes != nil {
46+ *probes++
47+ }
48+ http.Redirect(w, r, "/"+updateCheckOwner+"/"+updateCheckRepo+"/releases/"+tag, http.StatusFound)
49+ return
50+ }
51+ http.NotFound(w, r)
52+ }))
53+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
54+ t.Setenv("RICKUB_HOST", srv.URL)
55+ t.Setenv("RICKUB_TOKEN", "rickub_pat_test")
56+ return srv
57+}
58+
59+// withVersion pins cmd.Version for one test and restores it after.
60+func withVersion(t *testing.T, v string) {
61+ t.Helper()
62+ old := Version
63+ Version = v
64+ t.Cleanup(func() { Version = old })
65+}
66+
67+// TestStaleCLINoticeNamesBothVersions is the acceptance core of rickub/rickub#305:
68+// a CLI older than the newest release on its host prints a visible one-line
69+// warning naming BOTH versions, on stderr (stdout stays machine-parseable).
70+func TestStaleCLINoticeNamesBothVersions(t *testing.T) {
71+ withVersion(t, "0.1.0")
72+ srv := updateServer(t, "v0.2.0", nil)
73+ defer srv.Close()
74+
75+ out, errOut, err := execute(t, "version")
76+ if err != nil {
77+ t.Fatalf("execute: %v", err)
78+ }
79+ if !strings.Contains(errOut, "0.1.0") || !strings.Contains(errOut, "v0.2.0") {
80+ t.Fatalf("notice must name both versions (running 0.1.0, available v0.2.0); stderr = %q", errOut)
81+ }
82+ if !strings.Contains(errOut, "upgrade") {
83+ t.Fatalf("notice must say how to move on; stderr = %q", errOut)
84+ }
85+ if !strings.Contains(out, "rickub 0.1.0") {
86+ t.Fatalf("stdout must keep the plain version line, got %q", out)
87+ }
88+ if strings.Contains(out, "note:") {
89+ t.Fatalf("notice leaked into stdout: %q", out)
90+ }
91+}
92+
93+// TestFreshCLIIsQuiet: a binary at (or past) the newest release prints nothing —
94+// the notice is a staleness signal, not a check-in.
95+func TestFreshCLIIsQuiet(t *testing.T) {
96+ withVersion(t, "0.2.0")
97+ srv := updateServer(t, "v0.2.0", nil)
98+ defer srv.Close()
99+
100+ _, errOut, err := execute(t, "version")
101+ if err != nil {
102+ t.Fatalf("execute: %v", err)
103+ }
104+ if strings.Contains(errOut, "note:") {
105+ t.Fatalf("fresh binary was nagged: %q", errOut)
106+ }
107+}
108+
109+// TestRCOfCurrentVersionIsNotNewer: the host's "latest" includes prereleases
110+// (most-recently-created), and v0.2.0-rc.7 is NOT newer than a running 0.2.0 —
111+// semver orders the prerelease below its release. Nagging every stable user
112+// about each rc would teach people to ignore the notice.
113+func TestRCOfCurrentVersionIsNotNewer(t *testing.T) {
114+ withVersion(t, "0.2.0")
115+ srv := updateServer(t, "v0.2.0-rc.7", nil)
116+ defer srv.Close()
117+
118+ _, errOut, err := execute(t, "version")
119+ if err != nil {
120+ t.Fatalf("execute: %v", err)
121+ }
122+ if strings.Contains(errOut, "note:") {
123+ t.Fatalf("rc-of-running-version produced a notice: %q", errOut)
124+ }
125+}
126+
127+// TestProbeIsThrottledPerHost: two commands in one day issue ONE probe; a day
128+// later the probe repeats. The stamp is what keeps the check off every command —
129+// without it the CLI adds a network round-trip to every invocation.
130+func TestProbeIsThrottledPerHost(t *testing.T) {
131+ withVersion(t, "0.1.0")
132+ var probes int
133+ srv := updateServer(t, "v0.2.0", &probes)
134+ defer srv.Close()
135+
136+ if _, _, err := execute(t, "version"); err != nil {
137+ t.Fatalf("execute: %v", err)
138+ }
139+ if _, _, err := execute(t, "version"); err != nil {
140+ t.Fatalf("execute: %v", err)
141+ }
142+ if probes != 1 {
143+ t.Fatalf("probes = %d after two same-day commands, want 1", probes)
144+ }
145+ stampAge(t, srv.URL, 25*time.Hour) // yesterday's check has aged out
146+ if _, _, err := execute(t, "version"); err != nil {
147+ t.Fatalf("execute: %v", err)
148+ }
149+ if probes != 2 {
150+ t.Fatalf("probes = %d after the stamp aged out, want 2", probes)
151+ }
152+}
153+
154+// TestDevBuildSkipsTheProbe: Version "dev" (a build from source, every `go
155+// test`/`go run`) never probes — it has no release identity, and the check must
156+// not add requests to test runs and local development.
157+func TestDevBuildSkipsTheProbe(t *testing.T) {
158+ withVersion(t, "dev")
159+ var probes int
160+ srv := updateServer(t, "v9.9.9", &probes)
161+ defer srv.Close()
162+
163+ if _, _, err := execute(t, "version"); err != nil {
164+ t.Fatalf("execute: %v", err)
165+ }
166+ if probes != 0 {
167+ t.Fatalf("dev build probed the host %d times, want 0", probes)
168+ }
169+}
170+
171+// TestProbeFailuresAreSilent: a host without the CLI repo (404), a non-redirect
172+// answer, a Location that is not a release tag, and an unreachable host each
173+// leave the command succeeding with no notice — best-effort means a failed check
174+// never becomes the user's problem.
175+func TestProbeFailuresAreSilent(t *testing.T) {
176+ withVersion(t, "0.1.0")
177+
178+ cases := []struct {
179+ name string
180+ handler http.HandlerFunc
181+ }{
182+ {"no releases on host", func(w http.ResponseWriter, r *http.Request) {
183+ http.NotFound(w, r)
184+ }},
185+ {"not a redirect", func(w http.ResponseWriter, _ *http.Request) {
186+ _, _ = w.Write([]byte(`{"ok":true}`))
187+ }},
188+ {"location is the releases list page", func(w http.ResponseWriter, r *http.Request) {
189+ http.Redirect(w, r, "/"+updateCheckOwner+"/"+updateCheckRepo+"/releases", http.StatusFound)
190+ }},
191+ {"location names a non-version tag", func(w http.ResponseWriter, r *http.Request) {
192+ http.Redirect(w, r, "/"+updateCheckOwner+"/"+updateCheckRepo+"/releases/runner-abc123", http.StatusFound)
193+ }},
194+ }
195+ for _, tc := range cases {
196+ t.Run(tc.name, func(t *testing.T) {
197+ srv := httptest.NewServer(tc.handler)
198+ defer srv.Close()
199+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
200+ t.Setenv("RICKUB_HOST", srv.URL)
201+
202+ _, errOut, err := execute(t, "version")
203+ if err != nil {
204+ t.Fatalf("execute: %v", err)
205+ }
206+ if strings.Contains(errOut, "note:") {
207+ t.Fatalf("failed probe produced a notice: %q", errOut)
208+ }
209+ })
210+ }
211+
212+ // An unreachable host: point at a port that is closed.
213+ t.Run("unreachable host", func(t *testing.T) {
214+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
215+ t.Setenv("RICKUB_HOST", "http://127.0.0.1:1")
216+ _, errOut, err := execute(t, "version")
217+ if err != nil {
218+ t.Fatalf("execute: %v", err)
219+ }
220+ if strings.Contains(errOut, "note:") {
221+ t.Fatalf("unreachable host produced a notice: %q", errOut)
222+ }
223+ })
224+}
225+
226+// TestStampIsPerHost: a stamp for one host must not silence the check against
227+// another — switching --host (or RICKUB_HOST) re-checks the host you actually
228+// asked about.
229+func TestStampIsPerHost(t *testing.T) {
230+ withVersion(t, "0.1.0")
231+ var probesA, probesB int
232+ srvA := updateServer(t, "v0.2.0", &probesA)
233+ defer srvA.Close()
234+ if _, _, err := execute(t, "version"); err != nil {
235+ t.Fatalf("execute: %v", err)
236+ }
237+ if probesA != 1 {
238+ t.Fatalf("probesA = %d, want 1", probesA)
239+ }
240+
241+ srvB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
242+ if r.URL.Path == "/"+updateCheckOwner+"/"+updateCheckRepo+"/releases/latest" {
243+ probesB++
244+ http.Redirect(w, r, "/"+updateCheckOwner+"/"+updateCheckRepo+"/releases/v0.3.0", http.StatusFound)
245+ return
246+ }
247+ http.NotFound(w, r)
248+ }))
249+ defer srvB.Close()
250+
251+ _, errOut, err := execute(t, "--host", srvB.URL, "version")
252+ if err != nil {
253+ t.Fatalf("execute: %v", err)
254+ }
255+ if probesB != 1 {
256+ t.Fatalf("probesB = %d, want 1 (host B must not inherit host A's stamp)", probesB)
257+ }
258+ if !strings.Contains(errOut, "v0.3.0") {
259+ t.Fatalf("notice must reflect host B's release; stderr = %q", errOut)
260+ }
261+}
262+
263+// TestCompareVersions pins the ordering rules the notice depends on.
264+func TestCompareVersions(t *testing.T) {
265+ cases := []struct {
266+ a, b string
267+ want int
268+ }{
269+ {"v0.2.0", "0.1.0", 1},
270+ {"0.1.0", "v0.2.0", -1},
271+ {"v0.2.0", "v0.2.0", 0},
272+ {"0.2", "0.1.9", 1}, // missing components are zero
273+ {"0.2.0", "0.2.0-rc.1", 1}, // release > its prerelease
274+ {"0.2.0-rc.11", "0.2.0-rc.9", 1}, // numeric identifiers, not lexical
275+ {"0.2.0-rc.2", "0.2.0-rc.2", 0}, //
276+ {"0.2.0-rc.1", "0.2.0-beta", 1}, // rc > beta byte-wise
277+ {"0.2.0-rc.1", "0.2.0-rc.1.1", -1}, //
278+ {"runner-abc123", "0.1.0", 0}, // unparsable never compares
279+ {"0.1.0", "runner-abc123", 0}, //
280+ {"", "0.1.0", 0}, //
281+ }
282+ for _, tc := range cases {
283+ if got := compareVersions(tc.a, tc.b); got != tc.want {
284+ t.Errorf("compareVersions(%q, %q) = %d, want %d", tc.a, tc.b, got, tc.want)
285+ }
286+ }
287+}
288+
289+// TestTagFromReleaseLocation pins the Location shapes the host writes and the
290+// ones it must not be confused by.
291+func TestTagFromReleaseLocation(t *testing.T) {
292+ cases := []struct {
293+ loc string
294+ want string
295+ }{
296+ {"/rickub/cli/releases/v0.2.0", "v0.2.0"},
297+ {"https://host.example/rickub/cli/releases/v0.1.0-rc.9", "v0.1.0-rc.9"},
298+ {"/rickub/cli/releases/latest", ""}, // redirect to itself
299+ {"/rickub/cli/releases", ""}, // the list page
300+ {"/rickub/cli/releases/download/v0.2.0/rickub_linux_amd64.tar.gz", ""},
301+ {"/somewhere/else entirely", ""},
302+ {"", ""},
303+ }
304+ for _, tc := range cases {
305+ if got := tagFromReleaseLocation(tc.loc); got != tc.want {
306+ t.Errorf("tagFromReleaseLocation(%q) = %q, want %q", tc.loc, got, tc.want)
307+ }
308+ }
309+}
310+
311+// TestUpdateStampPathIsPerHostAndSane: the stamp lands in its own directory
312+// beside the config file, with a path-safe host key — two hosts, two stamps, no
313+// clobbering, and never inside the config file itself.
314+func TestUpdateStampPathIsPerHostAndSane(t *testing.T) {
315+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
316+ a, err := updateStampPath("https://rickub.com")
317+ if err != nil {
318+ t.Fatalf("stamp path: %v", err)
319+ }
320+ b, err := updateStampPath("https://dev.rickub.com/")
321+ if err != nil {
322+ t.Fatalf("stamp path: %v", err)
323+ }
324+ if a == b {
325+ t.Fatalf("two hosts share a stamp: %q", a)
326+ }
327+ cfgPath, err := config.Path()
328+ if err != nil {
329+ t.Fatalf("config path: %v", err)
330+ }
331+ if a == cfgPath || filepath.Dir(a) == cfgPath {
332+ t.Fatalf("stamp %q must not be (or sit inside) the config file %q", a, cfgPath)
333+ }
334+ if base := filepath.Base(a); !strings.HasSuffix(base, "rickub.com") {
335+ t.Errorf("stamp base %q loses the host identity", base)
336+ }
337+ if dir := filepath.Dir(a); !strings.HasSuffix(filepath.Base(dir), "update-check") {
338+ t.Errorf("stamp directory %q is not the dedicated update-check dir", dir)
339+ }
340+}
341+
342+// TestStampFreshnessWindow pins the window: fresh inside the interval, stale
343+// after it, and a stamp in the future (clock jumped back) reads fresh so the
344+// probe cannot rapid-fire.
345+func TestStampFreshnessWindow(t *testing.T) {
346+ path := filepath.Join(t.TempDir(), "stamp")
347+ writeUpdateStamp(path)
348+ if !stampIsFresh(path) {
349+ t.Fatal("a just-written stamp must be fresh")
350+ }
351+ if err := os.WriteFile(path, []byte(time.Now().UTC().Add(-updateCheckInterval-time.Hour).Format(time.RFC3339)), 0o600); err != nil {
352+ t.Fatalf("age stamp: %v", err)
353+ }
354+ if stampIsFresh(path) {
355+ t.Fatal("a stamp older than the interval must be stale")
356+ }
357+ if err := os.WriteFile(path, []byte(time.Now().UTC().Add(time.Hour).Format(time.RFC3339)), 0o600); err != nil {
358+ t.Fatalf("future stamp: %v", err)
359+ }
360+ if !stampIsFresh(path) {
361+ t.Fatal("a future stamp (clock moved back) must read fresh, not pinball the probe")
362+ }
363+ if stampIsFresh(filepath.Join(filepath.Dir(path), "absent")) {
364+ t.Fatal("an absent stamp must read stale")
365+ }
366+ if err := os.WriteFile(path, []byte("not a timestamp"), 0o600); err != nil {
367+ t.Fatalf("garbage stamp: %v", err)
368+ }
369+ if stampIsFresh(path) {
370+ t.Fatal("an unparsable stamp must read stale")
371+ }
372+}