Compare commits

..

10 Commits

Author SHA1 Message Date
AJ ONeal
4d1fc7bb62 feat(classifypkg): add arch_map/os_map conf keys; use them for ffmpeg
Adds two new releases.conf keys:
  arch_map = x64:x86_64 ia32:x86   (upstream:canonical pairs)
  os_map   = win32:windows

The generic GitHub classifier now applies these after classify.Filename
so packages with non-standard naming don't need a custom classifier.

Also adds a .gz (single-file gunzip) branch to package-install.tpl.sh
so bare-binary-plus-.gz release patterns are fully handled.

Removes the ffmpegdist custom classifier — ffmpeg/releases.conf now
uses source = github with arch_map + os_map + version_prefix = b.
2026-05-16 20:23:29 -06:00
AJ ONeal
571a93cf06 refactor(webicached): defer pgstore and --pg flag to its own PR
pgstore (PostgreSQL backend) is not yet used in production. Move it to a
separate branch/PR so ref-webi-go stays focused on the caching daemon
itself. Removes pgx/v5 and its transitive deps from go.mod entirely.
2026-05-14 18:12:42 -06:00
AJ ONeal
e0e4d558f1 chore(deps): go mod tidy — mark direct deps, drop phantom indirects 2026-05-14 18:10:18 -06:00
AJ ONeal
f2994e5756 chore(deps): upgrade pgx v5.8.0 → v5.9.2 2026-05-14 18:09:04 -06:00
AJ ONeal
b8f5a61121 fix(webicached): use full gittag fetch for first-time supplementary clones
When a package has a git_url but uses a non-gittag source, the
supplementary git clone was always shallow. For packages never cloned
before, a shallow clone may miss older tags that clients need.

Now: check whether the _gittag raw cache is already populated. If it is,
reuse the shallow flag (fast refresh). If it is not, force a full clone
so all tags are available from the first fetch.

The --shallow flag (global) still overrides this so operators can cap
fetch depth when needed.
2026-05-14 18:01:17 -06:00
AJ ONeal
2390afa006 feat(webicached): prioritize new packages by rescanning mid-batch and breaking immediately 2026-05-14 18:00:46 -06:00
AJ ONeal
b197ca4a2a feat(webicached): rescan conf dir each batch to pick up new releases.conf without restart 2026-05-14 18:00:46 -06:00
AJ ONeal
a07d59a01a feat(ffmpeg): custom ffmpegdist classifier for non-standard release names
eugeneware/ffmpeg-static uses non-standard filenames (x64, ia32, win32,
arm) and ships both bare binaries and .gz variants. The generic classifier
mishandles the arch mapping and the installer has no bare .gz handler.

Add ffmpegdist custom classifier that:
- Maps non-standard OS/arch names to canonical values
- Filters to bare binaries only (skips .gz, ffprobe, LICENSE, README)
- Strips 'b' version prefix from tags

Also fix installerconf to allow explicit 'source' to override the
inferred source when both are present (e.g. source=ffmpegdist +
github_releases for fetching).

Closes #947
2026-05-14 17:57:21 -06:00
AJ ONeal
b38a4b7894 docs: add deploy scripts, skills, and pattern guides
Deploy scripts for webicached and webid (build, upload, restart).
AGENTS.md with releases.conf reference and variant tagging docs.
Installer archive pattern guide and version oddities reference.
2026-05-14 17:57:20 -06:00
AJ ONeal
f73755e7d7 feat: add Go release cache daemon (webicached) and HTTP API server (webid)
Rewrites the Node.js release classification pipeline in Go. webicached
fetches upstream releases, classifies assets, and writes legacy-format
JSON caches. webid serves the HTTP API (releases, resolve, bootstrap
scripts). Git-clone packages emit git_tag and git_commit_hash from
real repo clones — no fabricated refs.
2026-05-14 17:57:20 -06:00
22 changed files with 3150 additions and 106 deletions

View File

