feat: add arch/libc fallback chains and version waterfall resolution

Prefer latest version over best CPU match. An amd64v4 machine gets
v2.0.0 (baseline only) instead of v1.0.0 (which had a v4 build)
because recency beats specificity.

- buildmeta: add amd64v2/v3/v4 micro-levels, ArchFallbacks, LibcFallbacks
- classify: detect micro-arch levels, treat Windows "arm" as ARM64
- platlatest: add Resolve() that walks fallback chains picking newest
This commit is contained in:
AJ ONeal
2026-03-09 21:44:06 -06:00
parent 1e26a3e5ec
commit 34cfe32492
5 changed files with 247 additions and 5 deletions

View File

@@ -30,7 +30,10 @@ type Arch string
const (
ArchAny Arch = "ANYARCH"
ArchAMD64 Arch = "x86_64"
ArchAMD64 Arch = "x86_64" // baseline (v1)
ArchAMD64v2 Arch = "x86_64_v2" // +SSE4, +POPCNT, etc.
ArchAMD64v3 Arch = "x86_64_v3" // +AVX2, +BMI, etc.
ArchAMD64v4 Arch = "x86_64_v4" // +AVX-512
ArchARM64 Arch = "aarch64"
ArchARMv7 Arch = "armv7"
ArchARMv6 Arch = "armv6"
@@ -98,3 +101,41 @@ type Target struct {
func (t Target) Triplet() string {
return string(t.OS) + "-" + string(t.Arch) + "-" + string(t.Libc)
}
// ArchFallbacks returns the architectures that a machine with the given
// arch can run, ordered from most specific to least. The input arch is
// always first. Returns nil for unknown architectures.
//
// For example, an amd64v4 machine can run v4, v3, v2, and baseline (v1)
// binaries. An armv7 machine can run armv7 and armv6 binaries.
func ArchFallbacks(arch Arch) []Arch {
switch arch {
case ArchAMD64v4:
return []Arch{ArchAMD64v4, ArchAMD64v3, ArchAMD64v2, ArchAMD64}
case ArchAMD64v3:
return []Arch{ArchAMD64v3, ArchAMD64v2, ArchAMD64}
case ArchAMD64v2:
return []Arch{ArchAMD64v2, ArchAMD64}
case ArchARMv7:
return []Arch{ArchARMv7, ArchARMv6}
default:
// No fallback chain — exact match only.
return []Arch{arch}
}
}
// LibcFallbacks returns the libc variants a machine can use, ordered
// by preference. A musl system can only run musl or static binaries.
// A glibc system can only run glibc or static binaries.
func LibcFallbacks(libc Libc) []Libc {
switch libc {
case LibcGNU:
return []Libc{LibcGNU, LibcNone}
case LibcMusl:
return []Libc{LibcMusl, LibcNone}
case LibcMSVC:
return []Libc{LibcMSVC, LibcNone}
default:
return []Libc{libc}
}
}

View File

@@ -32,11 +32,25 @@ func (r Result) Target() buildmeta.Target {
// Filename classifies a release asset filename, returning the detected
// OS, architecture, libc, and archive format. Undetected fields are empty.
//
// OS is detected first because it can influence arch interpretation.
// For example, "windows-arm" in modern releases means ARM64, while
// bare "arm" on Linux historically means ARMv6.
func Filename(name string) Result {
lower := strings.ToLower(name)
os := detectOS(lower)
arch := detectArch(lower)
// On Windows, bare "arm" (detected as ARMv6) almost certainly means
// ARM64. Windows never shipped ARMv6 binaries — "ARM" became the
// marketing label for ARM64 (Windows on ARM).
if os == buildmeta.OSWindows && arch == buildmeta.ArchARMv6 {
arch = buildmeta.ArchARM64
}
return Result{
OS: detectOS(lower),
Arch: detectArch(lower),
OS: os,
Arch: arch,
Libc: detectLibc(lower),
Format: detectFormat(lower),
}
@@ -78,12 +92,16 @@ var archPatterns = []struct {
arch buildmeta.Arch
pattern *regexp.Regexp
}{
// amd64 before x86 — "x86_64" must not match as x86.
// amd64 micro-levels before baseline — "amd64v3" must not fall through to amd64.
{buildmeta.ArchAMD64v4, regexp.MustCompile(`(?i)(?:x86[_-]64[_-]v4|amd64v4|v4-amd64)`)},
{buildmeta.ArchAMD64v3, regexp.MustCompile(`(?i)(?:x86[_-]64[_-]v3|amd64v3|v3-amd64)`)},
{buildmeta.ArchAMD64v2, regexp.MustCompile(`(?i)(?:x86[_-]64[_-]v2|amd64v2|v2-amd64)`)},
// amd64 baseline before x86 — "x86_64" must not match as x86.
{buildmeta.ArchAMD64, regexp.MustCompile(`(?i)(?:x86[_-]64|amd64|x64|64-bit)`)},
// arm64 before armv7/armv6 — "aarch64" must not match as arm.
{buildmeta.ArchARM64, regexp.MustCompile(`(?i)(?:aarch64|arm64|armv8)`)},
{buildmeta.ArchARMv7, regexp.MustCompile(`(?i)(?:armv7l?|arm-?v7|arm32)`)},
{buildmeta.ArchARMv6, regexp.MustCompile(`(?i)(?:armv6l?|arm-?v6|aarch32)`)},
{buildmeta.ArchARMv6, regexp.MustCompile(`(?i)(?:armv6l?|arm-?v6|aarch32|` + b + `arm` + bEnd + `)`)},
// ppc64le before ppc64.
{buildmeta.ArchPPC64LE, regexp.MustCompile(`(?i)ppc64le`)},
{buildmeta.ArchPPC64, regexp.MustCompile(`(?i)ppc64`)},

View File

@@ -151,6 +151,46 @@ func TestFilename(t *testing.T) {
format: buildmeta.FormatTarGz,
},
// Windows ARM: bare "arm" means ARM64, not ARMv6/v7
{
name: "windows arm means arm64",
input: "tool-1.0.0-windows-arm.zip",
wantOS: buildmeta.OSWindows,
arch: buildmeta.ArchARM64,
format: buildmeta.FormatZip,
},
{
name: "windows arm64 stays arm64",
input: "tool-1.0.0-windows-arm64.zip",
wantOS: buildmeta.OSWindows,
arch: buildmeta.ArchARM64,
format: buildmeta.FormatZip,
},
// amd64 micro-architecture levels
{
name: "amd64v2",
input: "tool-linux-amd64v2.tar.gz",
arch: buildmeta.ArchAMD64v2,
},
{
name: "amd64v3",
input: "tool-linux-x86_64_v3.tar.gz",
arch: buildmeta.ArchAMD64v3,
},
{
name: "amd64v4",
input: "tool-linux-amd64v4.tar.gz",
arch: buildmeta.ArchAMD64v4,
},
{
name: "amd64v3 not baseline",
input: "tool-1.0.0-linux-amd64v3.tar.gz",
wantOS: buildmeta.OSLinux,
arch: buildmeta.ArchAMD64v3,
format: buildmeta.FormatTarGz,
},
// ARM variants: arm64 must not match armv7/armv6
{
name: "aarch64 not armv7",

View File

@@ -22,6 +22,7 @@ import (
"sync"
"github.com/webinstall/webi-installers/internal/buildmeta"
"github.com/webinstall/webi-installers/internal/lexver"
)
// Index tracks the latest version for each build target of a package.
@@ -78,6 +79,41 @@ func (idx *Index) All() map[string]string {
return out
}
// Resolve finds the latest compatible version for a target by walking
// the arch and libc fallback chains. It prefers the newest version over
// the most specific arch match.
//
// For example, on an amd64v4 machine with glibc:
// - v2.0.0 has amd64 (baseline) → candidate "v2.0.0"
// - v1.0.0 has amd64v4 → candidate "v1.0.0"
// - Returns "v2.0.0" because it's newer, even though v1.0.0
// is a more specific arch match.
//
// Returns the best version and the exact target it matched, or "" if
// no compatible version exists.
func (idx *Index) Resolve(t buildmeta.Target) (version string, matched buildmeta.Target) {
idx.mu.RLock()
defer idx.mu.RUnlock()
arches := buildmeta.ArchFallbacks(t.Arch)
libcs := buildmeta.LibcFallbacks(t.Libc)
for _, arch := range arches {
for _, libc := range libcs {
candidate := buildmeta.Target{OS: t.OS, Arch: arch, Libc: libc}
v := idx.m[candidate.Triplet()]
if v == "" {
continue
}
if version == "" || lexver.Compare(lexver.Parse(v), lexver.Parse(version)) > 0 {
version = v
matched = candidate
}
}
}
return version, matched
}
// Save persists the index to disk (atomic write).
func (idx *Index) Save() error {
idx.mu.RLock()

View File

@@ -91,6 +91,113 @@ func TestAll(t *testing.T) {
}
}
func TestResolveArchFallback(t *testing.T) {
p := filepath.Join(t.TempDir(), "latest.json")
idx, err := platlatest.Open(p)
if err != nil {
t.Fatal(err)
}
// v1.0.0 had per-microarch builds.
idx.Set(buildmeta.Target{OS: buildmeta.OSLinux, Arch: buildmeta.ArchAMD64v4, Libc: buildmeta.LibcGNU}, "v1.0.0")
idx.Set(buildmeta.Target{OS: buildmeta.OSLinux, Arch: buildmeta.ArchAMD64v3, Libc: buildmeta.LibcGNU}, "v1.0.0")
idx.Set(buildmeta.Target{OS: buildmeta.OSLinux, Arch: buildmeta.ArchAMD64v2, Libc: buildmeta.LibcGNU}, "v1.0.0")
idx.Set(buildmeta.Target{OS: buildmeta.OSLinux, Arch: buildmeta.ArchAMD64, Libc: buildmeta.LibcGNU}, "v1.0.0")
// v2.0.0 dropped microarch, ships only baseline amd64.
idx.Set(buildmeta.Target{OS: buildmeta.OSLinux, Arch: buildmeta.ArchAMD64, Libc: buildmeta.LibcGNU}, "v2.0.0")
// An amd64v4 machine should get v2.0.0 (latest) via baseline fallback,
// not v1.0.0 (which had a specific v4 build).
want := buildmeta.Target{OS: buildmeta.OSLinux, Arch: buildmeta.ArchAMD64v4, Libc: buildmeta.LibcGNU}
ver, matched := idx.Resolve(want)
if ver != "v2.0.0" {
t.Errorf("Resolve(amd64v4) version = %q, want v2.0.0", ver)
}
if matched.Arch != buildmeta.ArchAMD64 {
t.Errorf("Resolve(amd64v4) matched arch = %q, want %q", matched.Arch, buildmeta.ArchAMD64)
}
}
func TestResolveExactMatchPreferred(t *testing.T) {
p := filepath.Join(t.TempDir(), "latest.json")
idx, err := platlatest.Open(p)
if err != nil {
t.Fatal(err)
}
// Both amd64v3 and baseline have the same latest version.
idx.Set(buildmeta.Target{OS: buildmeta.OSLinux, Arch: buildmeta.ArchAMD64v3, Libc: buildmeta.LibcGNU}, "v2.0.0")
idx.Set(buildmeta.Target{OS: buildmeta.OSLinux, Arch: buildmeta.ArchAMD64, Libc: buildmeta.LibcGNU}, "v2.0.0")
// When versions are equal, the more specific arch should win
// (it appears first in the fallback chain).
want := buildmeta.Target{OS: buildmeta.OSLinux, Arch: buildmeta.ArchAMD64v3, Libc: buildmeta.LibcGNU}
ver, matched := idx.Resolve(want)
if ver != "v2.0.0" {
t.Errorf("version = %q, want v2.0.0", ver)
}
if matched.Arch != buildmeta.ArchAMD64v3 {
t.Errorf("matched arch = %q, want %q (more specific)", matched.Arch, buildmeta.ArchAMD64v3)
}
}
func TestResolveLibcFallback(t *testing.T) {
p := filepath.Join(t.TempDir(), "latest.json")
idx, err := platlatest.Open(p)
if err != nil {
t.Fatal(err)
}
// Only a static (LibcNone) build exists.
idx.Set(buildmeta.Target{OS: buildmeta.OSLinux, Arch: buildmeta.ArchAMD64, Libc: buildmeta.LibcNone}, "v1.0.0")
// A glibc machine should find it via libc fallback.
want := buildmeta.Target{OS: buildmeta.OSLinux, Arch: buildmeta.ArchAMD64, Libc: buildmeta.LibcGNU}
ver, matched := idx.Resolve(want)
if ver != "v1.0.0" {
t.Errorf("version = %q, want v1.0.0", ver)
}
if matched.Libc != buildmeta.LibcNone {
t.Errorf("matched libc = %q, want %q", matched.Libc, buildmeta.LibcNone)
}
}
func TestResolveNoMatch(t *testing.T) {
p := filepath.Join(t.TempDir(), "latest.json")
idx, err := platlatest.Open(p)
if err != nil {
t.Fatal(err)
}
idx.Set(linuxAMD64, "v1.0.0")
// Darwin target should not match a Linux entry.
ver, _ := idx.Resolve(darwinARM64)
if ver != "" {
t.Errorf("Resolve(darwin) = %q, want empty (no match)", ver)
}
}
func TestResolveBaselineOnly(t *testing.T) {
p := filepath.Join(t.TempDir(), "latest.json")
idx, err := platlatest.Open(p)
if err != nil {
t.Fatal(err)
}
// amd64v1 machine can't run v2+ binaries.
idx.Set(buildmeta.Target{OS: buildmeta.OSLinux, Arch: buildmeta.ArchAMD64v2, Libc: buildmeta.LibcGNU}, "v2.0.0")
idx.Set(buildmeta.Target{OS: buildmeta.OSLinux, Arch: buildmeta.ArchAMD64, Libc: buildmeta.LibcGNU}, "v1.0.0")
// Baseline machine gets v1.0.0 — it can't run v2's amd64v2 binary.
want := buildmeta.Target{OS: buildmeta.OSLinux, Arch: buildmeta.ArchAMD64, Libc: buildmeta.LibcGNU}
ver, _ := idx.Resolve(want)
if ver != "v1.0.0" {
t.Errorf("Resolve(amd64 baseline) = %q, want v1.0.0", ver)
}
}
func TestOpenNonexistent(t *testing.T) {
p := filepath.Join(t.TempDir(), "does-not-exist.json")
idx, err := platlatest.Open(p)