Skip to content

Commit fd51af9

Browse files
committed
fix: update notifier semver comparison + release v0.8.3
The update-available notifier used string inequality (latest != version) to decide whether to print the notice. After upgrading past a cached latest_tag (the cache has a 1 hour TTL), the newer binary would read its pre-upgrade cache and emit "Update available: v0.8.2 -> v0.8.1" because the two strings were unequal — a phantom downgrade seen on the Tiverton host right after `claw update`. Switch to strict semver ordering (golang.org/x/mod/semver) so stale caches whose latest_tag is older than the running binary no longer trigger a bogus notice. Add a focused test covering the regression plus the typical newer/equal/malformed cases. Also refresh site/changelog.md with the v0.8.3 entry and bump the nav dropdown version. go mod tidy promoted x/mod and robfig/cron/v3 from indirect to direct — both are in fact imported directly.
1 parent 9b6b0ca commit fd51af9

File tree

5 files changed

+76
-8
lines changed

5 files changed

+76
-8
lines changed

cmd/claw/update_check.go

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88
"path/filepath"
99
"strings"
1010
"time"
11+
12+
"golang.org/x/mod/semver"
1113
)
1214

1315
const (
@@ -90,16 +92,16 @@ func writeCache(c *updateCheckCache) {
9092
_ = os.WriteFile(path, data, 0o644)
9193
}
9294

93-
// maybeNotifyUpdate prints an update notice if a newer release is available.
94-
// Checks at most once per hour; never blocks on network errors.
95+
// maybeNotifyUpdate prints an update notice if a strictly newer release is
96+
// available. Checks at most once per hour; never blocks on network errors.
9597
func maybeNotifyUpdate() {
9698
if version == "dev" {
9799
return
98100
}
99101

100102
cache := readCache()
101103
if cache != nil && time.Since(cache.CheckedAt) < updateCheckInterval {
102-
if cache.LatestTag != "" && cache.LatestTag != version {
104+
if isNewerRelease(cache.LatestTag, version) {
103105
printUpdateNotice(cache.LatestTag)
104106
}
105107
return
@@ -111,11 +113,28 @@ func maybeNotifyUpdate() {
111113
LatestTag: latest,
112114
})
113115

114-
if latest != "" && latest != version {
116+
if isNewerRelease(latest, version) {
115117
printUpdateNotice(latest)
116118
}
117119
}
118120

121+
// isNewerRelease reports whether latest is a strictly higher semantic version
122+
// than current. Both inputs are expected to be unprefixed (e.g. "0.8.2"),
123+
// matching the format stored in the cache and stamped into the binary by
124+
// goreleaser. Returns false for empty, malformed, or equal/older versions so
125+
// stale caches after an upgrade don't flag a phantom "downgrade".
126+
func isNewerRelease(latest, current string) bool {
127+
if latest == "" || current == "" {
128+
return false
129+
}
130+
l := "v" + strings.TrimPrefix(latest, "v")
131+
c := "v" + strings.TrimPrefix(current, "v")
132+
if !semver.IsValid(l) || !semver.IsValid(c) {
133+
return false
134+
}
135+
return semver.Compare(l, c) > 0
136+
}
137+
119138
func printUpdateNotice(latest string) {
120139
fmt.Fprintf(os.Stderr, "\n Update available: v%s → v%s (run: claw update)\n\n", version, latest)
121140
}

cmd/claw/update_check_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package main
2+
3+
import "testing"
4+
5+
func TestIsNewerRelease(t *testing.T) {
6+
tests := []struct {
7+
name string
8+
latest string
9+
current string
10+
want bool
11+
}{
12+
{"strictly newer patch", "0.8.2", "0.8.1", true},
13+
{"strictly newer minor", "0.9.0", "0.8.9", true},
14+
{"strictly newer major", "1.0.0", "0.99.9", true},
15+
{"equal", "0.8.2", "0.8.2", false},
16+
17+
// Regression: v0.8.1 cache read by a freshly upgraded v0.8.2 binary
18+
// was printing "Update available: v0.8.2 → v0.8.1" because the pre-fix
19+
// code compared with != instead of semver ordering.
20+
{"stale cache after upgrade", "0.8.1", "0.8.2", false},
21+
22+
{"empty latest", "", "0.8.2", false},
23+
{"empty current", "0.8.2", "", false},
24+
{"both empty", "", "", false},
25+
{"malformed latest", "garbage", "0.8.2", false},
26+
{"malformed current", "0.8.2", "garbage", false},
27+
28+
// Accept strings with or without a leading "v" on either side —
29+
// the cache historically stores unprefixed values but the field could
30+
// drift; the comparator should still work.
31+
{"v-prefixed latest", "v0.8.2", "0.8.1", true},
32+
{"v-prefixed current", "0.8.2", "v0.8.1", true},
33+
}
34+
35+
for _, tt := range tests {
36+
t.Run(tt.name, func(t *testing.T) {
37+
got := isNewerRelease(tt.latest, tt.current)
38+
if got != tt.want {
39+
t.Errorf("isNewerRelease(%q, %q) = %v, want %v", tt.latest, tt.current, got, tt.want)
40+
}
41+
})
42+
}
43+
}

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ go 1.23.0
55
require (
66
github.com/docker/docker v26.1.4+incompatible
77
github.com/moby/buildkit v0.13.2
8+
github.com/robfig/cron/v3 v3.0.1
89
github.com/spf13/cobra v1.8.1
10+
golang.org/x/mod v0.13.0
911
gopkg.in/yaml.v3 v3.0.1
1012
oras.land/oras-go/v2 v2.6.0
1113
)
@@ -26,13 +28,11 @@ require (
2628
github.com/opencontainers/go-digest v1.0.0 // indirect
2729
github.com/opencontainers/image-spec v1.1.1 // indirect
2830
github.com/pkg/errors v0.9.1 // indirect
29-
github.com/robfig/cron/v3 v3.0.1 // indirect
3031
github.com/spf13/pflag v1.0.5 // indirect
3132
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect
3233
go.opentelemetry.io/otel v1.21.0 // indirect
3334
go.opentelemetry.io/otel/metric v1.21.0 // indirect
3435
go.opentelemetry.io/otel/trace v1.21.0 // indirect
35-
golang.org/x/mod v0.13.0 // indirect
3636
golang.org/x/sync v0.14.0 // indirect
3737
golang.org/x/sys v0.18.0 // indirect
3838
golang.org/x/tools v0.14.0 // indirect

site/.vitepress/config.mts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export default defineConfig({
4343
nav: [
4444
{ text: 'Guide', link: '/guide/what-is-clawdapus' },
4545
{
46-
text: 'v0.8.2',
46+
text: 'v0.8.3',
4747
items: [
4848
{ text: 'Changelog', link: '/changelog' },
4949
{ text: 'Manifesto', link: '/manifesto' },

site/changelog.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,13 @@ outline: deep
3131

3232
<!-- Nothing yet -->
3333

34-
## v0.8.2 <Badge type="tip" text="Latest" /> {#v0-8-2}
34+
## v0.8.3 <Badge type="tip" text="Latest" /> {#v0-8-3}
35+
36+
*2026-04-09*
37+
38+
- **Fix: update-check notifier prints phantom "downgrade" after upgrading** — the update-available notifier used a plain string inequality instead of a semver comparison, so a freshly upgraded binary reading its pre-upgrade cache (within the 1 hour TTL) would print `Update available: v0.8.2 → v0.8.1`. The notifier now uses strict semver ordering, so a stale cache whose `latest_tag` is older than the running binary no longer triggers a bogus notice. As a one-time workaround on an already-upgraded host, `rm ~/.claw/.claw-update-check` clears the stale cache.
39+
40+
## v0.8.2 {#v0-8-2}
3541

3642
*2026-04-09*
3743

0 commit comments

Comments
 (0)