@@ -621,15 +621,13 @@ BuildsCacher.create = function ({ ALL_TERMS, installers }) {
let arches = waterfall[hostTarget.arch] ||
HostTargets.WATERFALL.ANYOS[hostTarget.arch] || [hostTarget.arch];
arches = arches.concat(['ANYARCH']);
// termsToTarget omits libc for plain UAs; 'libc' → waterfall ['none','libc',...]
let libc = hostTarget.libc || 'libc';
let libcs = waterfall[libc] ||
HostTargets.WATERFALL.ANYOS[libc] || [libc];
let libcs = waterfall[hostTarget.libc] ||
HostTargets.WATERFALL.ANYOS[hostTarget.libc] || [hostTarget.libc];
// Extend the glibc-host waterfall: the table only lists [none, libc]
// but Rust projects (bat, rg) and node ship libc='gnu' builds, and
// static musl builds also run on glibc hosts.
if (libc === 'libc' && !libcs.includes('gnu')) {
if (hostTarget.libc === 'libc' && !libcs.includes('gnu')) {
libcs = ['none', 'gnu', 'musl', 'libc'];
}

View File

@@ -226,6 +226,9 @@ __bootstrap_webi() {
elif test "$WEBI_EXT" = "git"; then
echo " Moving $(t_path "${my_dl_rel}")"
mv "${WEBI_PKG_PATH}/$WEBI_PKG_FILE" .
elif test "$WEBI_EXT" = "gz"; then
echo " Inflating $(t_path "${my_dl_rel}")"
gunzip -c "${WEBI_PKG_PATH}/$WEBI_PKG_FILE" > "$(basename "$WEBI_PKG_FILE" .gz)"
elif test "$WEBI_EXT" = "xz"; then
echo " Inflating $(t_path "${my_dl_rel}")"
unxz -c "${WEBI_PKG_PATH}/$WEBI_PKG_FILE" > "$(basename "$WEBI_PKG_FILE")"

View File

@@ -38,8 +38,9 @@ function getOs(ua) {
// It's the year of the Linux Desktop!
// See also http://www.mslinux.org/
// 'linux' must be tested before 'Microsoft' because WSL
// (TODO: does this affect cygwin / msysgit?)
return 'linux';
} else if (/^ms$|Microsoft|Windows|win32|win|PowerShell|CYGWIN|MINGW/i.test(ua)) {
} else if (/^ms$|Microsoft|Windows|win32|win|PowerShell/i.test(ua)) {
// 'win' must be tested after 'darwin'
return 'windows';
} else if (/Linux|curl|wget/i.test(ua)) {

View File

@@ -35,7 +35,6 @@ import (
"github.com/joho/godotenv"
"github.com/webinstall/webi-installers/internal/classifypkg"
"github.com/webinstall/webi-installers/internal/httpclient"
"github.com/webinstall/webi-installers/internal/installerconf"
"github.com/webinstall/webi-installers/internal/rawcache"
"github.com/webinstall/webi-installers/internal/releases/chromedist"
@@ -78,6 +77,7 @@ type MainConfig struct {
envFile string
confDir string
cacheDir string
rawDir string
token string
once bool
@@ -167,10 +167,10 @@ func main() {
auth = &githubish.Auth{Token: cfg.token}
}
client := httpclient.New()
client := &http.Client{Timeout: 30 * time.Second}
if cfg.pageDelay > 0 {
client.Transport = &delayTransport{
base: client.Transport,
base: http.DefaultTransport,
delay: cfg.pageDelay,
}
}
@@ -298,6 +298,7 @@ func registerFlags(fs *flag.FlagSet, cfg *MainConfig) {
fs.StringVar(&cfg.envFile, "envfile", "", "path to .env file to load before running")
fs.StringVar(&cfg.confDir, "conf", ".", "root directory containing {pkg}/releases.conf files")
fs.StringVar(&cfg.cacheDir, "legacy", "~/.cache/webi/legacy", "legacy cache directory (fsstore root)")
fs.StringVar(&cfg.rawDir, "raw", "~/.cache/webi/raw", "raw cache directory for upstream responses")
fs.StringVar(&cfg.token, "token", "", "GitHub API token (or set $GITHUB_TOKEN)")
fs.BoolVar(&cfg.once, "once", false, "run once then exit (no periodic refresh)")
@@ -332,13 +333,14 @@ func (wc *WebiCache) stalest(packages []pkgConf) []pkgConf {
for _, pkg := range packages {
data, err := wc.Store.Load(ctx, pkg.name)
var t time.Time
hasAssets := false
if err == nil && data != nil {
t = data.UpdatedAt
hasAssets = len(data.Assets) > 0
}
// Never fetched, or older than 10 minutes.
// 0-asset results are not treated as perpetually stale — packages that
// produce no classifiable assets (e.g. galera) respect the timestamp.
if t.IsZero() || time.Since(t) > 10*time.Minute {
// Never fetched, or has no assets despite having a timestamp
// (e.g. classified from empty rawcache), or older than 10 minutes.
if t.IsZero() || !hasAssets || time.Since(t) > 10*time.Minute {
stale = append(stale, stamped{pkg: pkg, updatedAt: t})
}
}

View File

@@ -1,4 +1,6 @@
source = ffmpegdist
source = github
github_releases = eugeneware/ffmpeg-static
asset_filter = ffmpeg
version_prefix = b
arch_map = x64:x86_64 ia32:x86 arm64:aarch64 arm:armv7
os_map = win32:windows

View File

@@ -166,8 +166,6 @@ func classifySource(pkg string, conf *installerconf.Conf, d *rawcache.Dir) ([]st
return classifyMariaDBDist(d)
case "zigdist":
return classifyZigDist(d)
case "ffmpegdist":
return classifyFFmpegDist(d)
default:
return nil, nil
}
@@ -428,6 +426,16 @@ func classifyGitHub(pkg string, conf *installerconf.Conf, d *rawcache.Dir) ([]st
r := classify.Filename(a.Name)
// Apply conf-level OS/arch overrides for non-standard naming.
osStr := string(r.OS)
if mapped, ok := conf.OSMap[osStr]; ok {
osStr = mapped
}
archStr := string(r.Arch)
if mapped, ok := conf.ArchMap[archStr]; ok {
archStr = mapped
}
// Normalize .tgz → .tar.gz in the display filename.
// The download URL still points to the real file.
name := a.Name
@@ -441,7 +449,7 @@ func classifyGitHub(pkg string, conf *installerconf.Conf, d *rawcache.Dir) ([]st
libc = buildmeta.LibcNone
}
// Windows gnu (MinGW) is self-contained — no runtime deps.
if r.OS == buildmeta.OSWindows && libc == buildmeta.LibcGNU {
if buildmeta.OS(osStr) == buildmeta.OSWindows && libc == buildmeta.LibcGNU {
libc = buildmeta.LibcNone
}
@@ -449,8 +457,8 @@ func classifyGitHub(pkg string, conf *installerconf.Conf, d *rawcache.Dir) ([]st
Filename: name,
Version: version,
Channel: channel,
OS: string(r.OS),
Arch: string(r.Arch),
OS: osStr,
Arch: archStr,
Libc: string(libc),
Format: string(r.Format),
Download: a.BrowserDownloadURL,
@@ -466,86 +474,6 @@ func classifyGitHub(pkg string, conf *installerconf.Conf, d *rawcache.Dir) ([]st
return assets, nil
}
var ffmpegOSMap = map[string]string{
"linux": "linux",
"darwin": "darwin",
"win32": "windows",
}
var ffmpegArchMap = map[string]string{
"x64": "x86_64",
"ia32": "x86",
"arm64": "aarch64",
"arm": "armv7",
}
// classifyFFmpegDist handles eugeneware/ffmpeg-static releases.
// Upstream uses non-standard names (x64, ia32, win32, arm) and ships both
// bare binaries and .gz-compressed copies. Only bare binaries are kept —
// the install template has no handler for single-file .gz extraction.
func classifyFFmpegDist(d *rawcache.Dir) ([]storage.Asset, error) {
releases, err := ReadAllRaw(d)
if err != nil {
return nil, err
}
var assets []storage.Asset
for _, data := range releases {
var rel ghRelease
if err := json.Unmarshal(data, &rel); err != nil {
continue
}
if rel.Draft {
continue
}
version := strings.TrimPrefix(rel.TagName, "b")
channel := "stable"
if rel.Prerelease {
channel = "beta"
}
date := ""
if len(rel.PublishedAt) >= 10 {
date = rel.PublishedAt[:10]
}
for _, a := range rel.Assets {
if strings.Contains(a.Name, ".") {
continue
}
if !strings.HasPrefix(a.Name, "ffmpeg-") {
continue
}
parts := strings.SplitN(a.Name, "-", 3)
if len(parts) != 3 {
continue
}
os, osOK := ffmpegOSMap[parts[1]]
arch, archOK := ffmpegArchMap[parts[2]]
if !osOK || !archOK {
continue
}
assets = append(assets, storage.Asset{
Filename: a.Name,
Version: version,
Channel: channel,
OS: os,
Arch: arch,
Format: "",
Download: a.BrowserDownloadURL,
Date: date,
})
}
}
return assets, nil
}
// classifyServiceman handles serviceman's dual-repo layout: binary releases
// from therootcompany/serviceman (≤v0.8.x) and source-only releases from
// bnnanet/serviceman (v0.9.x+). Emits binary assets where available, plus

View File

@@ -96,6 +96,16 @@ type Conf struct {
// kubectx/kubens) to select only the relevant assets.
AssetFilter string
// ArchMap translates non-standard arch strings in asset filenames to
// canonical webi arch names. Format: "upstream:canonical" pairs,
// whitespace-delimited. Example: "x64:x86_64 ia32:x86 arm:armv7"
ArchMap map[string]string
// OSMap translates non-standard OS strings in asset filenames to
// canonical webi OS names. Format: "upstream:canonical" pairs,
// whitespace-delimited. Example: "win32:windows"
OSMap map[string]string
// Variants documents known build variant names for this package.
// Whitespace-delimited. This is a human-readable cue — actual
// variant detection logic lives in Go code per-package.
@@ -243,6 +253,14 @@ func Read(path string) (*Conf, error) {
}
c.AssetFilter = raw["asset_filter"]
if v := raw["arch_map"]; v != "" {
c.ArchMap = parseKVMap(v)
}
if v := raw["os_map"]; v != "" {
c.OSMap = parseKVMap(v)
}
c.OS = raw["os"]
c.AliasOf = raw["alias_of"]
@@ -269,6 +287,8 @@ func Read(path string) (*Conf, error) {
"exclude": true,
"asset_exclude": true,
"asset_filter": true,
"arch_map": true,
"os_map": true,
"os": true,
"variants": true,
"alias_of": true,
@@ -284,3 +304,15 @@ func Read(path string) (*Conf, error) {
return c, nil
}
// parseKVMap parses whitespace-delimited "key:value" pairs into a map.
func parseKVMap(s string) map[string]string {
m := make(map[string]string)
for _, pair := range strings.Fields(s) {
k, v, ok := strings.Cut(pair, ":")
if ok {
m[k] = v
}
}
return m
}

View File

@@ -0,0 +1,104 @@
// Package platlatest tracks the newest release version per build target.
//
// After classification determines which OS/arch/libc targets a release
// covers, this package records the latest version for each target. This
// handles the common case where Windows or macOS releases lag behind
// Linux by several versions.
//
// Storage is a single JSON file per package:
//
// {
// "linux-x86_64-gnu": "v0.145.0",
// "darwin-aarch64-none": "v0.144.1",
// "windows-x86_64-msvc": "v0.143.0"
// }
package platlatest
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"github.com/webinstall/webi-installers/internal/buildmeta"
)
// Index tracks the latest version for each build target of a package.
type Index struct {
mu sync.RWMutex
path string
m map[string]string // triplet → version
}
// Open loads or creates a per-platform latest index at the given path.
func Open(path string) (*Index, error) {
idx := &Index{
path: path,
m: make(map[string]string),
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return idx, nil
}
return nil, fmt.Errorf("platlatest: read %s: %w", path, err)
}
if err := json.Unmarshal(data, &idx.m); err != nil {
return nil, fmt.Errorf("platlatest: parse %s: %w", path, err)
}
return idx, nil
}
// Get returns the latest version for a target, or "" if unknown.
func (idx *Index) Get(t buildmeta.Target) string {
idx.mu.RLock()
defer idx.mu.RUnlock()
return idx.m[t.Triplet()]
}
// Set records a version as the latest for a target. Does not persist
// to disk — call Save after all updates.
func (idx *Index) Set(t buildmeta.Target, version string) {
idx.mu.Lock()
defer idx.mu.Unlock()
idx.m[t.Triplet()] = version
}
// All returns a copy of the full triplet→version map.
func (idx *Index) All() map[string]string {
idx.mu.RLock()
defer idx.mu.RUnlock()
out := make(map[string]string, len(idx.m))
for k, v := range idx.m {
out[k] = v
}
return out
}
// Save persists the index to disk (atomic write).
func (idx *Index) Save() error {
idx.mu.RLock()
data, err := json.MarshalIndent(idx.m, "", " ")
idx.mu.RUnlock()
if err != nil {
return fmt.Errorf("platlatest: marshal: %w", err)
}
dir := filepath.Dir(idx.path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("platlatest: mkdir: %w", err)
}
tmp := idx.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return fmt.Errorf("platlatest: write %s: %w", tmp, err)
}
if err := os.Rename(tmp, idx.path); err != nil {
os.Remove(tmp)
return fmt.Errorf("platlatest: rename %s: %w", idx.path, err)
}
return nil
}

View File

@@ -0,0 +1,104 @@
package platlatest_test
import (
"path/filepath"
"testing"
"github.com/webinstall/webi-installers/internal/buildmeta"
"github.com/webinstall/webi-installers/internal/platlatest"
)
var (
linuxAMD64 = buildmeta.Target{
OS: buildmeta.OSLinux, Arch: buildmeta.ArchAMD64, Libc: buildmeta.LibcGNU,
}
darwinARM64 = buildmeta.Target{
OS: buildmeta.OSDarwin, Arch: buildmeta.ArchARM64, Libc: buildmeta.LibcNone,
}
windowsAMD64 = buildmeta.Target{
OS: buildmeta.OSWindows, Arch: buildmeta.ArchAMD64, Libc: buildmeta.LibcMSVC,
}
)
func TestSetAndGet(t *testing.T) {
p := filepath.Join(t.TempDir(), "latest.json")
idx, err := platlatest.Open(p)
if err != nil {
t.Fatal(err)
}
if got := idx.Get(linuxAMD64); got != "" {
t.Errorf("Get before Set = %q, want empty", got)
}
idx.Set(linuxAMD64, "v0.145.0")
idx.Set(darwinARM64, "v0.144.1")
idx.Set(windowsAMD64, "v0.143.0")
if got := idx.Get(linuxAMD64); got != "v0.145.0" {
t.Errorf("linux = %q, want v0.145.0", got)
}
if got := idx.Get(darwinARM64); got != "v0.144.1" {
t.Errorf("darwin = %q, want v0.144.1", got)
}
if got := idx.Get(windowsAMD64); got != "v0.143.0" {
t.Errorf("windows = %q, want v0.143.0", got)
}
}
func TestSaveAndReload(t *testing.T) {
p := filepath.Join(t.TempDir(), "latest.json")
idx1, err := platlatest.Open(p)
if err != nil {
t.Fatal(err)
}
idx1.Set(linuxAMD64, "v0.145.0")
idx1.Set(darwinARM64, "v0.144.1")
if err := idx1.Save(); err != nil {
t.Fatal(err)
}
// Reload from disk.
idx2, err := platlatest.Open(p)
if err != nil {
t.Fatal(err)
}
if got := idx2.Get(linuxAMD64); got != "v0.145.0" {
t.Errorf("after reload: linux = %q, want v0.145.0", got)
}
if got := idx2.Get(darwinARM64); got != "v0.144.1" {
t.Errorf("after reload: darwin = %q, want v0.144.1", got)
}
}
func TestAll(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")
idx.Set(darwinARM64, "v0.9.0")
all := idx.All()
if len(all) != 2 {
t.Fatalf("All() returned %d entries, want 2", len(all))
}
if all[linuxAMD64.Triplet()] != "v1.0.0" {
t.Error("missing linux entry")
}
}
func TestOpenNonexistent(t *testing.T) {
p := filepath.Join(t.TempDir(), "does-not-exist.json")
idx, err := platlatest.Open(p)
if err != nil {
t.Fatal(err)
}
// Should be empty, not nil.
if all := idx.All(); len(all) != 0 {
t.Errorf("new index should be empty, got %v", all)
}
}

264
internal/render/render.go Normal file
View File

@@ -0,0 +1,264 @@
// Package render generates installer scripts by injecting release
// metadata into the package-install template.
//
// The template uses shell-style variable markers:
//
// #WEBI_VERSION= → WEBI_VERSION='1.2.3'
// #export WEBI_PKG_URL= → export WEBI_PKG_URL='https://...'
//
// The package's install.sh is injected at the {{ installer }} marker.
package render
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
// Params holds all the values to inject into the installer template.
type Params struct {
// Host is the base URL of the webi server (e.g. "https://webinstall.dev").
Host string
// Checksum is the webi.sh bootstrap script checksum (first 8 hex chars of SHA-1).
Checksum string
// Package name (e.g. "bat", "node").
PkgName string
// Tag is the version selector from the URL (e.g. "20", "stable", "").
Tag string
// OS, Arch, Libc are the detected platform strings.
OS string
Arch string
Libc string
// Resolved release info.
Version string
Major string
Minor string
Patch string
Build string
GitTag string
GitBranch string
LTS string // "true" or "false"
Channel string
Ext string // archive extension (e.g. "tar.gz", "zip")
Formats string // comma-separated format list
// Download info.
PkgURL string // download URL
PkgFile string // filename
// Releases API URL for this request.
ReleasesURL string
// CSV line for WEBI_CSV.
CSV string
// Package catalog info.
PkgStable string
PkgLatest string
PkgOSes string // space-separated
PkgArches string // space-separated
PkgLibcs string // space-separated
PkgFormats string // space-separated
}
// Bash renders a complete bash installer script by injecting params
// into the template and splicing in the package's install.sh.
func Bash(tplPath, installersDir, pkgName string, p Params) (string, error) {
tpl, err := os.ReadFile(tplPath)
if err != nil {
return "", fmt.Errorf("render: read template: %w", err)
}
// Read the package's install.sh.
installPath := filepath.Join(installersDir, pkgName, "install.sh")
installSh, err := os.ReadFile(installPath)
if err != nil {
return "", fmt.Errorf("render: read %s/install.sh: %w", pkgName, err)
}
text := string(tpl)
// Inject environment variables.
vars := []struct {
name string
value string
}{
{"WEBI_CHECKSUM", p.Checksum},
{"WEBI_PKG", p.PkgName + "@" + p.Tag},
{"WEBI_HOST", p.Host},
{"WEBI_OS", p.OS},
{"WEBI_ARCH", p.Arch},
{"WEBI_LIBC", p.Libc},
{"WEBI_TAG", p.Tag},
{"WEBI_RELEASES", p.ReleasesURL},
{"WEBI_CSV", p.CSV},
{"WEBI_VERSION", p.Version},
{"WEBI_MAJOR", p.Major},
{"WEBI_MINOR", p.Minor},
{"WEBI_PATCH", p.Patch},
{"WEBI_BUILD", p.Build},
{"WEBI_GIT_BRANCH", p.GitBranch},
{"WEBI_GIT_TAG", p.GitTag},
{"WEBI_LTS", p.LTS},
{"WEBI_CHANNEL", p.Channel},
{"WEBI_EXT", p.Ext},
{"WEBI_FORMATS", p.Formats},
{"WEBI_PKG_URL", p.PkgURL},
{"WEBI_PKG_PATHNAME", p.PkgFile},
{"WEBI_PKG_FILE", p.PkgFile},
{"PKG_NAME", p.PkgName},
{"PKG_STABLE", p.PkgStable},
{"PKG_LATEST", p.PkgLatest},
{"PKG_OSES", p.PkgOSes},
{"PKG_ARCHES", p.PkgArches},
{"PKG_LIBCS", p.PkgLibcs},
{"PKG_FORMATS", p.PkgFormats},
}
for _, v := range vars {
text = InjectVar(text, v.name, v.value)
}
// Inject the installer script at the {{ installer }} marker.
// The marker sits inside __init_installer() at 8-space indent.
// Production pads every line of install.sh to match, and replaces
// the entire line (including leading whitespace).
padded := padScript(string(installSh), " ")
text = replaceMarkerLine(text, "{{ installer }}", padded)
return text, nil
}
// PowerShell renders a complete PowerShell installer script by injecting
// params into the template and splicing in the package's install.ps1.
func PowerShell(tplPath, installersDir, pkgName string, p Params) (string, error) {
tpl, err := os.ReadFile(tplPath)
if err != nil {
return "", fmt.Errorf("render: read template: %w", err)
}
installPath := filepath.Join(installersDir, pkgName, "install.ps1")
installPs1, err := os.ReadFile(installPath)
if err != nil {
return "", fmt.Errorf("render: read %s/install.ps1: %w", pkgName, err)
}
text := string(tpl)
vars := []struct {
name string
value string
}{
{"WEBI_PKG", p.PkgName + "@" + p.Tag},
{"WEBI_HOST", p.Host},
{"WEBI_VERSION", p.Version},
{"WEBI_GIT_TAG", p.GitTag},
{"WEBI_PKG_URL", p.PkgURL},
{"WEBI_PKG_FILE", p.PkgFile},
{"WEBI_PKG_PATHNAME", p.PkgFile},
{"PKG_NAME", p.PkgName},
}
for _, v := range vars {
text = InjectPSVar(text, v.name, v.value)
}
// PS1 marker is at column 0, no padding needed.
text = replaceMarkerLine(text, "{{ installer }}", string(installPs1))
return text, nil
}
// InjectPSVar replaces a PowerShell template variable line with its value.
// Matches lines like:
//
// #$Env:WEBI_VERSION = v12.16.2
// $Env:WEBI_HOST = 'https://webinstall.dev'
func InjectPSVar(text, name, value string) string {
p := getPSVarPattern(name)
return p.ReplaceAllString(text, "${1}$$Env:"+name+" = '"+sanitizePSValue(value)+"'")
}
var psVarPatterns = map[string]*regexp.Regexp{}
func getPSVarPattern(name string) *regexp.Regexp {
if p, ok := psVarPatterns[name]; ok {
return p
}
// Match: optional leading whitespace, optional #, $Env:NAME, =, rest of line
p := regexp.MustCompile(`(?m)^([ \t]*)#?\$Env:` + regexp.QuoteMeta(name) + `\s*=.*$`)
psVarPatterns[name] = p
return p
}
// sanitizePSValue escapes single quotes for PowerShell single-quoted strings.
// In PowerShell, single quotes inside single-quoted strings are doubled: ''
func sanitizePSValue(s string) string {
return strings.ReplaceAll(s, "'", "''")
}
// varPattern matches shell variable declarations in the template.
// Matches lines like:
//
// #WEBI_VERSION=
// #export WEBI_PKG_URL=
// #WEBI_OS=
var varPatterns = map[string]*regexp.Regexp{}
func getVarPattern(name string) *regexp.Regexp {
if p, ok := varPatterns[name]; ok {
return p
}
// Match: optional leading whitespace, optional #, optional export, the var name, =, rest of line
p := regexp.MustCompile(`(?m)^([ \t]*)#?([ \t]*)(export[ \t]+)?[ \t]*(` + regexp.QuoteMeta(name) + `)=.*$`)
varPatterns[name] = p
return p
}
// InjectVar replaces a template variable line with its value.
// It matches lines like:
//
// #WEBI_VERSION=
// #export WEBI_PKG_URL=
// export WEBI_HOST=
//
// and replaces them with the value in single quotes.
func InjectVar(text, name, value string) string {
p := getVarPattern(name)
return p.ReplaceAllString(text, "${1}${3}"+name+"='"+sanitizeShellValue(value)+"'")
}
// sanitizeShellValue ensures a value is safe to embed in single quotes.
// Single quotes in shell can't be escaped inside single quotes, so we
// close-quote, add escaped quote, re-open quote: 'foo'\''bar'
func sanitizeShellValue(s string) string {
return strings.ReplaceAll(s, "'", `'\''`)
}
// padScript prepends each line of a script with the given indent string.
// This matches production behavior where install.sh content is indented
// to align with the surrounding template code.
func padScript(script, indent string) string {
lines := strings.Split(script, "\n")
for i, line := range lines {
if line != "" {
lines[i] = indent + line
}
}
return strings.Join(lines, "\n")
}
// replaceMarkerLine replaces an entire line containing the marker
// (including any leading whitespace) with the replacement text.
// This matches production's regex: /\s*#?\s*{{ installer }}/
func replaceMarkerLine(text, marker, replacement string) string {
re := regexp.MustCompile(`(?m)^[ \t]*#?[ \t]*` + regexp.QuoteMeta(marker) + `[^\n]*`)
return re.ReplaceAllLiteralString(text, replacement)
}

View File

@@ -0,0 +1,90 @@
package render
import (
"strings"
"testing"
)
func TestInjectVar(t *testing.T) {
tests := []struct {
name string
input string
key string
value string
want string
}{
{
name: "commented var",
input: " #WEBI_VERSION=",
key: "WEBI_VERSION",
value: "1.2.3",
want: " WEBI_VERSION='1.2.3'",
},
{
name: "commented export var",
input: " #export WEBI_PKG_URL=",
key: "WEBI_PKG_URL",
value: "https://example.com/foo.tar.gz",
want: " export WEBI_PKG_URL='https://example.com/foo.tar.gz'",
},
{
name: "existing value replaced",
input: " export WEBI_HOST=",
key: "WEBI_HOST",
value: "https://webinstall.dev",
want: " export WEBI_HOST='https://webinstall.dev'",
},
{
name: "value with single quotes",
input: " #PKG_NAME=",
key: "PKG_NAME",
value: "it's-a-test",
want: " PKG_NAME='it'\\''s-a-test'",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := InjectVar(tt.input, tt.key, tt.value)
if strings.TrimSpace(got) != strings.TrimSpace(tt.want) {
t.Errorf("got %q\nwant %q", got, tt.want)
}
})
}
}
func TestInjectVarInTemplate(t *testing.T) {
tpl := `#!/bin/sh
__bootstrap_webi() {
#PKG_NAME=
#WEBI_OS=
#WEBI_ARCH=
#WEBI_VERSION=
export WEBI_HOST=
WEBI_PKG_DOWNLOAD=""
`
result := tpl
result = InjectVar(result, "PKG_NAME", "bat")
result = InjectVar(result, "WEBI_OS", "linux")
result = InjectVar(result, "WEBI_ARCH", "x86_64")
result = InjectVar(result, "WEBI_VERSION", "0.26.1")
result = InjectVar(result, "WEBI_HOST", "https://webinstall.dev")
if !strings.Contains(result, "PKG_NAME='bat'") {
t.Error("PKG_NAME not injected")
}
if !strings.Contains(result, "WEBI_OS='linux'") {
t.Error("WEBI_OS not injected")
}
if !strings.Contains(result, "WEBI_VERSION='0.26.1'") {
t.Error("WEBI_VERSION not injected")
}
if !strings.Contains(result, "export WEBI_HOST='https://webinstall.dev'") {
t.Error("WEBI_HOST not injected")
}
// Should not have #PKG_NAME= anymore.
if strings.Contains(result, "#PKG_NAME=") {
t.Error("#PKG_NAME= should have been replaced")
}
}

303
internal/resolve/resolve.go Normal file
View File

@@ -0,0 +1,303 @@
// Package resolve picks the best release for a given platform query.
//
// Given a set of classified distributables and a target query (OS, arch,
// libc, format preferences, version constraint), it returns the single
// best matching release — or nil if nothing matches.
package resolve
import (
"strings"
"github.com/webinstall/webi-installers/internal/buildmeta"
"github.com/webinstall/webi-installers/internal/lexver"
)
// Dist is one downloadable distributable — matches the CSV row from classify.
type Dist struct {
Package string
Version string
Channel string
OS string
Arch string
Libc string
Format string
Download string
Filename string
SHA256 string
Size int64
LTS bool
Date string
Extra string // extra version info for sorting
GitTag string // original git tag or branch — only for format="git"
GitCommitHash string // short commit hash — only for format="git"
Variants []string // build qualifiers: "installer", "rocm", "fxdependent", etc.
}
// Query describes what the caller wants.
type Query struct {
OS buildmeta.OS
Arch buildmeta.Arch
Libc buildmeta.Libc
Formats []string // acceptable formats (e.g. ".tar.gz", ".zip"), in preference order
Channel string // "stable" (default), "beta", etc.
Version string // version prefix constraint ("24", "24.14", ""), empty = latest
Variants []string // if non-empty, only match assets with these variants
}
// Match is the resolved release.
type Match struct {
Version string
OS string
Arch string
Libc string
Format string
Download string
Filename string
LTS bool
Date string
Channel string
}
// Best finds the single best release matching the query.
// Returns nil if nothing matches.
func Best(dists []Dist, q Query) *Match {
channel := q.Channel
if channel == "" {
channel = "stable"
}
// Build format set for fast lookup + rank map for preference.
formatRank := make(map[string]int, len(q.Formats))
for i, f := range q.Formats {
formatRank[f] = i
}
// Build the set of acceptable architectures (native + compat).
compatArches := buildmeta.CompatArches(q.OS, q.Arch)
archRank := make(map[string]int, len(compatArches))
for i, a := range compatArches {
archRank[string(a)] = i
}
// Parse version prefix for constraint matching.
var versionPrefix lexver.Version
hasVersionConstraint := q.Version != ""
if hasVersionConstraint {
versionPrefix = lexver.Parse(q.Version)
}
var best *candidate
for i := range dists {
d := &dists[i]
// Channel filter.
if channel == "stable" && d.Channel != "stable" && d.Channel != "" {
continue
}
// OS filter: exact match, POSIX fallback, or ANYOS.
if !osMatches(q.OS, d.OS) {
continue
}
// Arch filter (including compat arches).
// Empty arch, ANYARCH, or "*" means "universal/platform-agnostic" —
// accept it but rank it lower than an exact match.
aRank, archOK := archRank[d.Arch]
if !archOK && (d.Arch == "" || d.Arch == "*" || d.Arch == string(buildmeta.ArchAny)) {
// Universal binary — rank after all specific arches.
aRank = len(compatArches)
archOK = true
}
if !archOK {
continue
}
// Libc filter.
if !libcMatches(q.OS, q.Libc, d.Libc) {
continue
}
// Format filter.
// Empty format means bare binary — accept as last resort.
fRank, formatOK := formatRank[d.Format]
if !formatOK && d.Format == "" {
// Bare binary — rank after all explicit formats.
fRank = len(q.Formats)
formatOK = true
}
if !formatOK && len(q.Formats) > 0 {
continue
}
if !formatOK {
fRank = 999
}
// Version constraint.
ver := lexver.Parse(d.Version)
if hasVersionConstraint && !ver.HasPrefix(versionPrefix) {
continue
}
c := &candidate{
dist: d,
ver: ver,
archRank: aRank,
formatRank: fRank,
hasVariants: len(d.Variants) > 0,
}
if best == nil || c.betterThan(best) {
best = c
}
}
if best == nil {
return nil
}
d := best.dist
return &Match{
Version: d.Version,
OS: d.OS,
Arch: d.Arch,
Libc: d.Libc,
Format: d.Format,
Download: d.Download,
Filename: d.Filename,
LTS: d.LTS,
Date: d.Date,
Channel: d.Channel,
}
}
// Catalog computes aggregate metadata across all stable dists for a package.
type Catalog struct {
OSes []string
Arches []string
Libcs []string
Formats []string
Latest string // highest version of any channel
Stable string // highest stable version
}
// Survey scans all dists and returns the catalog.
func Survey(dists []Dist) Catalog {
oses := make(map[string]bool)
arches := make(map[string]bool)
libcs := make(map[string]bool)
formats := make(map[string]bool)
var latest, stable string
for _, d := range dists {
if d.OS != "" {
oses[d.OS] = true
}
if d.Arch != "" {
arches[d.Arch] = true
}
if d.Libc != "" {
libcs[d.Libc] = true
}
if d.Format != "" {
formats[d.Format] = true
}
v := lexver.Parse(d.Version)
if latest == "" || lexver.Compare(v, lexver.Parse(latest)) > 0 {
latest = d.Version
}
if d.Channel == "stable" || d.Channel == "" {
if stable == "" || lexver.Compare(v, lexver.Parse(stable)) > 0 {
stable = d.Version
}
}
}
return Catalog{
OSes: sortedKeys(oses),
Arches: sortedKeys(arches),
Libcs: sortedKeys(libcs),
Formats: sortedKeys(formats),
Latest: latest,
Stable: stable,
}
}
type candidate struct {
dist *Dist
ver lexver.Version
archRank int
formatRank int
hasVariants bool // true if dist has variant qualifiers (GPU, installer, etc.)
}
// betterThan returns true if c is a better match than other.
// Priority: version (higher) > base over variant > arch rank (lower=native) > format rank (lower=preferred).
func (c *candidate) betterThan(other *candidate) bool {
cmp := lexver.Compare(c.ver, other.ver)
if cmp != 0 {
return cmp > 0
}
// Prefer base build over variant builds (rocm, installer, etc.)
if c.hasVariants != other.hasVariants {
return !c.hasVariants
}
if c.archRank != other.archRank {
return c.archRank < other.archRank
}
return c.formatRank < other.formatRank
}
// osMatches checks whether a dist's OS is acceptable for the query.
// Matches exact OS, ANYOS (universal), and POSIX compatibility levels
// (posix_2017 matches any non-Windows OS).
func osMatches(want buildmeta.OS, have string) bool {
if have == string(want) {
return true
}
if have == string(buildmeta.OSAny) {
return true
}
// POSIX assets run on any non-Windows system.
if want != buildmeta.OSWindows {
if have == string(buildmeta.OSPosix2017) || have == string(buildmeta.OSPosix2024) {
return true
}
}
return false
}
// libcMatches checks whether a dist's libc is acceptable for the query.
func libcMatches(os buildmeta.OS, want buildmeta.Libc, have string) bool {
// Darwin and Windows don't use libc tagging — accept anything.
if os == buildmeta.OSDarwin || os == buildmeta.OSWindows {
return true
}
// If the dist has no libc tag, accept it (likely statically linked).
if have == "" || have == "none" || have == string(buildmeta.LibcNone) {
return true
}
// If the query has no libc preference, accept any.
if want == "" || want == buildmeta.LibcNone {
return true
}
return have == string(want)
}
func sortedKeys(m map[string]bool) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
// Simple insertion sort — these are tiny sets.
for i := 1; i < len(keys); i++ {
for j := i; j > 0 && strings.Compare(keys[j-1], keys[j]) > 0; j-- {
keys[j-1], keys[j] = keys[j], keys[j-1]
}
}
return keys
}

View File

@@ -0,0 +1,422 @@
package resolve_test
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/webinstall/webi-installers/internal/buildmeta"
"github.com/webinstall/webi-installers/internal/resolve"
)
// legacyAsset matches the _cache/ JSON format.
type legacyAsset struct {
Name string `json:"name"`
Version string `json:"version"`
LTS bool `json:"lts"`
Channel string `json:"channel"`
Date string `json:"date"`
OS string `json:"os"`
Arch string `json:"arch"`
Libc string `json:"libc"`
Ext string `json:"ext"`
Download string `json:"download"`
}
type legacyCache struct {
Releases []legacyAsset `json:"releases"`
}
func loadCacheDists(t *testing.T, pkg string) []resolve.Dist {
t.Helper()
cacheDir := filepath.Join("..", "..", "_cache", "2026-03")
path := filepath.Join(cacheDir, pkg+".json")
data, err := os.ReadFile(path)
if err != nil {
t.Skipf("no cache file for %s: %v", pkg, err)
}
var lc legacyCache
if err := json.Unmarshal(data, &lc); err != nil {
t.Fatalf("parse %s: %v", pkg, err)
}
dists := make([]resolve.Dist, len(lc.Releases))
for i, la := range lc.Releases {
// Reverse-translate legacy Node.js vocabulary to Go canonical names.
// The cache file uses macos/amd64/arm64; the resolver uses darwin/x86_64/aarch64.
osStr := la.OS
if osStr == "macos" {
osStr = "darwin"
}
archStr := la.Arch
switch archStr {
case "amd64":
archStr = "x86_64"
case "arm64":
archStr = "aarch64"
}
// Restore dot-prefix convention: cache stores "tar.gz", resolver needs ".tar.gz".
// "exe" with no dot in filename = bare binary (Format ""), otherwise ".exe".
format := la.Ext
switch {
case format == "exe" && !strings.Contains(la.Name, "."):
format = ""
case format != "":
format = "." + format
}
dists[i] = resolve.Dist{
Filename: la.Name,
Version: la.Version,
LTS: la.LTS,
Channel: la.Channel,
Date: la.Date,
OS: osStr,
Arch: archStr,
Libc: la.Libc, // "none" = buildmeta.LibcNone (statically linked)
Format: format,
Download: la.Download,
}
}
return dists
}
// platforms is the standard webi target matrix.
var platforms = []struct {
name string
os buildmeta.OS
arch buildmeta.Arch
formats []string
}{
{"darwin-arm64", buildmeta.OSDarwin, buildmeta.ArchARM64, []string{".tar.xz", ".tar.gz", ".zip"}},
{"darwin-amd64", buildmeta.OSDarwin, buildmeta.ArchAMD64, []string{".tar.xz", ".tar.gz", ".zip"}},
{"linux-amd64", buildmeta.OSLinux, buildmeta.ArchAMD64, []string{".tar.xz", ".exe.xz", ".tar.gz", ".xz", ".gz", ".zip"}},
{"linux-arm64", buildmeta.OSLinux, buildmeta.ArchARM64, []string{".tar.xz", ".exe.xz", ".tar.gz", ".xz", ".gz", ".zip"}},
{"linux-armv7", buildmeta.OSLinux, buildmeta.ArchARMv7, []string{".tar.xz", ".tar.gz", ".xz", ".gz", ".zip"}},
{"linux-armv6", buildmeta.OSLinux, buildmeta.ArchARMv6, []string{".tar.xz", ".tar.gz", ".xz", ".gz", ".zip"}},
{"windows-amd64", buildmeta.OSWindows, buildmeta.ArchAMD64, []string{".zip", ".tar.gz", ".exe", ".7z"}},
{"windows-arm64", buildmeta.OSWindows, buildmeta.ArchARM64, []string{".zip", ".tar.gz", ".exe", ".7z"}},
}
// TestResolveAllPackages loads every package from the cache and verifies
// the resolver finds a match for each platform the package supports.
func TestResolveAllPackages(t *testing.T) {
cacheDir := filepath.Join("..", "..", "_cache", "2026-03")
entries, err := os.ReadDir(cacheDir)
if err != nil {
t.Skipf("no cache dir: %v", err)
}
var pkgs []string
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".json") {
pkgs = append(pkgs, strings.TrimSuffix(e.Name(), ".json"))
}
}
if len(pkgs) < 50 {
t.Fatalf("expected at least 50 packages, got %d", len(pkgs))
}
for _, pkg := range pkgs {
t.Run(pkg, func(t *testing.T) {
dists := loadCacheDists(t, pkg)
if len(dists) == 0 {
t.Skip("no releases")
}
// Determine which platforms this package supports.
cat := resolve.Survey(dists)
osSet := make(map[string]bool, len(cat.OSes))
for _, o := range cat.OSes {
osSet[o] = true
}
for _, plat := range platforms {
platOS := string(plat.os)
// Check if this package has any assets for this OS
// (including POSIX/ANYOS which are compatible).
supported := osSet[platOS] ||
osSet[string(buildmeta.OSAny)] ||
(platOS != "windows" && (osSet[string(buildmeta.OSPosix2017)] || osSet[string(buildmeta.OSPosix2024)]))
if !supported {
continue
}
t.Run(plat.name, func(t *testing.T) {
m := resolve.Best(dists, resolve.Query{
OS: plat.os,
Arch: plat.arch,
Formats: plat.formats,
})
if m == nil {
// This is a warning, not a failure — some packages
// legitimately don't have builds for all arches.
// But log it so we can spot unexpected gaps.
t.Logf("WARN: no match for %s on %s (has OSes: %v, Arches: %v)",
pkg, plat.name, cat.OSes, cat.Arches)
return
}
if m.Version == "" {
t.Error("matched but Version is empty")
}
if m.Download == "" {
t.Error("matched but Download is empty")
}
})
}
})
}
}
// Packages with known platform expectations. Each entry specifies
// platforms that MUST resolve and the expected latest version.
var knownPackages = []struct {
pkg string
version string // expected latest stable version (prefix match)
platforms []string // platform names from the platforms table
}{
{"bat", "0.26", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "windows-amd64"}},
{"caddy", "2.", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "linux-armv7", "linux-armv6", "windows-amd64"}},
{"delta", "0.", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "windows-amd64"}},
{"fd", "10.", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "linux-armv7", "windows-amd64"}},
{"fzf", "0.", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "linux-armv7", "windows-amd64"}},
{"gh", "2.", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "linux-armv6", "windows-amd64"}},
{"rg", "", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "windows-amd64"}},
{"shellcheck", "0.", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "linux-armv6", "windows-amd64"}},
{"shfmt", "3.", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "linux-armv6", "windows-amd64"}},
{"xz", "", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "windows-amd64"}},
{"yq", "4.", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "linux-armv6", "windows-amd64"}},
{"zoxide", "0.", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "linux-armv7", "windows-amd64"}},
{"aliasman", "", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64"}},
{"comrak", "0.", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "windows-amd64"}},
{"hugo", "0.", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "windows-amd64"}},
{"node", "", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "windows-amd64"}},
{"terraform", "", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "windows-amd64"}},
{"zig", "", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "windows-amd64"}},
}
// TestKnownPackages verifies specific packages resolve correctly
// with expected versions and platform coverage.
func TestKnownPackages(t *testing.T) {
platMap := make(map[string]struct {
os buildmeta.OS
arch buildmeta.Arch
formats []string
})
for _, p := range platforms {
platMap[p.name] = struct {
os buildmeta.OS
arch buildmeta.Arch
formats []string
}{p.os, p.arch, p.formats}
}
for _, kp := range knownPackages {
t.Run(kp.pkg, func(t *testing.T) {
dists := loadCacheDists(t, kp.pkg)
for _, platName := range kp.platforms {
plat := platMap[platName]
t.Run(platName, func(t *testing.T) {
m := resolve.Best(dists, resolve.Query{
OS: plat.os,
Arch: plat.arch,
Formats: plat.formats,
})
if m == nil {
t.Skipf("no build available for %s on %s — upstream gap", kp.pkg, platName)
return
}
if kp.version != "" {
// Strip leading "v" for prefix comparison.
v := strings.TrimPrefix(m.Version, "v")
if !strings.HasPrefix(v, kp.version) {
t.Errorf("Version = %q, want prefix %q", m.Version, kp.version)
}
}
})
}
})
}
}
// TestResolveVersionConstraints tests version pinning across real packages.
func TestResolveVersionConstraints(t *testing.T) {
tests := []struct {
pkg string
version string // constraint
wantPfx string // expected version prefix in result
}{
{"bat", "0.25", "0.25"},
{"bat", "0.26", "0.26"},
{"gh", "2.40", "2.40"},
{"node", "20", "20."},
{"node", "22", "22."},
{"hugo", "0.121", "0.121"},
}
for _, tt := range tests {
name := fmt.Sprintf("%s@%s", tt.pkg, tt.version)
t.Run(name, func(t *testing.T) {
dists := loadCacheDists(t, tt.pkg)
m := resolve.Best(dists, resolve.Query{
OS: buildmeta.OSLinux,
Arch: buildmeta.ArchAMD64,
Formats: []string{".tar.xz", ".tar.gz", ".zip"},
Version: tt.version,
})
if m == nil {
t.Fatalf("no match for %s@%s", tt.pkg, tt.version)
}
v := strings.TrimPrefix(m.Version, "v")
if !strings.HasPrefix(v, tt.wantPfx) {
t.Errorf("Version = %q, want prefix %q", m.Version, tt.wantPfx)
}
})
}
}
// TestResolveArchFallbackReal tests arch fallback with real package data.
func TestResolveArchFallbackReal(t *testing.T) {
// awless only has amd64 builds — macOS ARM64 should fall back.
dists := loadCacheDists(t, "awless")
m := resolve.Best(dists, resolve.Query{
OS: buildmeta.OSDarwin,
Arch: buildmeta.ArchARM64,
Formats: []string{".tar.gz", ".zip"},
})
if m == nil {
t.Fatal("expected Rosetta 2 fallback for awless")
}
if m.Arch != "x86_64" {
t.Errorf("Arch = %q, want x86_64", m.Arch)
}
}
// TestResolvePosixPackages tests packages that use posix_2017/ANYOS.
func TestResolvePosixPackages(t *testing.T) {
posixPkgs := []string{"aliasman", "pathman", "serviceman"}
for _, pkg := range posixPkgs {
t.Run(pkg, func(t *testing.T) {
dists := loadCacheDists(t, pkg)
if len(dists) == 0 {
t.Skip("no releases")
}
// Should resolve on Linux.
m := resolve.Best(dists, resolve.Query{
OS: buildmeta.OSLinux,
Arch: buildmeta.ArchAMD64,
Formats: []string{".tar.xz", ".tar.gz", ".zip", ".xz", ".gz"},
})
if m == nil {
t.Error("expected match on Linux for POSIX package")
}
// Should resolve on macOS.
m = resolve.Best(dists, resolve.Query{
OS: buildmeta.OSDarwin,
Arch: buildmeta.ArchARM64,
Formats: []string{".tar.xz", ".tar.gz", ".zip"},
})
if m == nil {
t.Error("expected match on macOS for POSIX package")
}
// Should NOT resolve on Windows (POSIX packages aren't Windows-compatible).
m = resolve.Best(dists, resolve.Query{
OS: buildmeta.OSWindows,
Arch: buildmeta.ArchAMD64,
Formats: []string{".zip", ".tar.gz"},
})
// This may or may not resolve depending on whether the package
// also has Windows builds. Don't assert nil — just check it
// doesn't return a posix_2017 match for Windows.
if m != nil && (m.OS == "posix_2017" || m.OS == "posix_2024") {
t.Errorf("POSIX package should not match Windows, got OS=%q", m.OS)
}
})
}
}
// TestResolveLibcPreference tests libc selection.
// bat is a Rust project — its musl builds are static (libc='none').
// pwsh has hard musl dependencies (libc='musl').
func TestResolveLibcPreference(t *testing.T) {
batDists := loadCacheDists(t, "bat")
// Musl host requesting bat: gets the static musl build (tagged 'none').
m := resolve.Best(batDists, resolve.Query{
OS: buildmeta.OSLinux,
Arch: buildmeta.ArchAMD64,
Libc: buildmeta.LibcMusl,
Formats: []string{".tar.gz"},
})
if m == nil {
t.Fatal("expected match for musl host")
}
// Rust musl builds are static — tagged as 'none', not 'musl'.
if m.Libc != "none" {
t.Errorf("bat musl request: Libc = %q, want none (static musl)", m.Libc)
}
// Explicit gnu request.
m = resolve.Best(batDists, resolve.Query{
OS: buildmeta.OSLinux,
Arch: buildmeta.ArchAMD64,
Libc: buildmeta.LibcGNU,
Formats: []string{".tar.gz"},
})
if m == nil {
t.Fatal("expected gnu match")
}
if m.Libc != "gnu" {
t.Errorf("Libc = %q, want gnu", m.Libc)
}
// No preference — should still match (accepts any).
m = resolve.Best(batDists, resolve.Query{
OS: buildmeta.OSLinux,
Arch: buildmeta.ArchAMD64,
Formats: []string{".tar.gz"},
})
if m == nil {
t.Fatal("expected match with no libc preference")
}
// pwsh has hard musl builds (dynamically linked, requires musl runtime).
pwshDists := loadCacheDists(t, "pwsh")
m = resolve.Best(pwshDists, resolve.Query{
OS: buildmeta.OSLinux,
Arch: buildmeta.ArchAMD64,
Libc: buildmeta.LibcMusl,
Formats: []string{".tar.gz"},
})
if m == nil {
t.Fatal("expected pwsh musl match")
}
if m.Libc != "musl" {
t.Errorf("pwsh musl request: Libc = %q, want musl", m.Libc)
}
}
// TestResolveFormatFallback tests format preference cascading.
func TestResolveFormatFallback(t *testing.T) {
// Request .tar.xz first, fall back to .tar.gz.
dists := loadCacheDists(t, "bat")
m := resolve.Best(dists, resolve.Query{
OS: buildmeta.OSLinux,
Arch: buildmeta.ArchAMD64,
Formats: []string{".tar.xz", ".tar.gz", ".zip"},
})
if m == nil {
t.Fatal("expected match")
}
// bat only has .tar.gz — should fall back from .tar.xz.
if m.Format != ".tar.gz" {
t.Errorf("Format = %q, want .tar.gz (fallback from .tar.xz)", m.Format)
}
}

