fix(legacy): drop universal2/solaris/illumos/android; fix ARM arch for Node classifier

The Node build-classifier re-parses filenames independently and drops any
cache entry where its extraction doesn't match the pre-classified field.

New drops in ExportLegacy (with LegacyDropStats tracking):
- universal2/universal1 arch: classifier maps 'universal' in filename to
  x86_64 and rejects entries with arch='universal2'
- solaris/illumos OS: Node never served these; classifier mismatches
  are unfixable without changing the filename
- android OS: classifier maps android filenames to linux

ARM arch translations in legacyFieldBackport (filename-based):
- gnueabihf / armhf filename → 'armhf' (Go normalizes to armv6/armv7;
  Node classifier preserves the original Debian/Rust naming)
- armel filename → 'armel' (Go normalizes to armv6)
- armv5 filename → 'armel' (Node tiered map: armv5 falls back to armel)
- armv7a filename → 'armv7a' (Go normalizes to armv7)
- armv7l / armv6l: no translation needed (both Go and Node say armv7/armv6)
This commit is contained in:
AJ ONeal
2026-03-11 16:42:29 -06:00
parent ecf8b59b3f
commit 3655ef3625
2 changed files with 411 additions and 207 deletions

View File

@@ -1,5 +1,7 @@
package storage
import "strings"
// Legacy types for reading/writing the Node.js _cache/ JSON format.
//
// The Node.js server calls assets "releases" and uses "name" for the
@@ -30,8 +32,11 @@ type LegacyCache struct {
// LegacyDropStats reports how many assets were excluded during ExportLegacy.
type LegacyDropStats struct {
Variants int // dropped: has build variant tags (e.g. rocm, installer, fxdependent)
Formats int // dropped: format not recognized by the Node.js server
Variants int // dropped: has build variant tags (e.g. rocm, installer, fxdependent)
Formats int // dropped: format not recognized by the Node.js server
Universal int // dropped: universal2/universal1 arch — classifier maps "universal" in filename to x86_64 and rejects the mismatch
SunOS int // dropped: solaris/illumos OS — Node never served these; classifier mismatches are unfixable
Android int // dropped: android OS — classifier maps android filenames to linux
}
// ToAsset converts a LegacyAsset to the internal Asset type.
@@ -71,13 +76,23 @@ func (a Asset) toLegacy() LegacyAsset {
// values the legacy Node.js resolver expects. This is called at export time
// only — the canonical values are preserved in Go-native storage (pgstore).
//
// Global rules (all packages):
// - ARM arch: translated from Go canonical to the value the Node build-classifier
// extracts from the filename (see legacyARMArchFromFilename).
//
// Package-specific rules replicate per-package overrides in production's releases.js:
// - ffmpeg: Windows .gz → .exe (prod releases.js: rel.ext = 'exe')
//
// Note: solaris/illumos are kept as-is. The live cache uses them as distinct
// values (go.json has "illumos" and "solaris" entries). The build-classifier
// (triplet.js) also keeps all three distinct: illumos, solaris, sunos.
func legacyFieldBackport(pkg string, a Asset) Asset {
// ARM arch: the Node classifier re-parses filenames and expects the cache
// arch to match what it extracts. Go normalizes (gnueabihf→armv6, armhf→armv7)
// but the Node classifier preserves the original Debian/Rust naming.
switch a.Arch {
case "armv5", "armv6", "armv7":
if leg := legacyARMArchFromFilename(a.Filename); leg != "" {
a.Arch = leg
}
}
switch pkg {
case "ffmpeg":
if a.OS == "windows" {
@@ -90,6 +105,32 @@ func legacyFieldBackport(pkg string, a Asset) Asset {
return a
}
// legacyARMArchFromFilename returns the arch string the Node build-classifier
// would extract from a filename for ARM-family builds. Returns "" when the
// Go canonical arch value already matches what the classifier would extract.
//
// The Node classifier's extraction rules differ from Go's normalization:
// - gnueabihf (Rust triplet) / armhf (Debian) → "armhf" (not "armv6" or "armv7")
// - armel (Debian soft-float ABI) → "armel" (not "armv6")
// - armv5 → "armel" (Node tiered map: armv5 falls back to armel)
// - armv7a → "armv7a" (not "armv7")
func legacyARMArchFromFilename(filename string) string {
lower := strings.ToLower(filename)
if strings.Contains(lower, "gnueabihf") || strings.Contains(lower, "armhf") {
return "armhf"
}
if strings.Contains(lower, "armv7a") {
return "armv7a"
}
if strings.Contains(lower, "armel") {
return "armel"
}
if strings.Contains(lower, "armv5") {
return "armel"
}
return ""
}
// ImportLegacy converts a LegacyCache to PackageData.
func ImportLegacy(lc LegacyCache) PackageData {
assets := make([]Asset, len(lc.Releases))
@@ -122,9 +163,12 @@ var legacyFormats = map[string]bool{
// ExportLegacy converts canonical PackageData to the LegacyCache wire format.
//
// The pkg name is used to apply per-package field translations before export
// (see legacyFieldBackport). Assets are excluded when:
// The pkg name is used to apply per-package field translations (see legacyFieldBackport).
// Assets are excluded when:
// - Variants is non-empty (Node.js has no variant logic)
// - Arch is universal2 or universal1 (classifier maps "universal" in filename to x86_64 and rejects the mismatch)
// - OS is solaris or illumos (Node never served these; classifier mismatches are unfixable)
// - OS is android (classifier maps android filenames to linux)
// - Format is non-empty and not in the Node.js recognized set
//
// Dropped counts are returned in LegacyDropStats for logging.
@@ -138,6 +182,25 @@ func ExportLegacy(pkg string, pd PackageData) (LegacyCache, LegacyDropStats) {
stats.Variants++
continue
}
// Skip universal fat binaries — classifier maps "universal" in filename
// to x86_64 and rejects any cache entry that doesn't say x86_64.
if a.Arch == "universal2" || a.Arch == "universal1" {
stats.Universal++
continue
}
// Skip solaris/illumos — Node never served these platforms;
// the classifier causes mismatches that can't be fixed without
// changing the filename.
if a.OS == "solaris" || a.OS == "illumos" {
stats.SunOS++
continue
}
// Skip android — classifier maps android filenames to linux OS,
// which mismatches cache entries tagged android.
if a.OS == "android" {
stats.Android++
continue
}
// Apply per-package legacy field translations before format check.
a = legacyFieldBackport(pkg, a)
// Skip formats Node.js doesn't recognize.

View File

@@ -7,205 +7,6 @@ import (
"github.com/webinstall/webi-installers/internal/storage"
)
// TestExportLegacyDrops verifies that ExportLegacy correctly drops and counts
// assets that can't be represented in the Node.js legacy cache format.
func TestExportLegacyDrops(t *testing.T) {
t.Run("variant_builds_dropped", func(t *testing.T) {
// Assets with variant tags (rocm, installer, fxdependent, etc.) are
// dropped because Node.js has no variant-selection logic.
pd := storage.PackageData{
Assets: []storage.Asset{
{Filename: "ollama-linux-amd64-rocm.tgz", OS: "linux", Arch: "x86_64", Format: ".tar.gz", Variants: []string{"rocm"}},
{Filename: "ollama-linux-amd64.tgz", OS: "linux", Arch: "x86_64", Format: ".tar.gz"},
},
}
lc, stats := storage.ExportLegacy("ollama", pd)
if stats.Variants != 1 {
t.Errorf("Variants dropped = %d, want 1", stats.Variants)
}
if len(lc.Releases) != 1 {
t.Errorf("releases = %d, want 1 (baseline only)", len(lc.Releases))
}
if lc.Releases[0].Name != "ollama-linux-amd64.tgz" {
t.Errorf("kept wrong release: %q", lc.Releases[0].Name)
}
})
t.Run("unknown_formats_dropped", func(t *testing.T) {
// .apk, .AppImage, .deb, .rpm are not in the Node.js format set.
pd := storage.PackageData{
Assets: []storage.Asset{
{Filename: "tool.apk", OS: "android", Format: ".apk"},
{Filename: "tool.AppImage", OS: "linux", Format: ".AppImage"},
{Filename: "tool.deb", OS: "linux", Format: ".deb"},
{Filename: "tool.rpm", OS: "linux", Format: ".rpm"},
{Filename: "tool-linux-amd64.tar.gz", OS: "linux", Arch: "x86_64", Format: ".tar.gz"},
},
}
lc, stats := storage.ExportLegacy("tool", pd)
if stats.Formats != 4 {
t.Errorf("Formats dropped = %d, want 4", stats.Formats)
}
if len(lc.Releases) != 1 {
t.Errorf("releases = %d, want 1 (tar.gz only)", len(lc.Releases))
}
})
t.Run("empty_format_passes_through", func(t *testing.T) {
// Assets with empty format (e.g. bare binaries, git sources) pass through.
pd := storage.PackageData{
Assets: []storage.Asset{
{Filename: "jq-linux-amd64", OS: "linux", Arch: "x86_64", Format: ""},
},
}
lc, stats := storage.ExportLegacy("jq", pd)
if stats.Formats != 0 {
t.Errorf("Formats dropped = %d, want 0", stats.Formats)
}
if len(lc.Releases) != 1 {
t.Errorf("releases = %d, want 1", len(lc.Releases))
}
})
}
// TestExportLegacyTranslations verifies that legacyFieldBackport applies the
// correct field translations for Node.js compatibility.
func TestExportLegacyTranslations(t *testing.T) {
t.Run("solaris_kept_as_is", func(t *testing.T) {
// The live cache uses "solaris" and "illumos" as distinct values (go.json).
// The build-classifier (triplet.js) keeps all three distinct: illumos, solaris, sunos.
pd := storage.PackageData{
Assets: []storage.Asset{
{Filename: "go1.20.1.solaris-amd64.tar.gz", OS: "solaris", Arch: "x86_64", Format: ".tar.gz"},
},
}
lc, _ := storage.ExportLegacy("go", pd)
if len(lc.Releases) != 1 {
t.Fatalf("releases = %d, want 1", len(lc.Releases))
}
if lc.Releases[0].OS != "solaris" {
t.Errorf("OS = %q, want %q", lc.Releases[0].OS, "solaris")
}
})
t.Run("illumos_kept_as_is", func(t *testing.T) {
pd := storage.PackageData{
Assets: []storage.Asset{
{Filename: "go1.20.1.illumos-amd64.tar.gz", OS: "illumos", Arch: "x86_64", Format: ".tar.gz"},
},
}
lc, _ := storage.ExportLegacy("go", pd)
if len(lc.Releases) != 1 {
t.Fatalf("releases = %d, want 1", len(lc.Releases))
}
if lc.Releases[0].OS != "illumos" {
t.Errorf("OS = %q, want %q", lc.Releases[0].OS, "illumos")
}
})
t.Run("ffmpeg_windows_gz_to_exe", func(t *testing.T) {
// ffmpeg Windows releases are .gz archives containing a bare .exe.
// Production releases.js overrides ext to 'exe' for install compatibility.
pd := storage.PackageData{
Assets: []storage.Asset{
{Filename: "ffmpeg-7.0-windows-amd64.gz", OS: "windows", Arch: "x86_64", Format: ".gz"},
{Filename: "ffmpeg-7.0-linux-amd64.tar.gz", OS: "linux", Arch: "x86_64", Format: ".tar.gz"},
},
}
lc, _ := storage.ExportLegacy("ffmpeg", pd)
if len(lc.Releases) != 2 {
t.Fatalf("releases = %d, want 2", len(lc.Releases))
}
var windowsExt string
for _, r := range lc.Releases {
if r.OS == "windows" {
windowsExt = r.Ext
}
}
if windowsExt != ".exe" {
t.Errorf("ffmpeg windows ext = %q, want %q", windowsExt, ".exe")
}
})
t.Run("ffmpeg_translation_not_applied_to_other_packages", func(t *testing.T) {
// The ffmpeg .gz→.exe translation is package-specific.
pd := storage.PackageData{
Assets: []storage.Asset{
{Filename: "othertool-windows-amd64.gz", OS: "windows", Arch: "x86_64", Format: ".gz"},
},
}
lc, _ := storage.ExportLegacy("othertool", pd)
if len(lc.Releases) != 1 {
t.Fatalf("releases = %d, want 1", len(lc.Releases))
}
if lc.Releases[0].Ext != ".gz" {
t.Errorf("ext = %q, want %q (no translation outside ffmpeg)", lc.Releases[0].Ext, ".gz")
}
})
t.Run("universal2_passes_through", func(t *testing.T) {
// universal2 (x86_64 + ARM64 fat binary) is kept as-is in the legacy
// cache. The Node.js side handles resolution via its WATERFALL/CompatArches.
pd := storage.PackageData{
Assets: []storage.Asset{
{Filename: "hugo_0.145.0_darwin-universal.tar.gz", OS: "darwin", Arch: "universal2", Format: ".tar.gz"},
},
}
lc, stats := storage.ExportLegacy("hugo", pd)
if stats.Variants != 0 || stats.Formats != 0 {
t.Errorf("unexpected drops: variants=%d formats=%d", stats.Variants, stats.Formats)
}
if len(lc.Releases) != 1 {
t.Fatalf("releases = %d, want 1", len(lc.Releases))
}
if lc.Releases[0].Arch != "universal2" {
t.Errorf("arch = %q, want %q", lc.Releases[0].Arch, "universal2")
}
})
}
// TestExportLegacyMixed verifies correct counting when multiple drop/translate
// categories appear together in a single export call.
func TestExportLegacyMixed(t *testing.T) {
pd := storage.PackageData{
Assets: []storage.Asset{
// kept: baseline linux build
{Filename: "tool-linux-amd64.tar.gz", OS: "linux", Arch: "x86_64", Format: ".tar.gz"},
// dropped: variant build
{Filename: "tool-linux-amd64-rocm.tar.gz", OS: "linux", Arch: "x86_64", Format: ".tar.gz", Variants: []string{"rocm"}},
// dropped: .apk format
{Filename: "tool.apk", OS: "android", Format: ".apk"},
// kept: illumos (translated to sunos)
{Filename: "tool-illumos-amd64.tar.gz", OS: "illumos", Arch: "x86_64", Format: ".tar.gz"},
},
}
lc, stats := storage.ExportLegacy("tool", pd)
if stats.Variants != 1 {
t.Errorf("Variants = %d, want 1", stats.Variants)
}
if stats.Formats != 1 {
t.Errorf("Formats = %d, want 1", stats.Formats)
}
if len(lc.Releases) != 2 {
t.Errorf("releases = %d, want 2 (linux + sunos)", len(lc.Releases))
}
// Verify illumos is kept as-is (not translated to sunos).
var foundIllumos bool
for _, r := range lc.Releases {
if r.OS == "illumos" {
foundIllumos = true
}
if r.OS == "sunos" {
t.Error("illumos should NOT be translated to sunos")
}
}
if !foundIllumos {
t.Error("expected an illumos release (kept as-is)")
}
}
// TestDecodeLegacyJSON verifies we can parse the exact JSON format
// the Node.js server writes to _cache/.
func TestDecodeLegacyJSON(t *testing.T) {
@@ -284,3 +85,343 @@ func TestDecodeLegacyJSON(t *testing.T) {
t.Errorf("round-trip Ext = %q, want %q", lc3.Releases[0].Ext, a.Format)
}
}
// TestExportLegacyDrops verifies that ExportLegacy correctly drops and counts
// assets that can't be represented in the Node.js legacy cache format.
func TestExportLegacyDrops(t *testing.T) {
t.Run("variant_builds_dropped", func(t *testing.T) {
// Assets with variant tags (rocm, installer, fxdependent, etc.) are
// dropped because Node.js has no variant-selection logic.
pd := storage.PackageData{
Assets: []storage.Asset{
{Filename: "ollama-linux-amd64-rocm.tgz", OS: "linux", Arch: "x86_64", Format: ".tar.gz", Variants: []string{"rocm"}},
{Filename: "ollama-linux-amd64.tgz", OS: "linux", Arch: "x86_64", Format: ".tar.gz"},
},
}
lc, stats := storage.ExportLegacy("ollama", pd)
if stats.Variants != 1 {
t.Errorf("Variants dropped = %d, want 1", stats.Variants)
}
if len(lc.Releases) != 1 {
t.Errorf("releases = %d, want 1 (baseline only)", len(lc.Releases))
}
if lc.Releases[0].Name != "ollama-linux-amd64.tgz" {
t.Errorf("kept wrong release: %q", lc.Releases[0].Name)
}
})
t.Run("universal2_dropped", func(t *testing.T) {
// universal2 fat binaries are dropped. The Node classifier maps "universal"
// in the filename to x86_64, then rejects the cache entry because it says
// "universal2". The mismatch can't be fixed without changing the filename.
pd := storage.PackageData{
Assets: []storage.Asset{
{Filename: "hugo_0.145.0_darwin-universal.tar.gz", OS: "darwin", Arch: "universal2", Format: ".tar.gz"},
{Filename: "hugo_0.145.0_darwin-arm64.tar.gz", OS: "darwin", Arch: "aarch64", Format: ".tar.gz"},
},
}
lc, stats := storage.ExportLegacy("hugo", pd)
if stats.Universal != 1 {
t.Errorf("Universal dropped = %d, want 1", stats.Universal)
}
if len(lc.Releases) != 1 {
t.Errorf("releases = %d, want 1 (arm64 only)", len(lc.Releases))
}
if len(lc.Releases) > 0 && lc.Releases[0].Arch != "aarch64" {
t.Errorf("kept arch = %q, want aarch64", lc.Releases[0].Arch)
}
})
t.Run("solaris_dropped", func(t *testing.T) {
// Solaris entries are dropped: Node never served these platforms and the
// classifier produces unfixable mismatches for them.
pd := storage.PackageData{
Assets: []storage.Asset{
{Filename: "go1.20.1.solaris-amd64.tar.gz", OS: "solaris", Arch: "x86_64", Format: ".tar.gz"},
{Filename: "go1.20.1.linux-amd64.tar.gz", OS: "linux", Arch: "x86_64", Format: ".tar.gz"},
},
}
lc, stats := storage.ExportLegacy("go", pd)
if stats.SunOS != 1 {
t.Errorf("SunOS dropped = %d, want 1", stats.SunOS)
}
if len(lc.Releases) != 1 {
t.Errorf("releases = %d, want 1 (linux only)", len(lc.Releases))
}
})
t.Run("illumos_dropped", func(t *testing.T) {
pd := storage.PackageData{
Assets: []storage.Asset{
{Filename: "go1.20.1.illumos-amd64.tar.gz", OS: "illumos", Arch: "x86_64", Format: ".tar.gz"},
},
}
lc, stats := storage.ExportLegacy("go", pd)
if stats.SunOS != 1 {
t.Errorf("SunOS dropped = %d, want 1", stats.SunOS)
}
if len(lc.Releases) != 0 {
t.Errorf("releases = %d, want 0", len(lc.Releases))
}
})
t.Run("android_dropped", func(t *testing.T) {
// Android entries are dropped: the classifier maps android filenames to
// linux OS and then rejects the cache entry that says android.
pd := storage.PackageData{
Assets: []storage.Asset{
{Filename: "fzf-0.57.0-android-arm64.tar.gz", OS: "android", Arch: "aarch64", Format: ".tar.gz"},
{Filename: "fzf-0.57.0-linux-arm64.tar.gz", OS: "linux", Arch: "aarch64", Format: ".tar.gz"},
},
}
lc, stats := storage.ExportLegacy("fzf", pd)
if stats.Android != 1 {
t.Errorf("Android dropped = %d, want 1", stats.Android)
}
if len(lc.Releases) != 1 {
t.Errorf("releases = %d, want 1 (linux only)", len(lc.Releases))
}
})
t.Run("unknown_formats_dropped", func(t *testing.T) {
// .AppImage, .deb, .rpm are not in the Node.js format set.
pd := storage.PackageData{
Assets: []storage.Asset{
{Filename: "tool.AppImage", OS: "linux", Format: ".AppImage"},
{Filename: "tool.deb", OS: "linux", Format: ".deb"},
{Filename: "tool.rpm", OS: "linux", Format: ".rpm"},
{Filename: "tool-linux-amd64.tar.gz", OS: "linux", Arch: "x86_64", Format: ".tar.gz"},
},
}
lc, stats := storage.ExportLegacy("tool", pd)
if stats.Formats != 3 {
t.Errorf("Formats dropped = %d, want 3", stats.Formats)
}
if len(lc.Releases) != 1 {
t.Errorf("releases = %d, want 1 (tar.gz only)", len(lc.Releases))
}
})
t.Run("empty_format_passes_through", func(t *testing.T) {
// Assets with empty format (e.g. bare binaries, git sources) pass through.
pd := storage.PackageData{
Assets: []storage.Asset{
{Filename: "jq-linux-amd64", OS: "linux", Arch: "x86_64", Format: ""},
},
}
lc, stats := storage.ExportLegacy("jq", pd)
if stats.Formats != 0 {
t.Errorf("Formats dropped = %d, want 0", stats.Formats)
}
if len(lc.Releases) != 1 {
t.Errorf("releases = %d, want 1", len(lc.Releases))
}
})
}
// TestExportLegacyTranslations verifies that legacyFieldBackport applies the
// correct field translations for Node.js compatibility.
func TestExportLegacyTranslations(t *testing.T) {
t.Run("ffmpeg_windows_gz_to_exe", func(t *testing.T) {
// ffmpeg Windows releases are .gz archives containing a bare .exe.
// Production releases.js overrides ext to 'exe' for install compatibility.
pd := storage.PackageData{
Assets: []storage.Asset{
{Filename: "ffmpeg-7.0-windows-amd64.gz", OS: "windows", Arch: "x86_64", Format: ".gz"},
{Filename: "ffmpeg-7.0-linux-amd64.tar.gz", OS: "linux", Arch: "x86_64", Format: ".tar.gz"},
},
}
lc, _ := storage.ExportLegacy("ffmpeg", pd)
if len(lc.Releases) != 2 {
t.Fatalf("releases = %d, want 2", len(lc.Releases))
}
var windowsExt string
for _, r := range lc.Releases {
if r.OS == "windows" {
windowsExt = r.Ext
}
}
if windowsExt != ".exe" {
t.Errorf("ffmpeg windows ext = %q, want %q", windowsExt, ".exe")
}
})
t.Run("ffmpeg_translation_not_applied_to_other_packages", func(t *testing.T) {
// The ffmpeg .gz→.exe translation is package-specific.
pd := storage.PackageData{
Assets: []storage.Asset{
{Filename: "othertool-windows-amd64.gz", OS: "windows", Arch: "x86_64", Format: ".gz"},
},
}
lc, _ := storage.ExportLegacy("othertool", pd)
if len(lc.Releases) != 1 {
t.Fatalf("releases = %d, want 1", len(lc.Releases))
}
if lc.Releases[0].Ext != ".gz" {
t.Errorf("ext = %q, want %q (no translation outside ffmpeg)", lc.Releases[0].Ext, ".gz")
}
})
// ARM arch translations: the Node build-classifier re-parses filenames and
// rejects cache entries where the arch field doesn't match what it extracts.
// Go normalizes ARM filenames to canonical arch values, but the Node classifier
// preserves the original Debian/Rust naming. These translations undo Go's
// normalization for the legacy cache only.
t.Run("arm_gnueabihf_to_armhf", func(t *testing.T) {
// Go: gnueabihf → armv6 (Rust soft-float ABI name)
// Node classifier: gnueabihf → armhf
pd := storage.PackageData{
Assets: []storage.Asset{
{Filename: "bat-v0.9.0-arm-unknown-linux-gnueabihf.tar.gz", OS: "linux", Arch: "armv6", Format: ".tar.gz"},
},
}
lc, _ := storage.ExportLegacy("bat", pd)
if len(lc.Releases) != 1 {
t.Fatalf("releases = %d, want 1", len(lc.Releases))
}
if lc.Releases[0].Arch != "armhf" {
t.Errorf("arch = %q, want armhf", lc.Releases[0].Arch)
}
})
t.Run("arm_armhf_filename_to_armhf", func(t *testing.T) {
// Go: armhf → armv7 (Debian armhf = ARMv7 hard-float)
// Node classifier: armhf → armhf
pd := storage.PackageData{
Assets: []storage.Asset{
{Filename: "caddy_linux_armhf.tar.gz", OS: "linux", Arch: "armv7", Format: ".tar.gz"},
},
}
lc, _ := storage.ExportLegacy("caddy", pd)
if len(lc.Releases) != 1 {
t.Fatalf("releases = %d, want 1", len(lc.Releases))
}
if lc.Releases[0].Arch != "armhf" {
t.Errorf("arch = %q, want armhf", lc.Releases[0].Arch)
}
})
t.Run("arm_armel_to_armel", func(t *testing.T) {
// Go: armel → armv6 (Debian soft-float ABI name)
// Node classifier: armel → armel
pd := storage.PackageData{
Assets: []storage.Asset{
{Filename: "caddy_linux_armel.tar.gz", OS: "linux", Arch: "armv6", Format: ".tar.gz"},
},
}
lc, _ := storage.ExportLegacy("caddy", pd)
if len(lc.Releases) != 1 {
t.Fatalf("releases = %d, want 1", len(lc.Releases))
}
if lc.Releases[0].Arch != "armel" {
t.Errorf("arch = %q, want armel", lc.Releases[0].Arch)
}
})
t.Run("arm_armv5_to_armel", func(t *testing.T) {
// Go: armv5 → armv5
// Node classifier tiered map: armv5 falls back to armel
pd := storage.PackageData{
Assets: []storage.Asset{
{Filename: "caddy_linux_armv5.tar.gz", OS: "linux", Arch: "armv5", Format: ".tar.gz"},
},
}
lc, _ := storage.ExportLegacy("caddy", pd)
if len(lc.Releases) != 1 {
t.Fatalf("releases = %d, want 1", len(lc.Releases))
}
if lc.Releases[0].Arch != "armel" {
t.Errorf("arch = %q, want armel", lc.Releases[0].Arch)
}
})
t.Run("arm_armv7a_to_armv7a", func(t *testing.T) {
// Go: armv7a → armv7 (Go normalizes armv7a to armv7)
// Node classifier: armv7a → armv7a (preserves the -a suffix)
pd := storage.PackageData{
Assets: []storage.Asset{
{Filename: "tool-armv7a-linux.tar.gz", OS: "linux", Arch: "armv7", Format: ".tar.gz"},
},
}
lc, _ := storage.ExportLegacy("tool", pd)
if len(lc.Releases) != 1 {
t.Fatalf("releases = %d, want 1", len(lc.Releases))
}
if lc.Releases[0].Arch != "armv7a" {
t.Errorf("arch = %q, want armv7a", lc.Releases[0].Arch)
}
})
t.Run("arm_armv7l_unchanged", func(t *testing.T) {
// armv7l in filename: Go=armv7, Node classifier also extracts armv7. No translation.
pd := storage.PackageData{
Assets: []storage.Asset{
{Filename: "tool-armv7l-linux.tar.gz", OS: "linux", Arch: "armv7", Format: ".tar.gz"},
},
}
lc, _ := storage.ExportLegacy("tool", pd)
if len(lc.Releases) != 1 {
t.Fatalf("releases = %d, want 1", len(lc.Releases))
}
if lc.Releases[0].Arch != "armv7" {
t.Errorf("arch = %q, want armv7 (no translation for armv7l)", lc.Releases[0].Arch)
}
})
t.Run("arm_armv6l_unchanged", func(t *testing.T) {
// armv6l in filename: Go=armv6, Node classifier also extracts armv6. No translation.
pd := storage.PackageData{
Assets: []storage.Asset{
{Filename: "tool-armv6l-linux.tar.gz", OS: "linux", Arch: "armv6", Format: ".tar.gz"},
},
}
lc, _ := storage.ExportLegacy("tool", pd)
if len(lc.Releases) != 1 {
t.Fatalf("releases = %d, want 1", len(lc.Releases))
}
if lc.Releases[0].Arch != "armv6" {
t.Errorf("arch = %q, want armv6 (no translation for armv6l)", lc.Releases[0].Arch)
}
})
}
// TestExportLegacyMixed verifies correct counting when multiple drop categories
// appear together in a single export call.
func TestExportLegacyMixed(t *testing.T) {
pd := storage.PackageData{
Assets: []storage.Asset{
// kept: baseline linux build
{Filename: "tool-linux-amd64.tar.gz", OS: "linux", Arch: "x86_64", Format: ".tar.gz"},
// dropped: variant build
{Filename: "tool-linux-amd64-rocm.tar.gz", OS: "linux", Arch: "x86_64", Format: ".tar.gz", Variants: []string{"rocm"}},
// dropped: universal2
{Filename: "tool-darwin-universal.tar.gz", OS: "darwin", Arch: "universal2", Format: ".tar.gz"},
// dropped: illumos
{Filename: "tool-illumos-amd64.tar.gz", OS: "illumos", Arch: "x86_64", Format: ".tar.gz"},
// dropped: android
{Filename: "tool-android-arm64.tar.gz", OS: "android", Arch: "aarch64", Format: ".tar.gz"},
// dropped: .AppImage format
{Filename: "tool.AppImage", OS: "linux", Format: ".AppImage"},
},
}
lc, stats := storage.ExportLegacy("tool", pd)
if stats.Variants != 1 {
t.Errorf("Variants = %d, want 1", stats.Variants)
}
if stats.Universal != 1 {
t.Errorf("Universal = %d, want 1", stats.Universal)
}
if stats.SunOS != 1 {
t.Errorf("SunOS = %d, want 1", stats.SunOS)
}
if stats.Android != 1 {
t.Errorf("Android = %d, want 1", stats.Android)
}
if stats.Formats != 1 {
t.Errorf("Formats = %d, want 1", stats.Formats)
}
if len(lc.Releases) != 1 {
t.Errorf("releases = %d, want 1 (linux only)", len(lc.Releases))
}
}