diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c31c4f4c..535736de 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -69,7 +69,10 @@ jobs: set -euo pipefail if [[ "$GITHUB_REF" == refs/tags/v* ]]; then VERSION="$GITHUB_REF_NAME"; else VERSION="dev-${GITHUB_SHA:0:12}"; fi mkdir -p bin - go build -trimpath -ldflags "-X main.version=${VERSION}" -o "bin/lem${EXE}" ./cli + # main.backend is "cpu" for every cell this job builds (the other backends — + # metal, amd, cuda — are built by their own dedicated jobs/CI); lem's + # self-update asset-selection reads it to pick its own release asset. + go build -trimpath -ldflags "-X main.version=${VERSION} -X main.backend=cpu" -o "bin/lem${EXE}" ./cli test -s "bin/lem${EXE}" - uses: actions/upload-artifact@v4 @@ -110,7 +113,12 @@ jobs: set -euo pipefail if [[ "$GITHUB_REF" == refs/tags/v* ]]; then VERSION="$GITHUB_REF_NAME"; else VERSION="dev-${GITHUB_SHA:0:12}"; fi task metallib - task build:embed VERSION="${VERSION}" + # GO_VERSION_LDFLAGS is Taskfile.yml's own var, threaded into build:embed's + # -ldflags; overriding it here on the CLI (Task's documented per-invocation + # override, e.g. the Taskfile's own "task build VERSION=1.2.3" example) folds + # in main.backend=metal without touching Taskfile.yml, whose ldflags string + # otherwise only knows about main.version. + task build:embed VERSION="${VERSION}" GO_VERSION_LDFLAGS="-X main.version=${VERSION} -X main.backend=metal" test -s bin/lem - uses: actions/upload-artifact@v4 @@ -161,7 +169,16 @@ jobs: echo "version=$GITHUB_REF_NAME" >> "$GITHUB_OUTPUT" echo "tag=$GITHUB_REF_NAME" >> "$GITHUB_OUTPUT" echo "name=$GITHUB_REF_NAME" >> "$GITHUB_OUTPUT" - echo "prerelease=false" >> "$GITHUB_OUTPUT" + # A tag containing '-' (v1.2.3-beta.1, v1.2.3-alpha.2, even a bare + # v2.0.0-rc.1) is a semver prerelease — publish it as a GitHub + # prerelease so go-update's channel filter (alpha/beta substring, + # else the prerelease flag) classifies it correctly instead of + # every tag looking "stable". + if [[ "$GITHUB_REF_NAME" == *-* ]]; then + echo "prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "prerelease=false" >> "$GITHUB_OUTPUT" + fi else echo "version=dev-${GITHUB_SHA:0:12}" >> "$GITHUB_OUTPUT" echo "tag=dev" >> "$GITHUB_OUTPUT" diff --git a/cli/go.mod b/cli/go.mod index a8683cc1..0187d4ca 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -37,6 +37,7 @@ require ( dappco.re/go/i18n v0.12.1 // indirect dappco.re/go/log v0.13.1 // indirect dappco.re/go/ratelimit v0.12.1 // indirect + dappco.re/go/update v0.13.0 forge.lthn.ai/Snider/Enchantrix v0.0.5 // indirect github.com/99designs/gqlgen v0.17.88 // indirect github.com/KyleBanks/depth v1.2.1 // indirect diff --git a/cli/go.sum b/cli/go.sum index 2830a783..213c68f0 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -48,6 +48,8 @@ dappco.re/go/ratelimit v0.12.1 h1:3NTFrsNGfuyZYWsKXTOyJbKSZiN2f6/9abeAbvaRV7w= dappco.re/go/ratelimit v0.12.1/go.mod h1:5Vdbbyyh99N3tMMqYVbTj8/Bg/jg3f31fscWsAKonuk= dappco.re/go/store v0.14.1 h1:om6d865punooegHQFZ6tnZoFOp/Ec0xh0iW/kKx/pWc= dappco.re/go/store v0.14.1/go.mod h1:ys8oTbYaB5wgxWQXcI48b0Mjm0aOld9PGJnqlU7vNs0= +dappco.re/go/update v0.13.0 h1:vIDjSCeLXC8EFmYFnOa7JURrrebet0GAz0rbfXoyLz0= +dappco.re/go/update v0.13.0/go.mod h1:nXfDo7mOC+FgqI3QIPjGhq7IRktc9X5FBVOlgOXdQUg= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= forge.lthn.ai/Snider/Borg v0.3.1 h1:gfC1ZTpLoZai07oOWJiVeQ8+qJYK8A795tgVGJHbVL8= diff --git a/cli/main.go b/cli/main.go index 5b33802a..effa91d9 100644 --- a/cli/main.go +++ b/cli/main.go @@ -45,6 +45,14 @@ var commandName = "lem" // an unstamped build reports "dev". var version = "dev" +// backend is stamped by the build (-ldflags "-X main.backend="), +// naming the GPU backend this binary was compiled for (docs/release-artifacts.md's +// {os}-{arch}-lem-{backend}-... grid). It has no locally meaningful default: `lem +// update`'s asset-selection needs the real value to find this platform's release +// asset, so an unstamped build reports "" and fails loudly there rather than +// silently guessing a backend. +var backend = "" + func cliName() string { name := core.Trim(commandName) if name == "" { @@ -92,6 +100,8 @@ func runCommand(ctx context.Context, args []string, stdout, stderr io.Writer) in return runSpecCommand(ctx, args[1:], stdout, stderr) case "ebook": return runEbookCommand(ctx, args[1:], stdout, stderr) + case "update": + return runUpdateCommand(ctx, args[1:], stdout, stderr) case "tui": return tui.Run(ctx, args[1:], stdout, stderr) case "version", "-v", "--version": @@ -127,6 +137,7 @@ func printUsage(w io.Writer) { core.WriteString(w, " pack build/inspect/list/extract .model containers (no weights loaded)\n") core.WriteString(w, " ebook render a model directory as a valid EPUB3 (weights as base64 plates)\n") core.WriteString(w, " version print the binary name and stamped version\n") + core.WriteString(w, " update self-update from GitHub releases (channel derived from the stamped version)\n") core.WriteString(w, "\n") core.WriteString(w, "Convert\n") core.WriteString(w, " quant quantise a dense model dir (MLX, GPTQ, FP8, NF4, or GGUF)\n") diff --git a/cli/main_test.go b/cli/main_test.go index 67cfa2da..2bfcdeb0 100644 --- a/cli/main_test.go +++ b/cli/main_test.go @@ -45,6 +45,7 @@ func TestRunCommand_Dispatch(t *testing.T) { {"data route", []string{"data"}, 2, false}, {"ebook route", []string{"ebook"}, 2, false}, {"spec route", []string{"spec", "--output", specOut}, 0, false}, + {"update route", []string{"update", "--help"}, 0, false}, {"version route", []string{"version"}, 0, false}, {"version flag", []string{"--version"}, 0, false}, } { @@ -91,6 +92,7 @@ func TestHelpPresentsLongFlagsOnly(t *testing.T) { {"data", "review", "--help"}, {"spec", "--help"}, {"ebook", "--help"}, + {"update", "--help"}, {"tui", "--help"}, } { t.Run(strings.Join(argv, "_"), func(t *testing.T) { @@ -115,7 +117,7 @@ func TestPrintUsage(t *testing.T) { var buf bytes.Buffer printUsage(&buf) out := buf.String() - for _, verb := range []string{"serve", "generate", "ssd", "sft", "tune", "pack", "ebook", "quant", "data", "spec"} { + for _, verb := range []string{"serve", "generate", "ssd", "sft", "tune", "pack", "ebook", "quant", "data", "spec", "update"} { if !core.Contains(out, verb) { t.Errorf("usage missing verb %q", verb) } diff --git a/cli/update.go b/cli/update.go new file mode 100644 index 00000000..b9c9e691 --- /dev/null +++ b/cli/update.go @@ -0,0 +1,381 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package main + +import ( + "archive/zip" + "bytes" + "context" + "flag" + "io" + "runtime" + + core "dappco.re/go" + updater "dappco.re/go/update" +) + +// The GitHub repository lem's own releases live in, for go-update's +// GithubClient primitives (see docs/release-artifacts.md for the asset grid +// those releases carry). +const ( + updateRepoOwner = "dAppCore" + updateRepoName = "go-inference" + updateDevTag = "dev" +) + +// runUpdateCommand implements `lem update`: self-update from GitHub releases. +// +// The channel defaults to whatever the running binary's own version implies +// (deriveUpdateChannel) rather than being a separate setting the user +// tracks — a v1.2.3 build follows stable, v1.2.3-beta.1 follows beta, +// dev- follows the rolling dev prerelease. --channel overrides it. +// +// lem update # check + apply, channel from the binary's version +// lem update --check # report only, never download +// lem update --channel=dev # follow the rolling dev prerelease regardless +func runUpdateCommand(ctx context.Context, args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet(cliCommandName("update"), flag.ContinueOnError) + fs.SetOutput(stderr) + channelFlag := fs.String("channel", "", "release channel: stable, beta, alpha, or dev (default: derived from the binary's own version)") + checkOnly := fs.Bool("check", false, "only check for an available update, do not apply it") + fs.Usage = func() { + core.WriteString(stderr, core.Sprintf("Usage: %s update [flags]\n", cliCommandName("update"))) + core.WriteString(stderr, "\n") + core.WriteString(stderr, "Self-update lem from GitHub releases. The channel defaults to whatever\n") + core.WriteString(stderr, "the running binary's own version implies: a vX.Y.Z build follows stable,\n") + core.WriteString(stderr, "vX.Y.Z-beta.N follows beta, vX.Y.Z-alpha.N follows alpha, and a\n") + core.WriteString(stderr, "dev- build follows the rolling dev prerelease.\n") + core.WriteString(stderr, "\n") + core.WriteString(stderr, "Flags:\n") + printFlagBlock(stderr, fs) + } + if err := fs.Parse(args); err != nil { + if core.Is(err, flag.ErrHelp) { + return 0 + } + return 2 + } + + channel := core.Trim(*channelFlag) + if channel == "" { + channel = deriveUpdateChannel(version) + } + switch channel { + case "stable", "beta", "alpha", "dev": + default: + core.Print(stderr, "%s: unknown channel %q (want stable, beta, alpha, or dev)", cliCommandName("update"), channel) + return 2 + } + + // CheckForNewerVersion/CheckOnly (reached via fetchUpdateRelease below) + // compare against this package var, not against go-update's own built-in + // placeholder — every comparison in this command must be relative to + // lem's own stamped version. + updater.Version = version + + release, semverAvailable, err := fetchUpdateRelease(ctx, channel) + if err != nil { + core.Print(stderr, "%s: %v", cliCommandName("update"), err) + return 1 + } + if release == nil { + core.Print(stdout, "No release found on the %s channel.", channel) + return 0 + } + + core.Print(stdout, "Current version: %s", version) + core.Print(stdout, "Channel: %s", channel) + + // For the semver channels, "up to date" is already known from the + // release tag alone (semverAvailable), with no asset involved — so a + // platform this release simply has no asset for (e.g. a windows build + // that was never shipped) is never blocked from reporting "nothing to + // do" when there genuinely is nothing to do. Asset-selection is only + // reached when there is a real reason to need the asset: reporting or + // applying an available update, or (for dev) determining availability + // at all, since a rolling tag has no semver to decide it from. + if channel != updateDevTag && !semverAvailable { + core.Print(stdout, "Already up to date (%s).", release.TagName) + return 0 + } + + invokedName := core.TrimSuffix(commandName, ".exe") + prefix, role, err := updateAssetPrefix(runtime.GOOS, runtime.GOARCH, backend, invokedName) + if err != nil { + core.Print(stderr, "%s: %v", cliCommandName("update"), err) + return 1 + } + + asset, err := selectReleaseAsset(release.Assets, prefix) + if err != nil { + core.Print(stderr, "%s: %v", cliCommandName("update"), err) + return 1 + } + // The version token embedded in the winning asset's own name — e.g. + // "dev-c65fa359abcd" or "v1.2.3" — trimming the exact prefix we matched + // and the .zip suffix leaves exactly that token, no separate parse step. + assetVersion := core.TrimSuffix(core.TrimPrefix(asset.Name, prefix), ".zip") + + // The dev channel has no semver to compare (a rolling tag is not a + // version), so its availability is a plain string inequality between the + // running binary's version and the asset it would download — never a + // semver.Compare on "dev-". + available := semverAvailable + if channel == updateDevTag { + available = version != assetVersion + } + + core.Print(stdout, "Platform: %s/%s (backend=%s, role=%s)", runtime.GOOS, runtime.GOARCH, backend, role) + core.Print(stdout, "Latest on %s: %s (asset %s)", channel, assetVersion, asset.Name) + + if !available { + core.Println("Already up to date.") + return 0 + } + + if *checkOnly { + core.Print(stdout, "Update available: %s -> %s", version, assetVersion) + core.Print(stdout, "Run %s update to apply it.", cliName()) + return 0 + } + + core.Println("Downloading update...") + if err := applyReleaseAsset(ctx, invokedName, asset); err != nil { + core.Print(stderr, "%s: %v", cliCommandName("update"), err) + return 1 + } + core.Print(stdout, "Updated to %s. Run %s again to use it.", assetVersion, cliName()) + return 0 +} + +// deriveUpdateChannel infers the release channel from a version string — +// the same shape build.yml's own prerelease-honesty check and go-update's +// determineChannel use: alpha/beta substrings win outright, any other +// hyphenated (prerelease-shaped) suffix falls back to beta (mirroring +// determineChannel's "prerelease flag with no alpha/beta substring -> beta" +// rule, with the tag's hyphen standing in for the flag since there is no +// GitHub release object here yet), a "dev" or "dev-*" version is the +// rolling channel go-update's alpha/beta/stable classifier has no concept +// of, and anything left is stable. This is the release-channels-as-a- +// side-effect design: a binary knows which channel it belongs to just by +// reading its own stamped version, with no separate tracked setting. +// +// deriveUpdateChannel("v1.2.3") // "stable" +// deriveUpdateChannel("v1.2.3-beta.1") // "beta" +// deriveUpdateChannel("v1.2.3-alpha.2") // "alpha" +// deriveUpdateChannel("dev-c65fa359abcd") // "dev" +func deriveUpdateChannel(v string) string { + lower := core.Lower(v) + switch { + case lower == "dev" || core.HasPrefix(lower, "dev-"): + return "dev" + case core.Contains(lower, "alpha"): + return "alpha" + case core.Contains(lower, "beta"): + return "beta" + case core.Contains(lower, "-"): + return "beta" + default: + return "stable" + } +} + +// fetchUpdateRelease fetches the release for channel through go-update's +// GithubClient and, for the semver channels, whether it is already known to +// be newer than updater.Version. +// +// The dev channel is fetched by its exact tag (GetReleaseByTag) rather than +// through GetLatestRelease's alpha/beta/stable classification: a rolling +// "dev" prerelease republished on every push has no semver to bucket, and +// determineChannel's substring/prerelease-flag rules were never meant to +// target one exact tag string. Its availability is NOT computed here — see +// the dev-channel handling in runUpdateCommand — because that needs the +// asset actually selected, not just the release. +// +// The semver channels go through CheckForNewerVersion and unpack its result +// via the VersionCheckResult interface, so this reuses go-update's own +// semver comparison instead of re-implementing it, while still reaching the +// release's Assets for this repo's own asset-selection (GetDownloadURL's +// automatic matching cannot do that job — see updateAssetPrefix). +func fetchUpdateRelease(ctx context.Context, channel string) (release *updater.Release, available bool, err error) { + client := updater.NewGithubClient() + + if channel == updateDevTag { + result := client.GetReleaseByTag(ctx, updateRepoOwner, updateRepoName, updateDevTag) + if !result.OK { + return nil, false, core.E("fetchUpdateRelease", "failed to fetch the dev release", core.NewError(result.Error())) + } + release, _ = result.Value.(*updater.Release) + return release, false, nil + } + + check := updater.CheckForNewerVersion(updateRepoOwner, updateRepoName, channel, true) + if !check.OK { + return nil, false, core.E("fetchUpdateRelease", "failed to check for updates", core.NewError(check.Error())) + } + vc, ok := check.Value.(updater.VersionCheckResult) + if !ok { + return nil, false, core.E("fetchUpdateRelease", "unexpected CheckForNewerVersion result payload", nil) + } + return vc.Release(), vc.Available(), nil +} + +// updateAssetPrefix builds the exact release-asset-name prefix this binary +// should look for, and reports which packaging (native/driver) that prefix +// implies. +// +// Both packagings ship the SAME compiled bytes: build.yml (docs/release- +// artifacts.md's "build once, package twice") compiles one binary per cell, +// then copies it into two differently-named zips — `lem` for Native, +// `lem-{backend}` for Driver. No ldflags var could distinguish Native from +// Driver, because there is nothing to distinguish at compile time — the +// split happens entirely in post-build packaging. The invoked binary's own +// name is therefore the only signal that exists for this, and it is unsound +// against a deliberate rename or symlink; that is an accepted limitation of +// the two-packagings-one-binary design, not an oversight here. +func updateAssetPrefix(goos, goarch, backend, invokedName string) (prefix, role string, err error) { + if backend == "" { + return "", "", core.E("updateAssetPrefix", "binary has no stamped backend (built without -X main.backend=) — cannot select a release asset", nil) + } + osName := releaseOSName(goos) + archName := releaseArchName(goarch) + switch invokedName { + case "lem": + return core.Sprintf("%s-%s-lem-%s-", osName, archName, backend), "native", nil + case "lem-" + backend: + return core.Sprintf("%s-%s-lem-driver-%s-", osName, archName, backend), "driver", nil + default: + return "", "", core.E("updateAssetPrefix", core.Sprintf( + "cannot tell whether invoked name %q is the Native or Driver build for backend %q (expected \"lem\" or \"lem-%s\"); self-update cannot pick an asset for a renamed or symlinked binary", + invokedName, backend, backend), nil) + } +} + +// releaseOSName maps a GOOS to the release grid's os dimension: darwin -> +// macos; linux and windows are spelled the same on both sides. +func releaseOSName(goos string) string { + if goos == "darwin" { + return "macos" + } + return goos +} + +// releaseArchName maps a GOARCH to the release grid's arch dimension. +func releaseArchName(goarch string) string { + switch goarch { + case "amd64": + return "x86_64" + case "arm64": + return "aarch64" + default: + return goarch + } +} + +// selectReleaseAsset finds the one release asset whose name starts with +// prefix. +// +// go-update's own GetDownloadURL cannot do this job: its automatic +// GOOS/GOARCH matching looks for runtime.GOOS/GOARCH substrings ("darwin", +// "arm64") in the asset name, but this repo's names spell the same +// dimensions differently ("macos", "aarch64" — see releaseOSName / +// releaseArchName), so it would either match nothing (darwin never appears +// in a macos-* name) or match the wrong thing by accident (GOOS "linux" +// happens to be spelled the same, so it would silently pick the first +// linux-flavoured asset regardless of backend or role). Zero or multiple +// matches is a loud, typed error naming exactly what was looked for — and +// for multiple matches, exactly what matched — rather than guessing either +// way. +func selectReleaseAsset(assets []updater.ReleaseAsset, prefix string) (updater.ReleaseAsset, error) { + var matches []updater.ReleaseAsset + for _, a := range assets { + if core.HasPrefix(a.Name, prefix) { + matches = append(matches, a) + } + } + switch len(matches) { + case 1: + return matches[0], nil + case 0: + return updater.ReleaseAsset{}, core.E("selectReleaseAsset", core.Sprintf("no release asset found with prefix %q", prefix), nil) + default: + names := make([]string, len(matches)) + for i, m := range matches { + names[i] = m.Name + } + return updater.ReleaseAsset{}, core.E("selectReleaseAsset", core.Sprintf("%d release assets match prefix %q (want exactly one): %s", len(matches), prefix, core.Join(", ", names...)), nil) + } +} + +// applyReleaseAsset downloads asset's zip, extracts the single binary named +// wantName, and applies it via go-update's DoUpdateFromReader. +// +// go-update's own DoUpdate cannot be used here: it fetches a URL and feeds +// the raw HTTP response body straight into selfupdate.Apply, which assumes +// that body IS the new binary's bytes. Every asset in this repo's grid is a +// zip holding one binary (docs/release-artifacts.md's zip layout rule) — +// even the plain cpu/metal cells — so DoUpdate would feed selfupdate.Apply a +// zip archive and corrupt the running binary rather than replace it. +// DoUpdateFromReader is the fix upstream: download and unzip HERE, then hand +// the extracted binary bytes to the same apply/rollback machinery DoUpdate +// itself uses internally, instead of re-implementing the binary swap. +func applyReleaseAsset(ctx context.Context, wantName string, asset updater.ReleaseAsset) error { + client := updater.NewHTTPClient() + reqResult := core.NewHTTPRequestContext(ctx, "GET", asset.DownloadURL, nil) + if !reqResult.OK { + return core.E("applyReleaseAsset", "failed to build download request", core.NewError(reqResult.Error())) + } + req, _ := reqResult.Value.(*core.Request) + + resp, err := client.Do(req) + if err != nil { + return core.E("applyReleaseAsset", "failed to download release asset", err) + } + defer resp.Body.Close() + if resp.StatusCode != core.StatusOK { + return core.E("applyReleaseAsset", core.Sprintf("download failed: %s", resp.Status), nil) + } + + bodyResult := core.ReadAll(resp.Body) + if !bodyResult.OK { + return core.E("applyReleaseAsset", "failed to read release asset", core.NewError(bodyResult.Error())) + } + + binary, err := extractZipEntry([]byte(bodyResult.Value.(string)), wantName) + if err != nil { + return err + } + + if r := updater.DoUpdateFromReader(bytes.NewReader(binary)); !r.OK { + return core.E("applyReleaseAsset", "failed to apply update", core.NewError(r.Error())) + } + return nil +} + +// extractZipEntry reads the single named entry out of a zip archive held +// entirely in memory. Every release asset in this repo's grid is small +// enough — one binary, occasionally plus a GPU kernel sidecar for amd/cuda +// that this function simply ignores by name — that streaming to a temp file +// first would buy nothing. +func extractZipEntry(zipBytes []byte, wantName string) ([]byte, error) { + reader, err := zip.NewReader(bytes.NewReader(zipBytes), int64(len(zipBytes))) + if err != nil { + return nil, core.E("extractZipEntry", "not a valid zip archive", err) + } + for _, f := range reader.File { + if f.Name != wantName { + continue + } + rc, err := f.Open() + if err != nil { + return nil, core.E("extractZipEntry", core.Sprintf("failed to open %q in archive", wantName), err) + } + defer rc.Close() + dataResult := core.ReadAll(rc) + if !dataResult.OK { + return nil, core.E("extractZipEntry", core.Sprintf("failed to read %q from archive", wantName), core.NewError(dataResult.Error())) + } + return []byte(dataResult.Value.(string)), nil + } + return nil, core.E("extractZipEntry", core.Sprintf("no entry named %q in the downloaded archive", wantName), nil) +} diff --git a/cli/update_test.go b/cli/update_test.go new file mode 100644 index 00000000..e62c8a5e --- /dev/null +++ b/cli/update_test.go @@ -0,0 +1,587 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package main + +import ( + "archive/zip" + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "runtime" + "testing" + + core "dappco.re/go" + updater "dappco.re/go/update" +) + +// --- deriveUpdateChannel ----------------------------------------------- + +func TestDeriveUpdateChannel_Good(t *testing.T) { + for _, tc := range []struct{ version, want string }{ + {"v1.2.3", "stable"}, + {"1.2.3", "stable"}, + {"v1.2.3-alpha.2", "alpha"}, + {"v1.2.3-beta.1", "beta"}, + } { + if got := deriveUpdateChannel(tc.version); got != tc.want { + t.Errorf("deriveUpdateChannel(%q) = %q, want %q", tc.version, got, tc.want) + } + } +} + +func TestDeriveUpdateChannel_Bad(t *testing.T) { + // The rolling shapes: a bare "dev" fallback and a real dev- build. + for _, tc := range []struct{ version, want string }{ + {"dev", "dev"}, + {"dev-c65fa359abcd", "dev"}, + {"DEV-C65FA359ABCD", "dev"}, + } { + if got := deriveUpdateChannel(tc.version); got != tc.want { + t.Errorf("deriveUpdateChannel(%q) = %q, want %q", tc.version, got, tc.want) + } + } +} + +func TestDeriveUpdateChannel_Ugly(t *testing.T) { + // A hyphenated prerelease suffix that names neither alpha nor beta still + // falls back to beta (mirroring go-update's own isPreRelease-with-no- + // alpha/beta-substring -> beta rule); an empty version has no hyphen and + // defaults to stable. + for _, tc := range []struct{ version, want string }{ + {"v2.0.0-rc.1", "beta"}, + {"", "stable"}, + } { + if got := deriveUpdateChannel(tc.version); got != tc.want { + t.Errorf("deriveUpdateChannel(%q) = %q, want %q", tc.version, got, tc.want) + } + } +} + +// --- updateAssetPrefix --------------------------------------------------- + +func TestUpdateAssetPrefix_Good(t *testing.T) { + for _, tc := range []struct { + name string + goos, goarch, backend, invoked string + wantPrefix, wantRole string + }{ + {"native macos metal", "darwin", "arm64", "metal", "lem", "macos-aarch64-lem-metal-", "native"}, + {"native linux cpu amd64", "linux", "amd64", "cpu", "lem", "linux-x86_64-lem-cpu-", "native"}, + {"driver linux amd", "linux", "amd64", "amd", "lem-amd", "linux-x86_64-lem-driver-amd-", "driver"}, + {"driver macos metal", "darwin", "arm64", "metal", "lem-metal", "macos-aarch64-lem-driver-metal-", "driver"}, + } { + t.Run(tc.name, func(t *testing.T) { + prefix, role, err := updateAssetPrefix(tc.goos, tc.goarch, tc.backend, tc.invoked) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if prefix != tc.wantPrefix { + t.Errorf("prefix = %q, want %q", prefix, tc.wantPrefix) + } + if role != tc.wantRole { + t.Errorf("role = %q, want %q", role, tc.wantRole) + } + }) + } +} + +func TestUpdateAssetPrefix_Bad(t *testing.T) { + // A renamed or symlinked binary: the invoked name matches neither the + // Native nor the Driver shape for its own backend. + _, _, err := updateAssetPrefix("linux", "amd64", "cpu", "myrenamed-lem") + if err == nil { + t.Fatal("expected an error for an unrecognised invoked name") + } + if !core.Contains(err.Error(), "myrenamed-lem") { + t.Errorf("error %q does not name the unrecognised invoked name", err.Error()) + } +} + +func TestUpdateAssetPrefix_Ugly(t *testing.T) { + // An unstamped binary (backend == "") cannot select an asset at all — + // this must fail loudly rather than guess a backend. + _, _, err := updateAssetPrefix("linux", "amd64", "", "lem") + if err == nil { + t.Fatal("expected an error for an unstamped backend") + } + if !core.Contains(err.Error(), "backend") { + t.Errorf("error %q does not mention the missing backend", err.Error()) + } +} + +// --- releaseOSName / releaseArchName ------------------------------------- + +func TestReleaseOSName_Good(t *testing.T) { + for _, tc := range []struct{ goos, want string }{ + {"linux", "linux"}, + {"windows", "windows"}, + } { + if got := releaseOSName(tc.goos); got != tc.want { + t.Errorf("releaseOSName(%q) = %q, want %q", tc.goos, got, tc.want) + } + } +} + +func TestReleaseOSName_Ugly(t *testing.T) { + // darwin is the one GOOS this repo's grid spells differently. + if got := releaseOSName("darwin"); got != "macos" { + t.Errorf("releaseOSName(darwin) = %q, want macos", got) + } +} + +func TestReleaseArchName_Good(t *testing.T) { + for _, tc := range []struct{ goarch, want string }{ + {"amd64", "x86_64"}, + {"arm64", "aarch64"}, + } { + if got := releaseArchName(tc.goarch); got != tc.want { + t.Errorf("releaseArchName(%q) = %q, want %q", tc.goarch, got, tc.want) + } + } +} + +func TestReleaseArchName_Ugly(t *testing.T) { + // An unmapped arch passes through unchanged — it will simply never + // prefix-match a real asset, the correct loud failure via + // selectReleaseAsset rather than a silent wrong guess. + if got := releaseArchName("386"); got != "386" { + t.Errorf("releaseArchName(386) = %q, want 386 (pass-through)", got) + } +} + +// --- selectReleaseAsset --------------------------------------------------- + +func TestSelectReleaseAsset_Good(t *testing.T) { + assets := []updater.ReleaseAsset{ + {Name: "linux-x86_64-lem-cpu-v1.2.3.zip", DownloadURL: "https://example.com/a"}, + {Name: "linux-x86_64-lem-driver-cpu-v1.2.3.zip", DownloadURL: "https://example.com/b"}, + } + + got, err := selectReleaseAsset(assets, "linux-x86_64-lem-cpu-") + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Name != assets[0].Name { + t.Errorf("got %q, want %q", got.Name, assets[0].Name) + } +} + +func TestSelectReleaseAsset_Bad(t *testing.T) { + // No asset for this platform at all. + _, err := selectReleaseAsset([]updater.ReleaseAsset{{Name: "windows-x86_64-lem-cpu-v1.2.3.zip"}}, "linux-x86_64-lem-cpu-") + + if err == nil { + t.Fatal("expected an error for zero matches") + } + if !core.Contains(err.Error(), "linux-x86_64-lem-cpu-") { + t.Errorf("error %q does not name the prefix that was looked for", err.Error()) + } +} + +func TestSelectReleaseAsset_Ugly(t *testing.T) { + // Two assets both matching the prefix is also a loud failure, naming + // what matched — never a silent first-pick. + assets := []updater.ReleaseAsset{ + {Name: "linux-x86_64-lem-cpu-v1.2.3.zip"}, + {Name: "linux-x86_64-lem-cpu-v1.2.4.zip"}, + } + + _, err := selectReleaseAsset(assets, "linux-x86_64-lem-cpu-") + + if err == nil { + t.Fatal("expected an error for multiple matches") + } + if !core.Contains(err.Error(), "2 release assets") { + t.Errorf("error %q does not report the match count", err.Error()) + } +} + +// --- fetchUpdateRelease --------------------------------------------------- + +// updateFetchTestClient is a configurable updater.GithubClient stub — +// mirroring go-update's own flowsTestClient/updaterTestClient pattern — used +// to drive fetchUpdateRelease and runUpdateCommand without touching the +// network. +type updateFetchTestClient struct { + latest *updater.Release + latestErr string + tagged *updater.Release + taggedErr string +} + +func (c updateFetchTestClient) GetPublicRepos(ctx context.Context, userOrOrg string) core.Result { + return core.Ok([]string{}) +} + +func (c updateFetchTestClient) GetLatestRelease(ctx context.Context, owner, repo, channel string) core.Result { + if c.latestErr != "" { + return core.Fail(core.E("updateFetchTestClient.GetLatestRelease", c.latestErr, nil)) + } + return core.Ok(c.latest) +} + +func (c updateFetchTestClient) GetReleaseByPullRequest(ctx context.Context, owner, repo string, prNumber int) core.Result { + return core.Ok((*updater.Release)(nil)) +} + +func (c updateFetchTestClient) GetReleaseByTag(ctx context.Context, owner, repo, tag string) core.Result { + if c.taggedErr != "" { + return core.Fail(core.E("updateFetchTestClient.GetReleaseByTag", c.taggedErr, nil)) + } + return core.Ok(c.tagged) +} + +func withUpdateGithubClient(client updater.GithubClient) func() { + original := updater.NewGithubClient + updater.NewGithubClient = func() updater.GithubClient { return client } + return func() { updater.NewGithubClient = original } +} + +func withUpdateVersion(v string) func() { + original := updater.Version + updater.Version = v + return func() { updater.Version = original } +} + +func TestFetchUpdateRelease_Good(t *testing.T) { + // A semver channel: CheckForNewerVersion's own comparison decides + // availability, unpacked through the VersionCheckResult interface. + defer withUpdateVersion("1.0.0")() + defer withUpdateGithubClient(updateFetchTestClient{latest: &updater.Release{TagName: "v1.1.0"}})() + + release, available, err := fetchUpdateRelease(context.Background(), "stable") + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !available { + t.Error("expected available = true (1.0.0 -> v1.1.0)") + } + if release == nil || release.TagName != "v1.1.0" { + t.Errorf("release = %+v, want TagName v1.1.0", release) + } +} + +func TestFetchUpdateRelease_Bad(t *testing.T) { + defer withUpdateVersion("1.0.0")() + defer withUpdateGithubClient(updateFetchTestClient{latestErr: "github down"})() + + _, _, err := fetchUpdateRelease(context.Background(), "stable") + + if err == nil { + t.Fatal("expected an error") + } + if !core.Contains(err.Error(), "failed to check for updates") { + t.Errorf("error %q missing the expected wrap message", err.Error()) + } +} + +func TestFetchUpdateRelease_Ugly(t *testing.T) { + // The dev channel: fetched by exact tag, never through the semver path; + // availability is always reported false here — the caller compares + // against the selected asset's own embedded version instead (see + // runUpdateCommand's dev-channel handling). + defer withUpdateGithubClient(updateFetchTestClient{tagged: &updater.Release{TagName: "dev"}})() + + release, available, err := fetchUpdateRelease(context.Background(), "dev") + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if available { + t.Error("dev channel must never report availability from fetchUpdateRelease itself") + } + if release == nil || release.TagName != "dev" { + t.Errorf("release = %+v, want TagName dev", release) + } +} + +// --- extractZipEntry ------------------------------------------------------- + +func buildTestZip(t *testing.T, entries map[string][]byte) []byte { + t.Helper() + var buf bytes.Buffer + w := zip.NewWriter(&buf) + for name, data := range entries { + f, err := w.Create(name) + if err != nil { + t.Fatalf("create zip entry %q: %v", name, err) + } + if _, err := f.Write(data); err != nil { + t.Fatalf("write zip entry %q: %v", name, err) + } + } + if err := w.Close(); err != nil { + t.Fatalf("close zip writer: %v", err) + } + return buf.Bytes() +} + +func TestExtractZipEntry_Good(t *testing.T) { + zipBytes := buildTestZip(t, map[string][]byte{"lem": []byte("binary-bytes")}) + + data, err := extractZipEntry(zipBytes, "lem") + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(data) != "binary-bytes" { + t.Errorf("data = %q, want %q", data, "binary-bytes") + } +} + +func TestExtractZipEntry_Bad(t *testing.T) { + zipBytes := buildTestZip(t, map[string][]byte{"lem-metal": []byte("other")}) + + _, err := extractZipEntry(zipBytes, "lem") + + if err == nil { + t.Fatal("expected an error for a missing entry") + } + if !core.Contains(err.Error(), `"lem"`) { + t.Errorf("error %q does not name the missing entry", err.Error()) + } +} + +func TestExtractZipEntry_Ugly(t *testing.T) { + _, err := extractZipEntry([]byte("not a zip"), "lem") + + if err == nil { + t.Fatal("expected an error for a corrupt archive") + } + if !core.Contains(err.Error(), "not a valid zip") { + t.Errorf("error %q does not report the archive as invalid", err.Error()) + } +} + +// --- applyReleaseAsset ------------------------------------------------------ + +func withUpdateDoUpdateFromReader(fn func(core.Reader) core.Result) func() { + original := updater.DoUpdateFromReader + updater.DoUpdateFromReader = fn + return func() { updater.DoUpdateFromReader = original } +} + +func TestApplyReleaseAsset_Good(t *testing.T) { + zipBytes := buildTestZip(t, map[string][]byte{"lem": []byte("new-binary-bytes")}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(zipBytes) + })) + defer server.Close() + + var applied []byte + defer withUpdateDoUpdateFromReader(func(r core.Reader) core.Result { + data, _ := io.ReadAll(r) + applied = data + return core.Ok(nil) + })() + + err := applyReleaseAsset(context.Background(), "lem", updater.ReleaseAsset{DownloadURL: server.URL}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(applied) != "new-binary-bytes" { + t.Errorf("applied bytes = %q, want %q", applied, "new-binary-bytes") + } +} + +func TestApplyReleaseAsset_Bad(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + err := applyReleaseAsset(context.Background(), "lem", updater.ReleaseAsset{DownloadURL: server.URL}) + + if err == nil { + t.Fatal("expected an error for a failed download") + } + if !core.Contains(err.Error(), "download failed") { + t.Errorf("error %q missing the expected download-failure message", err.Error()) + } +} + +func TestApplyReleaseAsset_Ugly(t *testing.T) { + // The archive downloads fine but doesn't contain the entry we're looking + // for — e.g. a Driver zip fetched while running as Native. + zipBytes := buildTestZip(t, map[string][]byte{"lem-metal": []byte("wrong-binary")}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(zipBytes) + })) + defer server.Close() + + err := applyReleaseAsset(context.Background(), "lem", updater.ReleaseAsset{DownloadURL: server.URL}) + + if err == nil { + t.Fatal("expected an error for a missing zip entry") + } + if !core.Contains(err.Error(), `"lem"`) { + t.Errorf("error %q does not name the missing entry", err.Error()) + } +} + +// --- runUpdateCommand (end to end) ----------------------------------------- + +// withUpdateIdentity pins the build-identity package vars runUpdateCommand +// reads (version, backend, commandName) for the duration of a test. +func withUpdateIdentity(v, b, name string) func() { + origVersion, origBackend, origCommandName := version, backend, commandName + version, backend, commandName = v, b, name + return func() { version, backend, commandName = origVersion, origBackend, origCommandName } +} + +func TestRunUpdateCommand_Good(t *testing.T) { + // A stable channel with a real update available applies end to end: + // stubbed GithubClient -> real HTTP download of a real in-memory zip -> + // real unzip -> stubbed DoUpdateFromReader (never the real + // selfupdate.Apply, which would touch the test binary). + defer withUpdateIdentity("1.0.0", "cpu", "lem")() + + zipBytes := buildTestZip(t, map[string][]byte{"lem": []byte("new-bytes")}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(zipBytes) + })) + defer server.Close() + + assetName := core.Sprintf("%s-%s-lem-cpu-v1.1.0.zip", releaseOSName(runtime.GOOS), releaseArchName(runtime.GOARCH)) + defer withUpdateGithubClient(updateFetchTestClient{latest: &updater.Release{ + TagName: "v1.1.0", + Assets: []updater.ReleaseAsset{{Name: assetName, DownloadURL: server.URL}}, + }})() + var applied []byte + defer withUpdateDoUpdateFromReader(func(r core.Reader) core.Result { + data, _ := io.ReadAll(r) + applied = data + return core.Ok(nil) + })() + + var stdout, stderr bytes.Buffer + code := runUpdateCommand(context.Background(), nil, &stdout, &stderr) + + if code != 0 { + t.Fatalf("exit %d, want 0; stderr=%s", code, stderr.String()) + } + if string(applied) != "new-bytes" { + t.Errorf("applied = %q, want %q", applied, "new-bytes") + } + if !core.Contains(stdout.String(), "Updated to v1.1.0") { + t.Errorf("stdout missing the updated-to message: %s", stdout.String()) + } +} + +func TestRunUpdateCommand_Bad(t *testing.T) { + // An unrecognised --channel value is rejected before any network call — + // no GithubClient stub is even installed, so a real call would fail loud. + var stdout, stderr bytes.Buffer + code := runUpdateCommand(context.Background(), []string{"--channel", "nightly"}, &stdout, &stderr) + + if code != 2 { + t.Fatalf("exit %d, want 2; stderr=%s", code, stderr.String()) + } + if !core.Contains(stderr.String(), `unknown channel "nightly"`) { + t.Errorf("stderr missing the unknown-channel message: %s", stderr.String()) + } +} + +func TestRunUpdateCommand_Ugly(t *testing.T) { + // --check reports an available update but never downloads or applies it. + defer withUpdateIdentity("1.0.0", "cpu", "lem")() + + assetName := core.Sprintf("%s-%s-lem-cpu-v1.1.0.zip", releaseOSName(runtime.GOOS), releaseArchName(runtime.GOARCH)) + defer withUpdateGithubClient(updateFetchTestClient{latest: &updater.Release{ + TagName: "v1.1.0", + Assets: []updater.ReleaseAsset{{Name: assetName, DownloadURL: "https://updates.example.invalid/must-not-be-fetched"}}, + }})() + applyCalled := false + defer withUpdateDoUpdateFromReader(func(r core.Reader) core.Result { + applyCalled = true + return core.Ok(nil) + })() + + var stdout, stderr bytes.Buffer + code := runUpdateCommand(context.Background(), []string{"--check"}, &stdout, &stderr) + + if code != 0 { + t.Fatalf("exit %d, want 0; stderr=%s", code, stderr.String()) + } + if applyCalled { + t.Error("--check must never apply the update") + } + if !core.Contains(stdout.String(), "Update available") { + t.Errorf("stdout missing the update-available message: %s", stdout.String()) + } +} + +func TestRunUpdateCommand_AlreadyUpToDate(t *testing.T) { + defer withUpdateIdentity("1.1.0", "cpu", "lem")() + defer withUpdateGithubClient(updateFetchTestClient{latest: &updater.Release{TagName: "v1.1.0"}})() + + var stdout, stderr bytes.Buffer + code := runUpdateCommand(context.Background(), nil, &stdout, &stderr) + + if code != 0 { + t.Fatalf("exit %d, want 0; stderr=%s", code, stderr.String()) + } + if !core.Contains(stdout.String(), "Already up to date") { + t.Errorf("stdout missing the up-to-date message: %s", stdout.String()) + } +} + +func TestRunUpdateCommand_NoRelease(t *testing.T) { + defer withUpdateIdentity("1.0.0", "cpu", "lem")() + defer withUpdateGithubClient(updateFetchTestClient{latest: nil})() + + var stdout, stderr bytes.Buffer + code := runUpdateCommand(context.Background(), nil, &stdout, &stderr) + + if code != 0 { + t.Fatalf("exit %d, want 0; stderr=%s", code, stderr.String()) + } + if !core.Contains(stdout.String(), "No release found") { + t.Errorf("stdout missing the no-release message: %s", stdout.String()) + } +} + +func TestRunUpdateCommand_DevChannel(t *testing.T) { + // --channel=dev forces the rolling channel regardless of the stamped + // version, and compares the running version string against the asset's + // own embedded version — never a semver compare on a rolling tag. + defer withUpdateIdentity("dev-aaaaaaaaaaaa", "cpu", "lem")() + + assetName := core.Sprintf("%s-%s-lem-cpu-dev-bbbbbbbbbbbb.zip", releaseOSName(runtime.GOOS), releaseArchName(runtime.GOARCH)) + defer withUpdateGithubClient(updateFetchTestClient{tagged: &updater.Release{ + TagName: "dev", + Assets: []updater.ReleaseAsset{{Name: assetName}}, + }})() + + var stdout, stderr bytes.Buffer + code := runUpdateCommand(context.Background(), []string{"--channel", "dev", "--check"}, &stdout, &stderr) + + if code != 0 { + t.Fatalf("exit %d, want 0; stderr=%s", code, stderr.String()) + } + if !core.Contains(stdout.String(), "dev-aaaaaaaaaaaa -> dev-bbbbbbbbbbbb") { + t.Errorf("stdout missing the version-diff message: %s", stdout.String()) + } +} + +func TestRunUpdateCommand_UnstampedBackend(t *testing.T) { + // An unstamped build (backend == "") cannot select an asset — fails + // loudly after a successful release fetch, rather than guessing. + defer withUpdateIdentity("1.0.0", "", "lem")() + defer withUpdateGithubClient(updateFetchTestClient{latest: &updater.Release{TagName: "v1.1.0"}})() + + var stdout, stderr bytes.Buffer + code := runUpdateCommand(context.Background(), nil, &stdout, &stderr) + + if code != 1 { + t.Fatalf("exit %d, want 1; stderr=%s", code, stderr.String()) + } + if !core.Contains(stderr.String(), "backend") { + t.Errorf("stderr missing the missing-backend message: %s", stderr.String()) + } +}