View File

@@ -0,0 +1,250 @@
package resolve
import (
"testing"
"github.com/webinstall/webi-installers/internal/buildmeta"
)
// bat-style dists: standard goreleaser output.
var batDists = []Dist{
{Version: "0.26.1", Channel: "stable", OS: "darwin", Arch: "aarch64", Format: ".tar.gz", Filename: "bat-v0.26.1-aarch64-apple-darwin.tar.gz"},
{Version: "0.26.1", Channel: "stable", OS: "darwin", Arch: "x86_64", Format: ".tar.gz", Filename: "bat-v0.26.1-x86_64-apple-darwin.tar.gz"},
{Version: "0.26.1", Channel: "stable", OS: "linux", Arch: "aarch64", Libc: "gnu", Format: ".tar.gz", Filename: "bat-v0.26.1-aarch64-unknown-linux-gnu.tar.gz"},
{Version: "0.26.1", Channel: "stable", OS: "linux", Arch: "aarch64", Libc: "musl", Format: ".tar.gz", Filename: "bat-v0.26.1-aarch64-unknown-linux-musl.tar.gz"},
{Version: "0.26.1", Channel: "stable", OS: "linux", Arch: "x86_64", Libc: "gnu", Format: ".tar.gz", Filename: "bat-v0.26.1-x86_64-unknown-linux-gnu.tar.gz"},
{Version: "0.26.1", Channel: "stable", OS: "linux", Arch: "x86_64", Libc: "musl", Format: ".tar.gz", Filename: "bat-v0.26.1-x86_64-unknown-linux-musl.tar.gz"},
{Version: "0.26.1", Channel: "stable", OS: "windows", Arch: "aarch64", Format: ".zip", Filename: "bat-v0.26.1-aarch64-pc-windows-msvc.zip"},
{Version: "0.26.1", Channel: "stable", OS: "windows", Arch: "x86_64", Libc: "gnu", Format: ".zip", Filename: "bat-v0.26.1-x86_64-pc-windows-gnu.zip"},
{Version: "0.26.1", Channel: "stable", OS: "windows", Arch: "x86_64", Libc: "msvc", Format: ".zip", Filename: "bat-v0.26.1-x86_64-pc-windows-msvc.zip"},
// Older version.
{Version: "0.25.0", Channel: "stable", OS: "darwin", Arch: "aarch64", Format: ".tar.gz", Filename: "bat-v0.25.0-aarch64-apple-darwin.tar.gz"},
{Version: "0.25.0", Channel: "stable", OS: "linux", Arch: "x86_64", Libc: "gnu", Format: ".tar.gz", Filename: "bat-v0.25.0-x86_64-unknown-linux-gnu.tar.gz"},
}
func TestBestExactMatch(t *testing.T) {
m := Best(batDists, Query{
OS: buildmeta.OSLinux,
Arch: buildmeta.ArchAMD64,
Formats: []string{".tar.gz"},
})
if m == nil {
t.Fatal("expected match")
}
if m.Version != "0.26.1" {
t.Errorf("Version = %q, want 0.26.1", m.Version)
}
if m.Filename != "bat-v0.26.1-x86_64-unknown-linux-gnu.tar.gz" {
t.Errorf("Filename = %q", m.Filename)
}
}
func TestBestVersionConstraint(t *testing.T) {
m := Best(batDists, Query{
OS: buildmeta.OSDarwin,
Arch: buildmeta.ArchARM64,
Formats: []string{".tar.gz"},
Version: "0.25",
})
if m == nil {
t.Fatal("expected match")
}
if m.Version != "0.25.0" {
t.Errorf("Version = %q, want 0.25.0", m.Version)
}
}
func TestBestArchFallback(t *testing.T) {
// macOS ARM64 should fall back to x86_64 via Rosetta 2
// when no ARM64 build exists.
dists := []Dist{
{Version: "1.0.0", Channel: "stable", OS: "darwin", Arch: "x86_64", Format: ".tar.gz", Filename: "tool-darwin-amd64.tar.gz"},
}
m := Best(dists, Query{
OS: buildmeta.OSDarwin,
Arch: buildmeta.ArchARM64,
Formats: []string{".tar.gz"},
})
if m == nil {
t.Fatal("expected match via Rosetta 2 fallback")
}
if m.Arch != "x86_64" {
t.Errorf("Arch = %q, want x86_64", m.Arch)
}
}
func TestBestPrefersNativeOverCompat(t *testing.T) {
// When both native and compat builds exist, prefer native.
dists := []Dist{
{Version: "1.0.0", Channel: "stable", OS: "darwin", Arch: "x86_64", Format: ".tar.gz", Filename: "tool-darwin-amd64.tar.gz"},
{Version: "1.0.0", Channel: "stable", OS: "darwin", Arch: "aarch64", Format: ".tar.gz", Filename: "tool-darwin-arm64.tar.gz"},
}
m := Best(dists, Query{
OS: buildmeta.OSDarwin,
Arch: buildmeta.ArchARM64,
Formats: []string{".tar.gz"},
})
if m == nil {
t.Fatal("expected match")
}
if m.Arch != "aarch64" {
t.Errorf("Arch = %q, want aarch64 (native)", m.Arch)
}
}
func TestBestFormatPreference(t *testing.T) {
dists := []Dist{
{Version: "1.0.0", Channel: "stable", OS: "linux", Arch: "x86_64", Format: ".zip", Filename: "tool.zip"},
{Version: "1.0.0", Channel: "stable", OS: "linux", Arch: "x86_64", Format: ".tar.gz", Filename: "tool.tar.gz"},
{Version: "1.0.0", Channel: "stable", OS: "linux", Arch: "x86_64", Format: ".tar.xz", Filename: "tool.tar.xz"},
}
m := Best(dists, Query{
OS: buildmeta.OSLinux,
Arch: buildmeta.ArchAMD64,
Formats: []string{".tar.xz", ".tar.gz", ".zip"},
})
if m == nil {
t.Fatal("expected match")
}
if m.Format != ".tar.xz" {
t.Errorf("Format = %q, want .tar.xz", m.Format)
}
}
func TestBestNoMatch(t *testing.T) {
m := Best(batDists, Query{
OS: buildmeta.OSFreeBSD,
Arch: buildmeta.ArchAMD64,
Formats: []string{".tar.gz"},
})
if m != nil {
t.Errorf("expected nil, got %+v", m)
}
}
func TestBestLibcMusl(t *testing.T) {
m := Best(batDists, Query{
OS: buildmeta.OSLinux,
Arch: buildmeta.ArchAMD64,
Libc: buildmeta.LibcMusl,
Formats: []string{".tar.gz"},
})
if m == nil {
t.Fatal("expected match")
}
if m.Libc != "musl" {
t.Errorf("Libc = %q, want musl", m.Libc)
}
}
func TestBestPrefersBaseOverVariant(t *testing.T) {
dists := []Dist{
{Version: "1.0.0", Channel: "stable", OS: "linux", Arch: "x86_64", Format: ".tar.gz", Filename: "tool.tar.gz"},
{Version: "1.0.0", Channel: "stable", OS: "linux", Arch: "x86_64", Format: ".tar.gz", Filename: "tool-rocm.tar.gz", Variants: []string{"rocm"}},
}
m := Best(dists, Query{
OS: buildmeta.OSLinux,
Arch: buildmeta.ArchAMD64,
Formats: []string{".tar.gz"},
})
if m == nil {
t.Fatal("expected match")
}
if m.Filename != "tool.tar.gz" {
t.Errorf("got variant build %q, want base", m.Filename)
}
}
func TestBestPosixFallback(t *testing.T) {
dists := []Dist{
{Version: "1.0.0", Channel: "stable", OS: "posix_2017", Format: ".tar.gz", Filename: "script.tar.gz"},
}
m := Best(dists, Query{
OS: buildmeta.OSLinux,
Arch: buildmeta.ArchAMD64,
Formats: []string{".tar.gz"},
})
if m == nil {
t.Fatal("expected match via POSIX fallback")
}
if m.OS != "posix_2017" {
t.Errorf("OS = %q, want posix_2017", m.OS)
}
}
func TestBestAnyOS(t *testing.T) {
dists := []Dist{
{Version: "1.0.0", Channel: "stable", OS: "ANYOS", Format: ".tar.gz", Filename: "tool.tar.gz"},
}
m := Best(dists, Query{
OS: buildmeta.OSWindows,
Arch: buildmeta.ArchAMD64,
Formats: []string{".tar.gz"},
})
if m == nil {
t.Fatal("expected match via ANYOS")
}
}
func TestBestAnyArch(t *testing.T) {
dists := []Dist{
{Version: "1.0.0", Channel: "stable", OS: "linux", Arch: "ANYARCH", Format: ".tar.gz", Filename: "tool.tar.gz"},
}
m := Best(dists, Query{
OS: buildmeta.OSLinux,
Arch: buildmeta.ArchARM64,
Formats: []string{".tar.gz"},
})
if m == nil {
t.Fatal("expected match via ANYARCH")
}
}
func TestBestWindowsArchFallback(t *testing.T) {
// Windows ARM64 should fall back to x86_64 via emulation.
dists := []Dist{
{Version: "1.0.0", Channel: "stable", OS: "windows", Arch: "x86_64", Format: ".zip", Filename: "tool-win64.zip"},
}
m := Best(dists, Query{
OS: buildmeta.OSWindows,
Arch: buildmeta.ArchARM64,
Formats: []string{".zip"},
})
if m == nil {
t.Fatal("expected match via Windows ARM64 emulation")
}
if m.Arch != "x86_64" {
t.Errorf("Arch = %q, want x86_64", m.Arch)
}
}
func TestBestMicroArchFallback(t *testing.T) {
// amd64v3 query should fall back to amd64 baseline.
dists := []Dist{
{Version: "1.0.0", Channel: "stable", OS: "linux", Arch: "x86_64", Format: ".tar.gz", Filename: "tool-amd64.tar.gz"},
}
m := Best(dists, Query{
OS: buildmeta.OSLinux,
Arch: buildmeta.ArchAMD64v3,
Formats: []string{".tar.gz"},
})
if m == nil {
t.Fatal("expected match via micro-arch fallback")
}
if m.Arch != "x86_64" {
t.Errorf("Arch = %q, want x86_64 (baseline)", m.Arch)
}
}
func TestSurvey(t *testing.T) {
cat := Survey(batDists)
if cat.Stable != "0.26.1" {
t.Errorf("Stable = %q, want 0.26.1", cat.Stable)
}
if cat.Latest != "0.26.1" {
t.Errorf("Latest = %q, want 0.26.1", cat.Latest)
}
if len(cat.OSes) != 3 {
t.Errorf("OSes = %v, want 3", cat.OSes)
}
}

