Skip to content

Commit 19e6d8a

Browse files
committed
fix(versioncheck): pick semver-max instead of first stable tag
cmd/versioncheck was treating LatestTags() as ordered newest-first and taking the first stable entry. Three of the registries don't honor that: - Docker Hub sorts by tag_last_pushed, so a late patch on an old branch ranks above the real latest (bitcoin: v27.2 ahead of v28.0) - GHCR and OCI v2 tags/list don't define order at all Net effect: ~12 chains were silently marked "up to date" against a fake latest (bitcoin, bsc, cardano, cosmos, harmony, klaytn, opbnb, mantle, ton, goat, gravity-alpha, etc.). Mostly harmless for now because the real latest is genuinely newer, but it hides real updates behind false confidence. Added registry.Newest() — picks semver-max from a slice, skipping unparseable tags. Caller still strips pre-releases first. Updated checkVersions to collect all stable tags into a slice and call Newest(), instead of break-on-first. Re-running cmd/versioncheck shows the expected fixes: - bitcoin v28.0 -> v28.0 (was v27.2) - ton v2026.02-1 -> v2025.03 (was v2024.08) - gravity-alpha v3.6.8 -> v3.6.8 (was v2.3.3) - cosmos v27.0.0 -> v25.2.0 (was v19.0.0) - opbnb v0.5.2 -> v0.4.1 (was v0.2.0) - goat v0.4.2 -> v0.4.2 (was v0.1.0) Some chains still report a too-old latest (harmony, bsc 1.3 vs real 1.6.x) — those are pagination issues in the registry clients, the real latest just isn't in the first 50 tags Docker Hub returns. That's a separate fix. Also narrowed .gitignore "versioncheck" -> "/versioncheck" so the pattern matches only the root-level compiled binary, not the cmd/versioncheck/ source dir (git refused to add main.go otherwise). Context: traced bitcoin's "up to date v28.0 vs v27.2" output back to checkVersions(); read internal/registry/{client,dockerhub,ghcr, oci}.go to confirm none of them sort by semver; added 6 test cases for Newest including CalVer (TON-style v2026.02), prefix stripping, and unparseable-tag skipping. ~45 min total.
1 parent d6345d5 commit 19e6d8a

4 files changed

Lines changed: 76 additions & 5 deletions

File tree

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ cover.out
4141
testbin/
4242

4343
# Compiled binaries (use bin/ for build artifacts)
44-
versioncheck
44+
/versioncheck
4545

4646
# Logo lives in assets/, not in repo root
4747
/logo.png

cmd/versioncheck/main.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -144,14 +144,19 @@ func checkVersions(
144144
return
145145
}
146146

147-
// Pick the latest stable tag (skip pre-releases, nightly, latest, etc.)
147+
// Collect all stable tags, then pick the semver-max — registries
148+
// don't guarantee ordering (Docker Hub sorts by last_pushed, which
149+
// can rank a stale patch above a real major release).
150+
stable := make([]string, 0, len(tags))
148151
for _, t := range tags {
149152
if isStableTag(t.Tag, it.policy.TagPrefix) {
150-
res.LatestTag = t.Tag
151-
res.IsNewer = registry.IsNewer(res.LatestTag, res.CurrentTag, it.policy.TagPrefix)
152-
break
153+
stable = append(stable, t.Tag)
153154
}
154155
}
156+
if latest := registry.Newest(stable, it.policy.TagPrefix); latest != "" {
157+
res.LatestTag = latest
158+
res.IsNewer = registry.IsNewer(res.LatestTag, res.CurrentTag, it.policy.TagPrefix)
159+
}
155160

156161
mu.Lock()
157162
results = append(results, res)

internal/registry/semver.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,23 @@ func IsNewer(candidate, current, prefix string) bool {
3636
return semver.Compare(c, cur) > 0
3737
}
3838

39+
// Newest returns the tag from the slice that is greatest by semver under the
40+
// given prefix. Tags that fail semver parsing are skipped. Returns "" if no
41+
// candidate parses successfully. Callers should pre-filter pre-releases /
42+
// floating tags before passing tags in.
43+
func Newest(tags []string, prefix string) string {
44+
best := ""
45+
for _, t := range tags {
46+
if !semver.IsValid(normalizeTag(t, prefix)) {
47+
continue
48+
}
49+
if best == "" || IsNewer(t, best, prefix) {
50+
best = t
51+
}
52+
}
53+
return best
54+
}
55+
3956
// normalizeTag strips prefix, ensures a leading "v", and pads to vMAJOR.MINOR.PATCH
4057
// to satisfy golang.org/x/mod/semver strict validation (no leading zeros, 3 parts).
4158
func normalizeTag(tag, prefix string) string {

internal/registry/semver_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,55 @@ func TestIsNewer(t *testing.T) {
4141
}
4242
}
4343

44+
func TestNewest(t *testing.T) {
45+
cases := []struct {
46+
name string
47+
tags []string
48+
prefix string
49+
want string
50+
}{
51+
{
52+
name: "registry returns mixed order picks max",
53+
tags: []string{"v27.2", "v28.0", "v27.1", "v26.5"},
54+
want: "v28.0",
55+
},
56+
{
57+
name: "CalVer year-month",
58+
tags: []string{"v2024.08-1", "v2026.02-1", "v2025.06-1"},
59+
want: "v2026.02-1",
60+
},
61+
{
62+
name: "with prefix",
63+
tags: []string{"GreatVoyage-v4.8.0", "GreatVoyage-v4.8.1", "GreatVoyage-v4.7.9"},
64+
prefix: "GreatVoyage-",
65+
want: "GreatVoyage-v4.8.1",
66+
},
67+
{
68+
name: "skips garbage tags",
69+
tags: []string{"nightly", "v1.2.3", "stable"},
70+
want: "v1.2.3",
71+
},
72+
{
73+
name: "empty input",
74+
tags: nil,
75+
want: "",
76+
},
77+
{
78+
name: "all invalid",
79+
tags: []string{"foo", "bar"},
80+
want: "",
81+
},
82+
}
83+
for _, tc := range cases {
84+
t.Run(tc.name, func(t *testing.T) {
85+
got := Newest(tc.tags, tc.prefix)
86+
if got != tc.want {
87+
t.Errorf("Newest(%v, %q) = %q, want %q", tc.tags, tc.prefix, got, tc.want)
88+
}
89+
})
90+
}
91+
}
92+
4493
func TestNormalizeTag(t *testing.T) {
4594
cases := []struct {
4695
tag, prefix, want string

0 commit comments

Comments
 (0)