mirror of
https://github.com/webinstall/webi-installers.git
synced 2026-04-07 02:46:50 +00:00
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:
@@ -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}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`)},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user