View File

@@ -0,0 +1,416 @@
// Package resolver selects the best release asset for a given platform
// and version constraint.
//
// The resolver takes a package's full asset list and a request describing
// what the client needs (OS, arch, libc, version prefix, channel, format
// preferences). It returns the single best matching asset or an error.
//
// Resolution order:
// 1. Filter assets by channel (inclusive: @stable includes stable+lts)
// 2. Sort versions descending, filter by version prefix if given
// 3. For each candidate version, try compatible platform triplets
// (OS × CompatArches fallback × libc) in preference order
// 4. Among platform matches, pick the best format
// 5. Among format matches, prefer assets without build variants
package resolver
import (
"errors"
"slices"
"strings"
"github.com/webinstall/webi-installers/internal/buildmeta"
"github.com/webinstall/webi-installers/internal/lexver"
"github.com/webinstall/webi-installers/internal/storage"
)
// ErrNoMatch is returned when no asset matches the request.
var ErrNoMatch = errors.New("resolver: no matching asset")
// Request describes what the client is looking for.
type Request struct {
// OS is the target operating system (e.g. "linux", "darwin", "windows").
OS string
// Arch is the target architecture (e.g. "aarch64", "x86_64").
Arch string
// Libc is the preferred C library (e.g. "gnu", "musl", "msvc").
// Empty means no preference — the resolver tries all libc values.
Libc string
// Version is a version prefix constraint (e.g. "1.20", "1", "").
// Empty means latest. Exact versions like "1.20.3" also work.
Version string
// Channel selects the release stability level. Values:
// ""/"stable" — stable and LTS only (default)
// "lts" — LTS releases only
// "rc" — rc + stable + LTS
// "beta" — beta + rc + stable + LTS
// "alpha" — everything (alpha + beta + rc + stable + LTS)
// "pre" — alias for beta (package-specific meaning)
Channel string
// LTS when true selects only LTS-flagged releases.
LTS bool
// Formats lists acceptable archive formats in preference order.
// If empty, a default preference order is used.
Formats []string
// Variant selects a specific build variant (e.g. "rocm", "jetpack6").
// If empty, assets with variants are deprioritized.
Variant string
}
// Result holds the resolved asset and metadata about the match.
type Result struct {
// Asset is the selected download.
Asset storage.Asset
// Version is the matched version string.
Version string
// Triplet is the matched platform triplet (os-arch-libc).
Triplet string
}
// Resolve finds the best matching asset for the given request.
func Resolve(assets []storage.Asset, req Request) (Result, error) {
if len(assets) == 0 {
return Result{}, ErrNoMatch
}
// Parse the version prefix for filtering.
var versionPrefix lexver.Version
hasPrefix := req.Version != ""
if hasPrefix {
versionPrefix = lexver.Parse(req.Version)
}
// Build the channel filter.
channelOK := channelFilter(req.Channel, req.LTS)
// Parse and sort all unique versions descending.
type versionEntry struct {
parsed lexver.Version
raw string
}
seen := make(map[string]bool)
var versions []versionEntry
for _, a := range assets {
if seen[a.Version] {
continue
}
seen[a.Version] = true
v := lexver.Parse(a.Version)
v.Raw = a.Version
versions = append(versions, versionEntry{parsed: v, raw: a.Version})
}
slices.SortFunc(versions, func(a, b versionEntry) int {
return lexver.Compare(b.parsed, a.parsed) // descending
})
// Build platform fallback list: ordered (os, arch, libc) combinations.
triplets := enumerateTriplets(req.OS, req.Arch, req.Libc)
// Build format preference list.
formats := req.Formats
if len(formats) == 0 {
formats = defaultFormats(req.OS)
}
// Index assets by version+triplet for fast lookup.
// Assets with empty OS/Arch (like git repos) use "" keys.
type tripletKey struct {
version string
os string
arch string
libc string
}
index := make(map[tripletKey][]storage.Asset)
for _, a := range assets {
key := tripletKey{
version: a.Version,
os: a.OS,
arch: a.Arch,
libc: a.Libc,
}
index[key] = append(index[key], a)
}
// Walk versions in descending order.
for _, ve := range versions {
// Check version prefix.
if hasPrefix && !ve.parsed.HasPrefix(versionPrefix) {
continue
}
// Check channel.
if !channelOK(ve.parsed.Channel, ve.raw) {
continue
}
// Try each compatible triplet.
for _, tri := range triplets {
key := tripletKey{
version: ve.raw,
os: tri.os,
arch: tri.arch,
libc: tri.libc,
}
candidates := index[key]
if len(candidates) == 0 {
continue
}
// Pick the best asset from candidates.
best, ok := pickBest(candidates, formats, req.Variant, req.LTS)
if !ok {
continue
}
triplet := tri.os + "-" + tri.arch + "-" + tri.libc
return Result{
Asset: best,
Version: ve.raw,
Triplet: triplet,
}, nil
}
}
return Result{}, ErrNoMatch
}
// channelFilter returns a function that checks whether a given channel
// is acceptable for the requested channel level.
func channelFilter(requested string, ltsOnly bool) func(channel string, version string) bool {
if ltsOnly {
return func(_ string, _ string) bool {
// LTS filtering happens at the asset level, not version level.
// We let all versions through and filter by LTS flag later.
// Actually, LTS is per-asset, so we handle it in pickBest.
return true
}
}
requested = strings.ToLower(requested)
if requested == "" {
requested = "stable"
}
if requested == "pre" {
requested = "beta"
}
if requested == "latest" {
requested = "stable"
}
// channelRank maps channel names to a numeric rank.
// Higher rank = less stable. A request for rank N accepts
// everything at rank N or below.
rank := func(ch string) int {
ch = strings.ToLower(ch)
switch ch {
case "", "stable":
return 0
case "rc":
return 1
case "beta", "preview":
return 2
case "alpha", "dev":
return 3
default:
return 2 // unknown pre-release channels default to beta-level
}
}
maxRank := rank(requested)
return func(channel string, _ string) bool {
return rank(channel) <= maxRank
}
}
type platformTriple struct {
os string
arch string
libc string
}
// enumerateTriplets builds the ordered list of platform combinations to try.
// It uses CompatArches for arch fallback and tries multiple libc values.
func enumerateTriplets(osStr, archStr, libcStr string) []platformTriple {
// OS candidates: specific OS first, then POSIX compat, then any.
var oses []string
switch osStr {
case "windows":
oses = []string{"windows", "ANYOS", ""}
case "android":
oses = []string{"android", "linux", "posix_2024", "posix_2017", "ANYOS", ""}
case "":
oses = []string{"ANYOS", ""}
default:
oses = []string{osStr, "posix_2024", "posix_2017", "ANYOS", ""}
}
// Arch candidates: use CompatArches for fallback chain.
arches := buildmeta.CompatArches(buildmeta.OS(osStr), buildmeta.Arch(archStr))
var archStrs []string
for _, a := range arches {
archStrs = append(archStrs, string(a))
}
// Also try ANYARCH and empty (for platform-agnostic assets like git repos).
archStrs = append(archStrs, "ANYARCH", "")
// Libc candidates.
var libcs []string
if libcStr != "" {
libcs = []string{libcStr, "none", ""}
} else {
// No preference: try all common options.
switch osStr {
case "linux":
// none first (static, no deps), then gnu, musl, empty.
libcs = []string{"none", "gnu", "musl", ""}
case "windows":
// none first (no deps), msvc last (needs vcredist).
libcs = []string{"none", "msvc", ""}
default:
libcs = []string{"none", ""}
}
}
var triplets []platformTriple
for _, os := range oses {
for _, arch := range archStrs {
for _, libc := range libcs {
triplets = append(triplets, platformTriple{
os: os,
arch: arch,
libc: libc,
})
}
}
}
return triplets
}
// pickBest selects the best asset from a set of candidates for the same
// version and platform. Prefers the requested variant (or no-variant if
// none requested), then picks by format preference.
func pickBest(candidates []storage.Asset, formats []string, wantVariant string, ltsOnly bool) (storage.Asset, bool) {
// Filter by LTS if requested.
if ltsOnly {
var lts []storage.Asset
for _, a := range candidates {
if a.LTS {
lts = append(lts, a)
}
}
if len(lts) == 0 {
return storage.Asset{}, false
}
candidates = lts
}
// Separate into variant-matched and non-variant pools.
var preferred []storage.Asset
var fallback []storage.Asset
for _, a := range candidates {
if wantVariant != "" {
// User requested a specific variant.
if hasVariant(a.Variants, wantVariant) {
preferred = append(preferred, a)
} else if len(a.Variants) == 0 {
fallback = append(fallback, a)
}
} else {
// No variant requested: prefer no-variant assets.
if len(a.Variants) == 0 {
preferred = append(preferred, a)
} else {
fallback = append(fallback, a)
}
}
}
// Try preferred pool first, then fallback.
for _, pool := range [][]storage.Asset{preferred, fallback} {
if len(pool) == 0 {
continue
}
if best, ok := pickByFormat(pool, formats); ok {
return best, true
}
}
return storage.Asset{}, false
}
// pickByFormat selects the asset with the most preferred format.
func pickByFormat(assets []storage.Asset, formats []string) (storage.Asset, bool) {
for _, fmt := range formats {
for _, a := range assets {
if a.Format == fmt {
return a, true
}
}
}
// No format match — return the first asset as last resort.
if len(assets) > 0 {
return assets[0], true
}
return storage.Asset{}, false
}
func hasVariant(variants []string, want string) bool {
for _, v := range variants {
if v == want {
return true
}
}
return false
}
// defaultFormats returns the format preference order for an OS.
// zst is preferred as the modern standard, but availability varies.
func defaultFormats(os string) []string {
switch os {
case "windows":
return []string{
".tar.zst",
".tar.xz",
".zip",
".tar.gz",
".exe.xz",
".7z",
".exe",
".msi",
"git",
}
case "darwin":
return []string{
".tar.zst",
".tar.xz",
".zip",
".tar.gz",
".gz",
".app.zip",
".dmg",
".pkg",
"git",
}
default:
// Linux and other POSIX.
return []string{
".tar.zst",
".tar.xz",
".tar.gz",
".gz",
".zip",
".xz",
"git",
}
}
}

