detached b081de6

fix(agent): compare versions with semver

commit
b081de63779f007d4e0e2656aba6f56f320fedff
author
brice · · 2026-08-10 14:00 UTC
committer
brice · · 2026-08-11 07:21 UTC
parent
11ffb00
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +70 -31
M go.mod +1 -0
@@ -9,6 +9,7 @@ require (
99 github.com/mattn/go-runewidth v0.0.16
1010 github.com/sahilm/fuzzy v0.1.1
1111 golang.org/x/image v0.43.0
12+ golang.org/x/mod v0.35.0
1213 golang.org/x/net v0.57.0
1314 )
1415
@@ -9,6 +9,7 @@ require (
9 github.com/mattn/go-runewidth v0.0.169 github.com/mattn/go-runewidth v0.0.16
10 github.com/sahilm/fuzzy v0.1.110 github.com/sahilm/fuzzy v0.1.1
11 golang.org/x/image v0.43.011 golang.org/x/image v0.43.0
12+ golang.org/x/mod v0.35.0
12 golang.org/x/net v0.57.013 golang.org/x/net v0.57.0
13 )14 )
14 15
M go.sum +2 -0
@@ -22,6 +22,8 @@ github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
2222 github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
2323 golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
2424 golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
25+golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
26+golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
2527 golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
2628 golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
2729 golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
@@ -22,6 +22,8 @@ github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
22 github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=22 github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
23 golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=23 golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
24 golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=24 golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
25+golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
26+golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
25 golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=27 golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
26 golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=28 golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
27 golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=29 golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
M packages/agent/update.go +22 -30
@@ -7,9 +7,10 @@ import (
77 "net/http"
88 "os"
99 "path/filepath"
10- "strconv"
1110 "strings"
1211 "time"
12+
13+ "golang.org/x/mod/semver"
1314 )
1415
1516 // updateCheckTTL is how often we hit the GitHub API to look for a new
@@ -132,40 +133,31 @@ func buildInfo(current, latest, url string) UpdateInfo {
132133 return info
133134 }
134135
135-// versionLess returns a < b for dotted semver-ish tags like "0.0.4".
136-// Non-numeric components compare as zero, which is fine for zut's
137-// x.y.z-only scheme.
136+// versionLess reports whether a is an older semantic version than b.
137+// Invalid versions return false so update checks never offer a downgrade.
138138 func versionLess(a, b string) bool {
139- as := splitVersion(a)
140- bs := splitVersion(b)
141- for i := 0; i < 3; i++ {
142- av, bv := 0, 0
143- if i < len(as) {
144- av = as[i]
145- }
146- if i < len(bs) {
147- bv = bs[i]
148- }
149- if av != bv {
150- return av < bv
151- }
152- }
153- return false
139+ comparison, err := compareVersions(a, b)
140+ return err == nil && comparison < 0
154141 }
155142
156-func splitVersion(s string) []int {
157- s = strings.TrimPrefix(s, "v")
158- // Strip build-metadata suffix like "(abc1234, 2026-04-18)".
159- if i := strings.IndexAny(s, " ("); i > 0 {
160- s = s[:i]
143+func compareVersions(a, b string) (int, error) {
144+ a = normalizedVersion(a)
145+ b = normalizedVersion(b)
146+ if !semver.IsValid(a) {
147+ return 0, fmt.Errorf("invalid semantic version %q", strings.TrimPrefix(a, "v"))
148+ }
149+ if !semver.IsValid(b) {
150+ return 0, fmt.Errorf("invalid semantic version %q", strings.TrimPrefix(b, "v"))
161151 }
162- parts := strings.Split(s, ".")
163- out := make([]int, 0, len(parts))
164- for _, p := range parts {
165- n, _ := strconv.Atoi(p)
166- out = append(out, n)
152+ return semver.Compare(a, b), nil
153+}
154+
155+func normalizedVersion(v string) string {
156+ v = versionOnly(v)
157+ if !strings.HasPrefix(v, "v") {
158+ v = "v" + v
167159 }
168- return out
160+ return v
169161 }
170162
171163 // fetchLatestRelease queries the GitHub API for the latest published
@@ -7,9 +7,10 @@ import (
7 "net/http"7 "net/http"
8 "os"8 "os"
9 "path/filepath"9 "path/filepath"
10- "strconv"
11 "strings"10 "strings"
12 "time"11 "time"
12+
13+ "golang.org/x/mod/semver"
13 )14 )
14 15
15 // updateCheckTTL is how often we hit the GitHub API to look for a new16 // updateCheckTTL is how often we hit the GitHub API to look for a new
@@ -132,40 +133,31 @@ func buildInfo(current, latest, url string) UpdateInfo {
132 return info133 return info
133 }134 }
134 135
135-// versionLess returns a < b for dotted semver-ish tags like "0.0.4".136+// versionLess reports whether a is an older semantic version than b.
136-// Non-numeric components compare as zero, which is fine for zut's137+// Invalid versions return false so update checks never offer a downgrade.
137-// x.y.z-only scheme.
138 func versionLess(a, b string) bool {138 func versionLess(a, b string) bool {
139- as := splitVersion(a)139+ comparison, err := compareVersions(a, b)
140- bs := splitVersion(b)140+ return err == nil && comparison < 0
141- for i := 0; i < 3; i++ {
142- av, bv := 0, 0
143- if i < len(as) {
144- av = as[i]
145- }
146- if i < len(bs) {
147- bv = bs[i]
148- }
149- if av != bv {
150- return av < bv
151- }
152- }
153- return false
154 }141 }
155 142
156-func splitVersion(s string) []int {143+func compareVersions(a, b string) (int, error) {
157- s = strings.TrimPrefix(s, "v")144+ a = normalizedVersion(a)
158- // Strip build-metadata suffix like "(abc1234, 2026-04-18)".145+ b = normalizedVersion(b)
159- if i := strings.IndexAny(s, " ("); i > 0 {146+ if !semver.IsValid(a) {
160- s = s[:i]147+ return 0, fmt.Errorf("invalid semantic version %q", strings.TrimPrefix(a, "v"))
148+ }
149+ if !semver.IsValid(b) {
150+ return 0, fmt.Errorf("invalid semantic version %q", strings.TrimPrefix(b, "v"))
161 }151 }
162- parts := strings.Split(s, ".")152+ return semver.Compare(a, b), nil
163- out := make([]int, 0, len(parts))153+}
164- for _, p := range parts {154+
165- n, _ := strconv.Atoi(p)155+func normalizedVersion(v string) string {
166- out = append(out, n)156+ v = versionOnly(v)
157+ if !strings.HasPrefix(v, "v") {
158+ v = "v" + v
167 }159 }
168- return out160+ return v
169 }161 }
170 162
171 // fetchLatestRelease queries the GitHub API for the latest published163 // fetchLatestRelease queries the GitHub API for the latest published
M packages/agent/update_test.go +32 -0
@@ -52,3 +52,35 @@ func TestCheckForUpdateSkipsDevVersionWithoutCacheOrNetwork(t *testing.T) {
5252 t.Fatalf("development check made %d network requests, want 0", transport.calls)
5353 }
5454 }
55+
56+func TestVersionLess(t *testing.T) {
57+ pseudo := "0.3.35-0.20260809165004-9e3c28d4b65"
58+ cases := []struct {
59+ name string
60+ a, b string
61+ want bool
62+ }{
63+ {name: "older release", a: "0.3.33", b: "0.3.34", want: true},
64+ {name: "newer release", a: "0.3.35", b: "0.3.34", want: false},
65+ {name: "pseudo after prior release", a: pseudo, b: "0.3.34", want: false},
66+ {name: "pseudo before final release", a: pseudo, b: "0.3.35", want: true},
67+ {name: "prior release before pseudo", a: "0.3.34", b: pseudo, want: true},
68+ {name: "decorated build", a: "0.3.33 (abc1234, 2026-08-09)", b: "0.3.34", want: true},
69+ {name: "invalid current", a: "not-a-version", b: "0.3.34", want: false},
70+ }
71+ for _, tc := range cases {
72+ t.Run(tc.name, func(t *testing.T) {
73+ if got := versionLess(tc.a, tc.b); got != tc.want {
74+ t.Fatalf("versionLess(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want)
75+ }
76+ })
77+ }
78+}
79+
80+func TestBuildInfoDoesNotOfferPseudoVersionDowngrade(t *testing.T) {
81+ current := "0.3.35-0.20260809165004-9e3c28d4b65"
82+ info := buildInfo(current, "v0.3.34", "https://example.com/release")
83+ if info.Available {
84+ t.Fatalf("buildInfo(%q, %q) offered a downgrade", current, info.Latest)
85+ }
86+}
@@ -52,3 +52,35 @@ func TestCheckForUpdateSkipsDevVersionWithoutCacheOrNetwork(t *testing.T) {
52 t.Fatalf("development check made %d network requests, want 0", transport.calls)52 t.Fatalf("development check made %d network requests, want 0", transport.calls)
53 }53 }
54 }54 }
55+
56+func TestVersionLess(t *testing.T) {
57+ pseudo := "0.3.35-0.20260809165004-9e3c28d4b65"
58+ cases := []struct {
59+ name string
60+ a, b string
61+ want bool
62+ }{
63+ {name: "older release", a: "0.3.33", b: "0.3.34", want: true},
64+ {name: "newer release", a: "0.3.35", b: "0.3.34", want: false},
65+ {name: "pseudo after prior release", a: pseudo, b: "0.3.34", want: false},
66+ {name: "pseudo before final release", a: pseudo, b: "0.3.35", want: true},
67+ {name: "prior release before pseudo", a: "0.3.34", b: pseudo, want: true},
68+ {name: "decorated build", a: "0.3.33 (abc1234, 2026-08-09)", b: "0.3.34", want: true},
69+ {name: "invalid current", a: "not-a-version", b: "0.3.34", want: false},
70+ }
71+ for _, tc := range cases {
72+ t.Run(tc.name, func(t *testing.T) {
73+ if got := versionLess(tc.a, tc.b); got != tc.want {
74+ t.Fatalf("versionLess(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want)
75+ }
76+ })
77+ }
78+}
79+
80+func TestBuildInfoDoesNotOfferPseudoVersionDowngrade(t *testing.T) {
81+ current := "0.3.35-0.20260809165004-9e3c28d4b65"
82+ info := buildInfo(current, "v0.3.34", "https://example.com/release")
83+ if info.Available {
84+ t.Fatalf("buildInfo(%q, %q) offered a downgrade", current, info.Latest)
85+ }
86+}
M packages/agent/zutfile.go +5 -1
@@ -765,7 +765,11 @@ func checkZutfileRequirements(zf zutfileLoaded, version string) error {
765765 if isDevVersion(version) {
766766 return fmt.Errorf("agent requires zut %s or newer; unversioned development builds cannot satisfy min_zut", min)
767767 }
768- if versionLess(version, min) {
768+ comparison, err := compareVersions(version, min)
769+ if err != nil {
770+ return fmt.Errorf("validate min_zut requirement: %w", err)
771+ }
772+ if comparison < 0 {
769773 return fmt.Errorf("agent requires zut %s or newer; running %s", min, versionOnly(version))
770774 }
771775 }
@@ -765,7 +765,11 @@ func checkZutfileRequirements(zf zutfileLoaded, version string) error {
765 if isDevVersion(version) {765 if isDevVersion(version) {
766 return fmt.Errorf("agent requires zut %s or newer; unversioned development builds cannot satisfy min_zut", min)766 return fmt.Errorf("agent requires zut %s or newer; unversioned development builds cannot satisfy min_zut", min)
767 }767 }
768- if versionLess(version, min) {768+ comparison, err := compareVersions(version, min)
769+ if err != nil {
770+ return fmt.Errorf("validate min_zut requirement: %w", err)
771+ }
772+ if comparison < 0 {
769 return fmt.Errorf("agent requires zut %s or newer; running %s", min, versionOnly(version))773 return fmt.Errorf("agent requires zut %s or newer; running %s", min, versionOnly(version))
770 }774 }
771 }775 }
M packages/agent/zutfile_test.go +8 -0
@@ -246,6 +246,14 @@ func TestCheckZutfileMinVersion(t *testing.T) {
246246 if err := checkZutfileRequirements(zf, "0.3.0"); err != nil {
247247 t.Fatalf("minimum version rejected: %v", err)
248248 }
249+ if err := checkZutfileRequirements(zf, "not-a-version"); err == nil {
250+ t.Fatal("invalid zut version accepted")
251+ }
252+
253+ zf.Manifest.Runtime.MinZut = "not-a-version"
254+ if err := checkZutfileRequirements(zf, "0.3.0"); err == nil {
255+ t.Fatal("invalid min_zut requirement accepted")
256+ }
249257 }
250258
251259 func TestApplyZutfileModelRequirementsRejectsUnsupportedFields(t *testing.T) {
@@ -246,6 +246,14 @@ func TestCheckZutfileMinVersion(t *testing.T) {
246 if err := checkZutfileRequirements(zf, "0.3.0"); err != nil {246 if err := checkZutfileRequirements(zf, "0.3.0"); err != nil {
247 t.Fatalf("minimum version rejected: %v", err)247 t.Fatalf("minimum version rejected: %v", err)
248 }248 }
249+ if err := checkZutfileRequirements(zf, "not-a-version"); err == nil {
250+ t.Fatal("invalid zut version accepted")
251+ }
252+
253+ zf.Manifest.Runtime.MinZut = "not-a-version"
254+ if err := checkZutfileRequirements(zf, "0.3.0"); err == nil {
255+ t.Fatal("invalid min_zut requirement accepted")
256+ }
249 }257 }
250 258
251 func TestApplyZutfileModelRequirementsRejectsUnsupportedFields(t *testing.T) {259 func TestApplyZutfileModelRequirementsRejectsUnsupportedFields(t *testing.T) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close