mirror of
https://github.com/webinstall/webi-installers.git
synced 2026-05-30 04:22:47 +00:00
Compare commits
10 Commits
v1.3.2
...
feat/webi-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d1fc7bb62 | ||
|
|
571a93cf06 | ||
|
|
e0e4d558f1 | ||
|
|
f2994e5756 | ||
|
|
b8f5a61121 | ||
|
|
2390afa006 | ||
|
|
b197ca4a2a | ||
|
|
a07d59a01a | ||
|
|
b38a4b7894 | ||
|
|
f73755e7d7 |
Submodule _webi/build-classifier updated: f9cc9f3e19...9f87804eb4
@@ -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'];
|
||||
}
|
||||
|
||||
|
||||
@@ -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")"
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
104
internal/platlatest/platlatest.go
Normal file
104
internal/platlatest/platlatest.go
Normal 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
|
||||
}
|
||||
104
internal/platlatest/platlatest_test.go
Normal file
104
internal/platlatest/platlatest_test.go
Normal 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
264
internal/render/render.go
Normal 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)
|
||||
}
|
||||
90
internal/render/render_test.go
Normal file
90
internal/render/render_test.go
Normal 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
303
internal/resolve/resolve.go
Normal 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
|
||||
}
|
||||
422
internal/resolve/resolve_cache_test.go
Normal file
422
internal/resolve/resolve_cache_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
250
internal/resolve/resolve_test.go
Normal file
250
internal/resolve/resolve_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
416
internal/resolver/resolver.go
Normal file
416
internal/resolver/resolver.go
Normal 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",
|
||||
}
|
||||
}
|
||||
}
|
||||
290
internal/resolver/resolver_cache_test.go
Normal file
290
internal/resolver/resolver_cache_test.go
Normal 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
|
||||
}
|
||||
397
internal/resolver/resolver_test.go
Normal file
397
internal/resolver/resolver_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
247
internal/uadetect/uadetect.go
Normal file
247
internal/uadetect/uadetect.go
Normal 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
|
||||
}
|
||||
190
internal/uadetect/uadetect_test.go
Normal file
190
internal/uadetect/uadetect_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
2
mariadb-galera/releases.conf
Normal file
2
mariadb-galera/releases.conf
Normal file
@@ -0,0 +1,2 @@
|
||||
source = mariadbdist
|
||||
asset_filter = galera
|
||||
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user