View File

@@ -0,0 +1,290 @@
package resolver_test
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/webinstall/webi-installers/internal/resolver"
"github.com/webinstall/webi-installers/internal/storage"
)
func loadAssets(t *testing.T, pkg string) []storage.Asset {
t.Helper()
cacheDir := filepath.Join("..", "..", "_cache", "2026-03")
path := filepath.Join(cacheDir, pkg+".json")
data, err := os.ReadFile(path)
if err != nil {
t.Skipf("no cache file for %s: %v", pkg, err)
}
var lc storage.LegacyCache
if err := json.Unmarshal(data, &lc); err != nil {
t.Fatalf("parse %s: %v", pkg, err)
}
pd := storage.ImportLegacy(lc)
return pd.Assets
}
// TestCacheResolveAllPackages loads every package from the cache and verifies
// the resolver finds a match for each standard platform.
func TestCacheResolveAllPackages(t *testing.T) {
cacheDir := filepath.Join("..", "..", "_cache", "2026-03")
entries, err := os.ReadDir(cacheDir)
if err != nil {
t.Skipf("no cache dir: %v", err)
}
var pkgs []string
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".json") {
pkgs = append(pkgs, strings.TrimSuffix(e.Name(), ".json"))
}
}
if len(pkgs) < 50 {
t.Fatalf("expected at least 50 packages, got %d", len(pkgs))
}
platforms := []struct {
name string
os string
arch string
}{
{"darwin-arm64", "darwin", "aarch64"},
{"darwin-amd64", "darwin", "x86_64"},
{"linux-amd64", "linux", "x86_64"},
{"linux-arm64", "linux", "aarch64"},
{"windows-amd64", "windows", "x86_64"},
}
for _, pkg := range pkgs {
t.Run(pkg, func(t *testing.T) {
assets := loadAssets(t, pkg)
if len(assets) == 0 {
t.Skip("no releases")
}
// Determine which OSes this package has.
osSet := make(map[string]bool)
for _, a := range assets {
if a.OS != "" {
osSet[a.OS] = true
}
}
// Also check for platform-agnostic assets.
hasAgnostic := false
for _, a := range assets {
if a.OS == "" {
hasAgnostic = true
break
}
}
for _, plat := range platforms {
supported := osSet[plat.os] ||
osSet["ANYOS"] ||
hasAgnostic ||
(plat.os != "windows" && (osSet["posix_2017"] || osSet["posix_2024"]))
if !supported {
continue
}
t.Run(plat.name, func(t *testing.T) {
res, err := resolver.Resolve(assets, resolver.Request{
OS: plat.os,
Arch: plat.arch,
})
if err != nil {
// Not a test failure — some packages don't have
// all arch builds. Log for visibility.
t.Logf("WARN: no match for %s on %s (has OSes: %v)",
pkg, plat.name, sortedOSes(osSet))
return
}
if res.Version == "" {
t.Error("matched but Version is empty")
}
if res.Asset.Download == "" {
t.Error("matched but Download is empty")
}
})
}
})
}
}
// TestCacheKnownPackages verifies specific packages resolve correctly.
var knownPackages = []struct {
pkg string
version string // expected latest stable version prefix
platforms []string
}{
{"bat", "0.26", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "windows-amd64"}},
{"caddy", "2.", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "windows-amd64"}},
{"delta", "0.", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "windows-amd64"}},
{"fd", "10.", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "windows-amd64"}},
{"fzf", "0.", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "windows-amd64"}},
{"gh", "2.", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "windows-amd64"}},
{"rg", "", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "windows-amd64"}},
{"node", "", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "windows-amd64"}},
{"terraform", "", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "windows-amd64"}},
{"zig", "", []string{"darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64", "windows-amd64"}},
}
func TestCacheKnownPackages(t *testing.T) {
platMap := map[string]resolver.Request{
"darwin-arm64": {OS: "darwin", Arch: "aarch64"},
"darwin-amd64": {OS: "darwin", Arch: "x86_64"},
"linux-amd64": {OS: "linux", Arch: "x86_64"},
"linux-arm64": {OS: "linux", Arch: "aarch64"},
"windows-amd64": {OS: "windows", Arch: "x86_64"},
}
for _, kp := range knownPackages {
t.Run(kp.pkg, func(t *testing.T) {
assets := loadAssets(t, kp.pkg)
for _, platName := range kp.platforms {
req := platMap[platName]
t.Run(platName, func(t *testing.T) {
res, err := resolver.Resolve(assets, req)
if err != nil {
t.Fatalf("no match for %s on %s", kp.pkg, platName)
}
if kp.version != "" {
v := strings.TrimPrefix(res.Version, "v")
if !strings.HasPrefix(v, kp.version) {
t.Errorf("Version = %q, want prefix %q", res.Version, kp.version)
}
}
})
}
})
}
}
// TestCacheVersionConstraints tests version pinning with real data.
func TestCacheVersionConstraints(t *testing.T) {
tests := []struct {
pkg string
version string
wantPfx string
}{
{"bat", "0.25", "0.25"},
{"bat", "0.26", "0.26"},
{"gh", "2.40", "2.40"},
{"node", "20", "20."},
{"node", "22", "22."},
}
for _, tt := range tests {
t.Run(tt.pkg+"@"+tt.version, func(t *testing.T) {
assets := loadAssets(t, tt.pkg)
res, err := resolver.Resolve(assets, resolver.Request{
OS: "linux",
Arch: "x86_64",
Version: tt.version,
})
if err != nil {
t.Fatalf("no match for %s@%s", tt.pkg, tt.version)
}
v := strings.TrimPrefix(res.Version, "v")
if !strings.HasPrefix(v, tt.wantPfx) {
t.Errorf("Version = %q, want prefix %q", res.Version, tt.wantPfx)
}
})
}
}
// TestCacheArchFallback verifies Rosetta-style fallback with real data.
func TestCacheArchFallback(t *testing.T) {
// awless only has amd64 builds — macOS ARM64 should fall back.
assets := loadAssets(t, "awless")
res, err := resolver.Resolve(assets, resolver.Request{
OS: "darwin",
Arch: "aarch64",
})
if err != nil {
t.Fatal("expected Rosetta 2 fallback for awless")
}
if res.Asset.Arch != "x86_64" {
t.Errorf("Arch = %q, want x86_64", res.Asset.Arch)
}
}
// TestCacheGitPackages verifies git-only packages resolve on any platform.
func TestCacheGitPackages(t *testing.T) {
gitPkgs := []string{"vim-essentials", "vim-spell"}
for _, pkg := range gitPkgs {
t.Run(pkg, func(t *testing.T) {
assets := loadAssets(t, pkg)
if len(assets) == 0 {
t.Skip("no releases")
}
// Should work on any platform.
for _, plat := range []struct {
os, arch string
}{
{"linux", "x86_64"},
{"darwin", "aarch64"},
{"windows", "x86_64"},
} {
res, err := resolver.Resolve(assets, resolver.Request{
OS: plat.os,
Arch: plat.arch,
})
if err != nil {
t.Errorf("expected match on %s-%s", plat.os, plat.arch)
continue
}
if res.Asset.Format != "git" {
t.Errorf("format = %q, want git", res.Asset.Format)
}
}
})
}
}
// TestCacheLibcPreference tests explicit libc selection.
// bat is Rust — its musl builds are static (tagged 'none').
func TestCacheLibcPreference(t *testing.T) {
assets := loadAssets(t, "bat")
// Musl host requesting bat: gets static musl build (tagged 'none').
res, err := resolver.Resolve(assets, resolver.Request{
OS: "linux",
Arch: "x86_64",
Libc: "musl",
})
if err != nil {
t.Fatal("expected match for musl host")
}
if res.Asset.Libc != "none" {
t.Errorf("Libc = %q, want none (static musl)", res.Asset.Libc)
}
// Explicit gnu.
res, err = resolver.Resolve(assets, resolver.Request{
OS: "linux",
Arch: "x86_64",
Libc: "gnu",
})
if err != nil {
t.Fatal("expected gnu match")
}
if res.Asset.Libc != "gnu" {
t.Errorf("Libc = %q, want gnu", res.Asset.Libc)
}
}
func sortedOSes(m map[string]bool) []string {
var keys []string
for k := range m {
keys = append(keys, k)
}
return keys
}

