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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +70 -31
M go.mod +1 -0
| @@ -9,6 +9,7 @@ require ( | |||
| 9 | 9 | github.com/mattn/go-runewidth v0.0.16 | |
| 10 | 10 | github.com/sahilm/fuzzy v0.1.1 | |
| 11 | 11 | golang.org/x/image v0.43.0 | |
| 12 | + | golang.org/x/mod v0.35.0 | |
| 12 | 13 | golang.org/x/net v0.57.0 | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| @@ -9,6 +9,7 @@ require ( | |||||
| 9 | github.com/mattn/go-runewidth v0.0.16 | 9 | github.com/mattn/go-runewidth v0.0.16 | ||
| 10 | github.com/sahilm/fuzzy v0.1.1 | 10 | github.com/sahilm/fuzzy v0.1.1 | ||
| 11 | golang.org/x/image v0.43.0 | 11 | golang.org/x/image v0.43.0 | ||
| 12 | + | golang.org/x/mod v0.35.0 | |||
| 12 | golang.org/x/net v0.57.0 | 13 | 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= | |||
| 22 | 22 | github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= | |
| 23 | 23 | golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY= | |
| 24 | 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 | 27 | golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= | |
| 26 | 28 | golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= | |
| 27 | 29 | 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 ( | |||
| 7 | 7 | "net/http" | |
| 8 | 8 | "os" | |
| 9 | 9 | "path/filepath" | |
| 10 | - | "strconv" | |
| 11 | 10 | "strings" | |
| 12 | 11 | "time" | |
| 12 | + | ||
| 13 | + | "golang.org/x/mod/semver" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| 15 | 16 | // 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 | 133 | return info | |
| 133 | 134 | } | |
| 134 | 135 | ||
| 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. | |
| 138 | 138 | 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 | |
| 154 | 141 | } | |
| 155 | 142 | ||
| 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")) | |
| 161 | 151 | } | |
| 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 | |
| 167 | 159 | } | |
| 168 | - | return out | |
| 160 | + | return v | |
| 169 | 161 | } | |
| 170 | 162 | ||
| 171 | 163 | // 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 new | 16 | // 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 info | 133 | 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's | 137 | + | // 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 out | 160 | + | return v |
| 169 | } | 161 | } | ||
| 170 | 162 | ||||
| 171 | // fetchLatestRelease queries the GitHub API for the latest published | 163 | // 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) { | |||
| 52 | 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 | + | } | |
| @@ -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 { | |||
| 765 | 765 | if isDevVersion(version) { | |
| 766 | 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 | 773 | return fmt.Errorf("agent requires zut %s or newer; running %s", min, versionOnly(version)) | |
| 770 | 774 | } | |
| 771 | 775 | } | |
| @@ -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) { | |||
| 246 | 246 | if err := checkZutfileRequirements(zf, "0.3.0"); err != nil { | |
| 247 | 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 | 259 | 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) { | ||