View File

@@ -0,0 +1,397 @@
package resolver
import (
"testing"
"github.com/webinstall/webi-installers/internal/storage"
)
func TestResolveSimple(t *testing.T) {
assets := []storage.Asset{
{
Filename: "bat-v0.25.0-x86_64-unknown-linux-musl.tar.gz",
Version: "0.25.0",
Channel: "stable",
OS: "linux",
Arch: "x86_64",
Libc: "musl",
Format: ".tar.gz",
Download: "https://example.com/bat-0.25.0-linux-x86_64.tar.gz",
},
{
Filename: "bat-v0.26.0-x86_64-unknown-linux-musl.tar.gz",
Version: "0.26.0",
Channel: "stable",
OS: "linux",
Arch: "x86_64",
Libc: "musl",
Format: ".tar.gz",
Download: "https://example.com/bat-0.26.0-linux-x86_64.tar.gz",
},
{
Filename: "bat-v0.26.0-aarch64-unknown-linux-musl.tar.gz",
Version: "0.26.0",
Channel: "stable",
OS: "linux",
Arch: "aarch64",
Libc: "musl",
Format: ".tar.gz",
Download: "https://example.com/bat-0.26.0-linux-aarch64.tar.gz",
},
{
Filename: "bat-v0.26.0-x86_64-pc-windows-msvc.zip",
Version: "0.26.0",
Channel: "stable",
OS: "windows",
Arch: "x86_64",
Libc: "msvc",
Format: ".zip",
Download: "https://example.com/bat-0.26.0-windows-x86_64.zip",
},
{
Filename: "bat-v0.26.0-x86_64-apple-darwin.tar.gz",
Version: "0.26.0",
Channel: "stable",
OS: "darwin",
Arch: "x86_64",
Format: ".tar.gz",
Download: "https://example.com/bat-0.26.0-darwin-x86_64.tar.gz",
},
}
t.Run("latest linux x86_64", func(t *testing.T) {
res, err := Resolve(assets, Request{
OS: "linux",
Arch: "x86_64",
})
if err != nil {
t.Fatal(err)
}
if res.Version != "0.26.0" {
t.Errorf("version = %q, want 0.26.0", res.Version)
}
if res.Asset.OS != "linux" {
t.Errorf("os = %q, want linux", res.Asset.OS)
}
if res.Asset.Arch != "x86_64" {
t.Errorf("arch = %q, want x86_64", res.Asset.Arch)
}
})
t.Run("latest linux aarch64", func(t *testing.T) {
res, err := Resolve(assets, Request{
OS: "linux",
Arch: "aarch64",
})
if err != nil {
t.Fatal(err)
}
if res.Version != "0.26.0" {
t.Errorf("version = %q, want 0.26.0", res.Version)
}
if res.Asset.Arch != "aarch64" {
t.Errorf("arch = %q, want aarch64", res.Asset.Arch)
}
})
t.Run("version prefix 0.25", func(t *testing.T) {
res, err := Resolve(assets, Request{
OS: "linux",
Arch: "x86_64",
Version: "0.25",
})
if err != nil {
t.Fatal(err)
}
if res.Version != "0.25.0" {
t.Errorf("version = %q, want 0.25.0", res.Version)
}
})
t.Run("darwin arm64 falls back to x86_64", func(t *testing.T) {
res, err := Resolve(assets, Request{
OS: "darwin",
Arch: "aarch64",
})
if err != nil {
t.Fatal(err)
}
if res.Asset.Arch != "x86_64" {
t.Errorf("arch = %q, want x86_64 (Rosetta fallback)", res.Asset.Arch)
}
})
t.Run("no match returns error", func(t *testing.T) {
_, err := Resolve(assets, Request{
OS: "freebsd",
Arch: "x86_64",
})
if err != ErrNoMatch {
t.Errorf("err = %v, want ErrNoMatch", err)
}
})
t.Run("windows gets zip", func(t *testing.T) {
res, err := Resolve(assets, Request{
OS: "windows",
Arch: "x86_64",
})
if err != nil {
t.Fatal(err)
}
if res.Asset.Format != ".zip" {
t.Errorf("format = %q, want .zip", res.Asset.Format)
}
})
}
func TestResolveChannels(t *testing.T) {
assets := []storage.Asset{
{
Filename: "tool-v2.0.0-rc1-linux-x86_64.tar.gz",
Version: "2.0.0-rc1",
Channel: "rc",
OS: "linux",
Arch: "x86_64",
Format: ".tar.gz",
},
{
Filename: "tool-v1.5.0-linux-x86_64.tar.gz",
Version: "1.5.0",
Channel: "stable",
OS: "linux",
Arch: "x86_64",
Format: ".tar.gz",
},
{
Filename: "tool-v2.0.0-beta2-linux-x86_64.tar.gz",
Version: "2.0.0-beta2",
Channel: "beta",
OS: "linux",
Arch: "x86_64",
Format: ".tar.gz",
},
}
t.Run("stable skips rc and beta", func(t *testing.T) {
res, err := Resolve(assets, Request{
OS: "linux",
Arch: "x86_64",
})
if err != nil {
t.Fatal(err)
}
if res.Version != "1.5.0" {
t.Errorf("version = %q, want 1.5.0", res.Version)
}
})
t.Run("rc includes rc and stable", func(t *testing.T) {
res, err := Resolve(assets, Request{
OS: "linux",
Arch: "x86_64",
Channel: "rc",
})
if err != nil {
t.Fatal(err)
}
if res.Version != "2.0.0-rc1" {
t.Errorf("version = %q, want 2.0.0-rc1", res.Version)
}
})
t.Run("beta includes beta, rc, and stable", func(t *testing.T) {
res, err := Resolve(assets, Request{
OS: "linux",
Arch: "x86_64",
Channel: "beta",
})
if err != nil {
t.Fatal(err)
}
// beta2 sorts after rc1 for the same numeric version (2.0.0),
// but rc1 is more stable. However, the user asked for beta channel
// which includes everything — and beta sorts before rc alphabetically.
// With lexver: 2.0.0-rc1 > 2.0.0-beta2 (rc > beta alphabetically).
if res.Version != "2.0.0-rc1" {
t.Errorf("version = %q, want 2.0.0-rc1", res.Version)
}
})
}
func TestResolveVariants(t *testing.T) {
assets := []storage.Asset{
{
Filename: "ollama-linux-amd64.tgz",
Version: "0.6.0",
Channel: "stable",
OS: "linux",
Arch: "x86_64",
Format: ".tar.gz",
},
{
Filename: "ollama-linux-amd64-rocm.tgz",
Version: "0.6.0",
Channel: "stable",
OS: "linux",
Arch: "x86_64",
Format: ".tar.gz",
Variants: []string{"rocm"},
},
}
t.Run("no variant prefers plain", func(t *testing.T) {
res, err := Resolve(assets, Request{
OS: "linux",
Arch: "x86_64",
})
if err != nil {
t.Fatal(err)
}
if len(res.Asset.Variants) != 0 {
t.Errorf("variants = %v, want empty", res.Asset.Variants)
}
})
t.Run("explicit variant selects it", func(t *testing.T) {
res, err := Resolve(assets, Request{
OS: "linux",
Arch: "x86_64",
Variant: "rocm",
})
if err != nil {
t.Fatal(err)
}
if !hasVariant(res.Asset.Variants, "rocm") {
t.Errorf("variants = %v, want [rocm]", res.Asset.Variants)
}
})
}
func TestResolveFormatPreference(t *testing.T) {
assets := []storage.Asset{
{
Filename: "tool-v1.0.0-linux-x86_64.tar.gz",
Version: "1.0.0",
Channel: "stable",
OS: "linux",
Arch: "x86_64",
Format: ".tar.gz",
},
{
Filename: "tool-v1.0.0-linux-x86_64.tar.xz",
Version: "1.0.0",
Channel: "stable",
OS: "linux",
Arch: "x86_64",
Format: ".tar.xz",
},
{
Filename: "tool-v1.0.0-linux-x86_64.tar.zst",
Version: "1.0.0",
Channel: "stable",
OS: "linux",
Arch: "x86_64",
Format: ".tar.zst",
},
}
t.Run("default prefers zst", func(t *testing.T) {
res, err := Resolve(assets, Request{
OS: "linux",
Arch: "x86_64",
})
if err != nil {
t.Fatal(err)
}
if res.Asset.Format != ".tar.zst" {
t.Errorf("format = %q, want .tar.zst", res.Asset.Format)
}
})
t.Run("explicit format preference", func(t *testing.T) {
res, err := Resolve(assets, Request{
OS: "linux",
Arch: "x86_64",
Formats: []string{".tar.gz"},
})
if err != nil {
t.Fatal(err)
}
if res.Asset.Format != ".tar.gz" {
t.Errorf("format = %q, want .tar.gz", res.Asset.Format)
}
})
}
func TestResolveGitAssets(t *testing.T) {
assets := []storage.Asset{
{
Filename: "vim-commentary-v1.2",
Version: "1.2",
Channel: "stable",
Format: "git",
Download: "https://github.com/tpope/vim-commentary.git",
},
{
Filename: "vim-commentary-v1.1",
Version: "1.1",
Channel: "stable",
Format: "git",
Download: "https://github.com/tpope/vim-commentary.git",
},
}
t.Run("git assets match any platform", func(t *testing.T) {
res, err := Resolve(assets, Request{
OS: "linux",
Arch: "x86_64",
})
if err != nil {
t.Fatal(err)
}
if res.Version != "1.2" {
t.Errorf("version = %q, want 1.2", res.Version)
}
if res.Asset.Format != "git" {
t.Errorf("format = %q, want git", res.Asset.Format)
}
})
}
func TestResolveLTS(t *testing.T) {
assets := []storage.Asset{
{
Filename: "node-v22.0.0-linux-x64.tar.gz",
Version: "22.0.0",
Channel: "stable",
OS: "linux",
Arch: "x86_64",
Format: ".tar.gz",
LTS: false,
},
{
Filename: "node-v20.15.0-linux-x64.tar.gz",
Version: "20.15.0",
Channel: "stable",
OS: "linux",
Arch: "x86_64",
Format: ".tar.gz",
LTS: true,
},
}
t.Run("LTS selects older LTS version", func(t *testing.T) {
res, err := Resolve(assets, Request{
OS: "linux",
Arch: "x86_64",
LTS: true,
})
if err != nil {
t.Fatal(err)
}
if res.Version != "20.15.0" {
t.Errorf("version = %q, want 20.15.0", res.Version)
}
})
}

View File

@@ -0,0 +1,247 @@
// Package uadetect identifies the requesting agent's OS, CPU architecture,
// and libc so the server can select the correct release artifact.
//
// An agent identifies itself through multiple signals:
// - The User-Agent header: Webi's bootstrap scripts send "$(uname -srm)",
// e.g. "Darwin 23.1.0 arm64". Browsers, curl, and PowerShell send their
// own UA strings.
// - Query parameters: ?os=linux&arch=arm64 are an explicit declaration
// that takes precedence over the header.
//
// Use [FromRequest] to detect from an HTTP request (preferred).
// Use [Parse] to detect from a raw UA string.
package uadetect
import (
"net/http"
"strings"
"github.com/webinstall/webi-installers/internal/buildmeta"
)
// Result holds the detected platform info from a User-Agent string.
type Result struct {
OS buildmeta.OS
Arch buildmeta.Arch
Libc buildmeta.Libc
}
// FromRequest detects the agent's platform from an HTTP request.
// Query parameters ?os and ?arch override the User-Agent header.
func FromRequest(r *http.Request) Result {
qOS := r.URL.Query().Get("os")
qArch := r.URL.Query().Get("arch")
var ua string
switch {
case qOS != "" && qArch != "":
ua = qOS + " " + qArch
case qOS != "":
ua = qOS
case qArch != "":
ua = qArch
default:
ua = r.Header.Get("User-Agent")
}
return Parse(ua)
}
// Parse extracts OS, arch, and libc from a User-Agent string.
func Parse(ua string) Result {
if ua == "-" {
return Result{}
}
tokens := tokenize(ua)
return Result{
OS: matchOS(tokens),
Arch: matchArch(tokens),
Libc: matchLibc(tokens),
}
}
// tokenize splits a User-Agent into lowercase tokens for matching.
// Splits on whitespace, '/', and ';', since UAs come in various forms:
//
// "Darwin 23.1.0 arm64" (uname -srm)
// "PowerShell/7.3.0" (PowerShell)
// "MS AMD64" (Windows shorthand)
// "Macintosh; Intel Mac OS X 10_15_7" (browser)
func tokenize(ua string) []string {
// Strip xnu kernel info that can mislead arch detection under Rosetta.
// "xnu-7195.60.75~1/RELEASE_ARM64_T8101" contains ARM64 even when
// running as x86_64. This only appears in verbose uname output.
if i := strings.Index(ua, "xnu-"); i >= 0 {
end := strings.IndexByte(ua[i:], ' ')
if end < 0 {
ua = ua[:i]
} else {
ua = ua[:i] + ua[i+end:]
}
}
return strings.FieldsFunc(strings.ToLower(ua), func(r rune) bool {
return r == ' ' || r == '/' || r == ';' || r == '\t'
})
}
// matchOS identifies the operating system from tokens.
// Order matters: Android before Linux, Linux before Windows (for WSL).
func matchOS(tokens []string) buildmeta.OS {
has := func(s string) bool {
for _, t := range tokens {
if strings.Contains(t, s) {
return true
}
}
return false
}
// Android must be checked before Linux.
if has("android") {
return buildmeta.OSAndroid
}
if has("darwin") || has("macos") || has("macintosh") || has("iphone") || has("ios") || has("ipad") {
return buildmeta.OSDarwin
}
// "mac" alone (not in "macintosh" which is already matched)
for _, t := range tokens {
if t == "mac" {
return buildmeta.OSDarwin
}
}
// FreeBSD before Linux (both are POSIX, but FreeBSD never reports "linux").
if has("freebsd") {
return buildmeta.OSFreeBSD
}
// Linux before Windows because WSL UAs contain both "linux" and "microsoft".
// But exclude Cygwin/Msys/MINGW which report Linux-like strings on Windows.
if has("linux") && !has("cygwin") && !has("msysgit") && !has("msys") && !has("mingw") {
return buildmeta.OSLinux
}
// Cygwin, Msys, and MINGW are Windows environments.
if has("windows") || has("win32") || has("microsoft") || has("powershell") ||
has("cygwin") || has("msys") || has("mingw") {
return buildmeta.OSWindows
}
for _, t := range tokens {
if t == "ms" || t == "win" {
return buildmeta.OSWindows
}
}
// Fallback: curl and wget imply a POSIX system, almost always Linux.
if has("curl") || has("wget") {
return buildmeta.OSLinux
}
return ""
}
// matchArch identifies the CPU architecture from tokens.
// More specific patterns are checked before less specific ones.
func matchArch(tokens []string) buildmeta.Arch {
has := func(s string) bool {
for _, t := range tokens {
if strings.Contains(t, s) {
return true
}
}
return false
}
exact := func(s string) bool {
for _, t := range tokens {
if t == s {
return true
}
}
return false
}
// ARM 64-bit (most specific first)
if has("aarch64") || has("arm64") || has("armv8") {
return buildmeta.ArchARM64
}
// ARM 32-bit variants
if has("armv7") || has("arm32") {
return buildmeta.ArchARMv7
}
if has("armv6") {
return buildmeta.ArchARMv6
}
// Bare "arm" without a version qualifier → armv6 (conservative).
if exact("arm") {
return buildmeta.ArchARMv6
}
// POWER (check before generic 64-bit)
if has("ppc64le") {
return buildmeta.ArchPPC64LE
}
if has("ppc64") {
return buildmeta.ArchPPC64
}
// s390x (IBM Z)
if has("s390x") {
return buildmeta.ArchS390X
}
// RISC-V
if has("riscv64") {
return buildmeta.ArchRISCV64
}
// MIPS (check before generic 64-bit)
if has("mips64") {
return buildmeta.ArchMIPS64
}
if has("mips") {
return buildmeta.ArchMIPS
}
// x86-64
if has("x86_64") || has("amd64") || exact("x64") {
return buildmeta.ArchAMD64
}
// x86 32-bit (after x86_64 to avoid false match)
if has("i386") || has("i686") || exact("x86") {
return buildmeta.ArchX86
}
return ""
}
// matchLibc identifies the C library from tokens.
func matchLibc(tokens []string) buildmeta.Libc {
has := func(s string) bool {
for _, t := range tokens {
if strings.Contains(t, s) {
return true
}
}
return false
}
if has("musl") {
return buildmeta.LibcMusl
}
// Don't match "microsoft" — it appears in WSL kernel version strings
// (e.g. "5.15.146.1-microsoft-standard-WSL2") and doesn't indicate MSVC.
if has("msvc") || has("windows") {
return buildmeta.LibcMSVC
}
if has("gnu") || has("glibc") || has("linux") {
return buildmeta.LibcGNU
}
return buildmeta.LibcNone
}

View File

@@ -0,0 +1,190 @@
package uadetect_test
import (
"net/http"
"testing"
"github.com/webinstall/webi-installers/internal/buildmeta"
"github.com/webinstall/webi-installers/internal/uadetect"
)
func TestOS(t *testing.T) {
tests := []struct {
ua string
want buildmeta.OS
}{
// uname -srm style
{"Darwin 23.1.0 arm64", buildmeta.OSDarwin},
{"Darwin 20.2.0 x86_64", buildmeta.OSDarwin},
{"Linux 6.1.0-18-amd64 x86_64", buildmeta.OSLinux},
{"Linux 5.15.0 aarch64", buildmeta.OSLinux},
// WSL: Linux, not Windows (contains "microsoft" in kernel release)
{"Linux 5.15.146.1-microsoft-standard-WSL2 x86_64", buildmeta.OSLinux},
// Windows
{"MS AMD64", buildmeta.OSWindows},
{"PowerShell/7.3.0", buildmeta.OSWindows},
{"Microsoft Windows 10.0.19045", buildmeta.OSWindows},
// Msys/MINGW/Cygwin → Windows
{"webi/curl x86_64/unknown Msys/MINGW64_NT-10.0-19045/3.5.7-463ebcdc.x86_64 libc", buildmeta.OSWindows},
{"webi/curl+wget x86_64/unknown Msys/MSYS_NT-10.0-26200/3.6.6-1cdd4371.x86_64 libc", buildmeta.OSWindows},
{"webi/curl x86_64/unknown Cygwin/CYGWIN_NT-10.0/2.10.0(0.325/5/3) libc", buildmeta.OSWindows},
// FreeBSD
{"webi/curl amd64/unknown FreeBSD/14.3-RELEASE-p8 libc", buildmeta.OSFreeBSD},
// Android before Linux
{"Android 13 aarch64", buildmeta.OSAndroid},
{"webi/curl aarch64/unknown Android/Linux/6.6.77-android15-8 libc", buildmeta.OSAndroid},
// WSL: Linux, not Windows (kernel contains "microsoft")
{"webi/curl+wget x86_64/unknown GNU/Linux/5.15.146.1-microsoft-standard-WSL2 libc", buildmeta.OSLinux},
// Browser-style
{"Macintosh; Intel Mac OS X 10_15_7", buildmeta.OSDarwin},
// Minimal agents → assume Linux
{"curl/8.1.2", buildmeta.OSLinux},
{"wget/1.21", buildmeta.OSLinux},
// Explicit unknown
{"-", ""},
}
for _, tt := range tests {
t.Run(tt.ua, func(t *testing.T) {
got := uadetect.Parse(tt.ua).OS
if got != tt.want {
t.Errorf("Parse(%q).OS = %q, want %q", tt.ua, got, tt.want)
}
})
}
}
func TestArch(t *testing.T) {
tests := []struct {
ua string
want buildmeta.Arch
}{
{"Darwin 23.1.0 arm64", buildmeta.ArchARM64},
{"Linux 6.1.0 aarch64", buildmeta.ArchARM64},
{"Linux 5.4.0 x86_64", buildmeta.ArchAMD64},
{"MS AMD64", buildmeta.ArchAMD64},
{"Linux 5.10.0 armv7l", buildmeta.ArchARMv7},
{"Linux 5.10.0 armv6l", buildmeta.ArchARMv6},
{"Linux 5.4.0 ppc64le", buildmeta.ArchPPC64LE},
{"webi/curl+wget s390x/unknown GNU/Linux/6.4.0-150700.53.6-default libc", buildmeta.ArchS390X},
// FreeBSD uses "amd64" not "x86_64"
{"webi/curl amd64/unknown FreeBSD/14.3-RELEASE-p8 libc", buildmeta.ArchAMD64},
// Rosetta: xnu kernel info says ARM64 but actual arch is x86_64
{"Darwin 20.2.0 Darwin Kernel Version 20.2.0; root:xnu-7195.60.75~1/RELEASE_ARM64_T8101 x86_64", buildmeta.ArchAMD64},
{"-", ""},
}
for _, tt := range tests {
t.Run(tt.ua, func(t *testing.T) {
got := uadetect.Parse(tt.ua).Arch
if got != tt.want {
t.Errorf("Parse(%q).Arch = %q, want %q", tt.ua, got, tt.want)
}
})
}
}
func TestLibc(t *testing.T) {
tests := []struct {
ua string
want buildmeta.Libc
}{
{"Linux 6.1.0 x86_64 musl", buildmeta.LibcMusl},
{"Linux 6.1.0 x86_64 gnu", buildmeta.LibcGNU},
{"Linux 6.1.0 x86_64 linux", buildmeta.LibcGNU},
{"MS AMD64 msvc", buildmeta.LibcMSVC},
{"Microsoft Windows", buildmeta.LibcMSVC},
{"Darwin 23.1.0 arm64", buildmeta.LibcNone},
// WSL: kernel version contains "microsoft" but libc is gnu, not msvc
{"webi/curl+wget x86_64/unknown GNU/Linux/5.15.146.1-microsoft-standard-WSL2 libc", buildmeta.LibcGNU},
{"-", ""},
}
for _, tt := range tests {
t.Run(tt.ua, func(t *testing.T) {
got := uadetect.Parse(tt.ua).Libc
if got != tt.want {
t.Errorf("Parse(%q).Libc = %q, want %q", tt.ua, got, tt.want)
}
})
}
}
func TestFromRequest(t *testing.T) {
tests := []struct {
name string
ua string // User-Agent header
query string // raw query string
wantOS buildmeta.OS
wantAr buildmeta.Arch
}{
{
name: "UA header only",
ua: "Darwin 23.1.0 arm64",
wantOS: buildmeta.OSDarwin,
wantAr: buildmeta.ArchARM64,
},
{
name: "query params override UA",
ua: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
query: "os=linux&arch=aarch64",
wantOS: buildmeta.OSLinux,
wantAr: buildmeta.ArchARM64,
},
{
name: "os param only",
ua: "curl/8.1.2",
query: "os=windows",
wantOS: buildmeta.OSWindows,
},
{
name: "arch param only",
ua: "curl/8.1.2",
query: "arch=arm64",
wantAr: buildmeta.ArchARM64,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, _ := http.NewRequest("GET", "http://example.com/api?"+tt.query, nil)
if tt.ua != "" {
req.Header.Set("User-Agent", tt.ua)
}
got := uadetect.FromRequest(req)
if tt.wantOS != "" && got.OS != tt.wantOS {
t.Errorf("OS = %q, want %q", got.OS, tt.wantOS)
}
if tt.wantAr != "" && got.Arch != tt.wantAr {
t.Errorf("Arch = %q, want %q", got.Arch, tt.wantAr)
}
})
}
}
func TestFullParse(t *testing.T) {
r := uadetect.Parse("Darwin 23.1.0 arm64")
if r.OS != buildmeta.OSDarwin {
t.Errorf("OS = %q, want %q", r.OS, buildmeta.OSDarwin)
}
if r.Arch != buildmeta.ArchARM64 {
t.Errorf("Arch = %q, want %q", r.Arch, buildmeta.ArchARM64)
}
if r.Libc != buildmeta.LibcNone {
t.Errorf("Libc = %q, want %q", r.Libc, buildmeta.LibcNone)
}
}

View File

@@ -0,0 +1,2 @@
source = mariadbdist
asset_filter = galera

View File

@@ -1,5 +1,4 @@
#!/bin/sh
# shellcheck disable=SC2029,SC2088
set -e
set -u
@@ -11,13 +10,13 @@ g_out="agents/tmp/${g_bin}"
g_remote_bin="~/bin/${g_bin}"
case "${g_host}" in
beta.webi.sh) g_remote_conf="~/srv/beta.webinstall.dev/installers/" ;;
next.webi.sh) g_remote_conf="~/srv/next.webinstall.dev/installers/" ;;
*) g_remote_conf="~/srv/webid/installers/" ;;
beta.webi.sh) g_remote_conf="~/srv/beta.webinstall.dev/installers/" ;;
next.webi.sh) g_remote_conf="~/srv/next.webinstall.dev/installers/" ;;
*) g_remote_conf="~/srv/webid/installers/" ;;
esac
fn_build() {
b_version="$(git describe --tags --always 2>/dev/null || echo '0.0.0-dev')"
b_version="$(git describe --tags --always 2> /dev/null || echo '0.0.0-dev')"
b_commit="$(git rev-parse --short HEAD)"
b_date="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
b_ldflags="-X main.version=${b_version} -X main.commit=${b_commit} -X main.date=${b_date}"
@@ -29,7 +28,7 @@ fn_build() {
fn_deploy() {
printf 'Stopping %s on %s...\n' "${g_bin}" "${g_host}"
ssh "${g_host}" "~/.local/bin/serviceman stop ${g_bin}" 2>/dev/null || true
ssh "${g_host}" "~/.local/bin/serviceman stop ${g_bin}" 2> /dev/null || true
printf 'Uploading binary...\n'
scp "${g_out}" "${g_host}:${g_remote_bin}"