diff --git a/.agent/AGENTS.md b/.agent/AGENTS.md index 505e20c..6bd0c2d 100644 --- a/.agent/AGENTS.md +++ b/.agent/AGENTS.md @@ -66,6 +66,7 @@ The binary's interface is the product. Keep it small and stable. - `agent-init list-flavors` — print available flavors with descriptions. - `agent-init list-trackers` — print available trackers with descriptions. - `agent-init version` — print version info (commit + build date, embedded via `-ldflags`). +- `agent-init upgrade [--check] [--dry-run] [--force]` — update the binary in place from the latest GitHub release. Verifies SHA-256 against the published `checksums.txt` and fails closed on mismatch. See [`docs/cli.md`](../docs/cli.md#upgrade). ### Releases diff --git a/.agent/CODEBASE.md b/.agent/CODEBASE.md index d85108d..45ae329 100644 --- a/.agent/CODEBASE.md +++ b/.agent/CODEBASE.md @@ -32,6 +32,7 @@ _Generated by `gen-codemap.sh`. Re-run after structural changes._ | |-- iac.md | `-- project-management.md |-- go.mod +|-- go.sum |-- internal | |-- cli | | |-- cli.go @@ -60,6 +61,11 @@ _Generated by `gen-codemap.sh`. Re-run after structural changes._ | | |-- term_darwin.go | | |-- term_linux.go | | `-- term_other.go +| |-- selfupdate +| | |-- github.go +| | |-- github_test.go +| | |-- selfupdate.go +| | `-- selfupdate_test.go | |-- testflags | | `-- testflags.go | `-- trackers @@ -89,9 +95,9 @@ _Generated by `gen-codemap.sh`. Re-run after structural changes._ ## Public API surface ### Go -internal/cli/cli.go:66:type Version struct { -internal/cli/cli.go:152:type App struct { -internal/cli/cli.go:160:func New(out, errOut io.Writer, version Version) App { +internal/cli/cli.go:67:type Version struct { +internal/cli/cli.go:170:type App struct { +internal/cli/cli.go:178:func New(out, errOut io.Writer, version Version) App { internal/cli/cli_test.go:15:func TestListFlavors(t *testing.T) { internal/cli/cli_test.go:28:func TestInitLegacyTargetArgument(t *testing.T) { internal/cli/cli_test.go:47:func TestInitLegacyTargetArgumentWithoutFlag(t *testing.T) { @@ -131,8 +137,11 @@ internal/cli/cli_test.go:769:func TestFlaglessSubcommandHelp(t *testing.T) { internal/cli/cli_test.go:787:func TestUnknownCommandErrorPointsAtHelp(t *testing.T) { internal/cli/cli_test.go:799:func TestUnknownFlavorErrorPointsAtHelp(t *testing.T) { internal/cli/cli_test.go:815:func TestHelpFlagsMatchDocs(t *testing.T) { -internal/cli/cli_test.go:842:func TestVersion(t *testing.T) { -internal/cli/cli_test.go:855:func TestVersionDefaultsToDev(t *testing.T) { +internal/cli/cli_test.go:842:func TestUpgradeHelp(t *testing.T) { +internal/cli/cli_test.go:859:func TestUpgradeDevBuildRefusedWithoutForce(t *testing.T) { +internal/cli/cli_test.go:871:func TestUpgradeRejectsPositionalArgs(t *testing.T) { +internal/cli/cli_test.go:880:func TestVersion(t *testing.T) { +internal/cli/cli_test.go:893:func TestVersionDefaultsToDev(t *testing.T) { internal/flavors/claudecowork/flavor.go:11:func Templates() embed.FS { internal/flavors/claudecowork/flavor.go:18:func ExecutablePaths() []string { internal/flavors/claudecowork/flavor.go:25:func NextSteps(target string) string { @@ -186,14 +195,11 @@ internal/gitignore/gitignore_test.go:128:func TestEnsureHiddenCreatesAndAppends( internal/gitignore/gitignore_test.go:174:func TestEnsureHiddenIsIdempotent(t *testing.T) { internal/gitignore/gitignore_test.go:196:func TestEnsureHiddenReplacesStaleBlockInPlace(t *testing.T) { internal/gitignore/gitignore_test.go:234:func TestEnsureHiddenWritesNoGitignore(t *testing.T) { -internal/scaffold/color_test.go:9:func TestColorDisabledForNonTTYOutputs(t *testing.T) { -internal/scaffold/color_test.go:26:func TestColorDisabledByEnvironment(t *testing.T) { -internal/scaffold/color_test.go:50:func TestColorEnabledForTerminalFile(t *testing.T) { ## Stats ``` -Total tracked files: 350 +Total tracked files: 355 ``` diff --git a/README.md b/README.md index 8d0cb1b..deed3c9 100644 --- a/README.md +++ b/README.md @@ -79,10 +79,13 @@ agent-init add-tracker agent-init list-flavors agent-init list-trackers agent-init version +agent-init upgrade [--check] [--dry-run] [--force] ``` Defaults: flavor `fullstack`, target `.`. +`agent-init upgrade` replaces the binary in place with the latest GitHub release, verifying its checksum first. There is no automatic update check — run `agent-init upgrade --check` to see if a newer version exists. See [`docs/cli.md`](./docs/cli.md#upgrade). + Run `agent-init --help` for the subcommand list, and `agent-init --help` for a command's flags and examples. See [`docs/cli.md`](./docs/cli.md) for the full reference. Examples. Each block states what the command writes and what it leaves alone. diff --git a/docs/cli.md b/docs/cli.md index 93f5c2f..b7180cd 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,6 +1,6 @@ # CLI -`agent-init` is a small CLI with five subcommands. Source: [internal/cli/cli.go](../internal/cli/cli.go). +`agent-init` is a small CLI with six subcommands. Source: [internal/cli/cli.go](../internal/cli/cli.go). ``` agent-init init [flavor] [target-dir] @@ -8,6 +8,7 @@ agent-init add-tracker agent-init list-flavors agent-init list-trackers agent-init version +agent-init upgrade [--check] [--dry-run] [--force] ``` If no subcommand is given, the binary defaults to `init` with the default flavor. So `agent-init` and `agent-init init` are equivalent. @@ -131,6 +132,34 @@ In dev builds (`go run ./cmd/agent-init version`), prints `version=dev commit=dev buildDate=unknown` — the ldflags only apply to release builds. +## `upgrade` + +Updates `agent-init` in place to the latest GitHub release. There is **no +automatic background check**: a release is only contacted when you run this +command, so normal invocations make no network calls. + +```bash +agent-init upgrade # install the latest release, replacing this binary +agent-init upgrade --check # only report whether a newer version exists +agent-init upgrade --dry-run # download and verify, but do not replace the binary +``` + +### Flags + +| Flag | Effect | +|------|--------| +| `--check` | Report whether a newer release exists and exit, without downloading or installing anything. | +| `--dry-run` | Download the latest archive and verify its checksum, but stop before replacing the binary. | +| `--force` | Install the latest release even when the current version is already newest. Also required to upgrade a dev build, which has no release version to compare against. | + +### Behavior + +- Makes a network call to GitHub's releases API and downloads the OS/arch-specific asset plus `checksums.txt`. Honors `GITHUB_TOKEN` / `GH_TOKEN` to lift the anonymous rate limit. +- Verifies the archive's SHA-256 against the published checksum and swaps the binary in place. **Fails closed on checksum mismatch** — the existing binary is left untouched. +- Requires write permission on the binary's install directory. A root-owned install path (e.g. `/usr/local/bin`) cannot be upgraded without elevated access; `upgrade` reports the error rather than escalating itself. +- A dev build (`version=dev`) cannot be compared to a release; `upgrade` refuses unless `--force` is passed. +- Source: [internal/selfupdate/selfupdate.go](../internal/selfupdate/selfupdate.go) (verify + replace), [internal/selfupdate/github.go](../internal/selfupdate/github.go) (releases client), [cli.go:runUpgrade](../internal/cli/cli.go). The release asset names this matches are cut by [.github/workflows/release.yml](../.github/workflows/release.yml). + ## Help The binary documents its own usage. Help text is generated from a single data diff --git a/go.mod b/go.mod index d8734e4..b9818a4 100644 --- a/go.mod +++ b/go.mod @@ -3,3 +3,5 @@ module github.com/Lillevang/agent-init go 1.26 toolchain go1.26.3 + +require golang.org/x/mod v0.37.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9e6a024 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 3c69edc..e427872 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -16,6 +16,7 @@ import ( "github.com/Lillevang/agent-init/internal/gitconfig" "github.com/Lillevang/agent-init/internal/gitignore" "github.com/Lillevang/agent-init/internal/scaffold" + "github.com/Lillevang/agent-init/internal/selfupdate" "github.com/Lillevang/agent-init/internal/trackers" ) @@ -138,8 +139,25 @@ var commands = []commandHelp{ summary: "print version info (version + commit + build date)", usage: "agent-init version", }, + { + name: "upgrade", + summary: "download and install the latest release, replacing this binary", + usage: "agent-init upgrade [--check] [--dry-run] [--force]", + flags: []flagHelp{ + {"--check", "report whether a newer release exists without downloading or installing"}, + {"--dry-run", "download and verify the latest release but do not replace the binary"}, + {"--force", "install the latest release even if already up to date (also required to upgrade a dev build)"}, + }, + examples: []string{ + "agent-init upgrade # install the latest release", + "agent-init upgrade --check # only report whether a newer version exists", + }, + }, } +// upgradeRepo is the GitHub repository self-upgrade pulls releases from. +const upgradeRepo = selfupdate.DefaultRepo + func lookupCommand(name string) (commandHelp, bool) { for _, c := range commands { if c.name == name { @@ -188,6 +206,8 @@ func (a App) Run(ctx context.Context, args []string) error { return a.runAddTracker(ctx, args[1:]) case "version": return a.runVersion(args[1:]) + case "upgrade": + return a.runUpgrade(ctx, args[1:]) case "help": // `help ` prints that subcommand's help; bare help is // the top-level overview. (`-h` / `--help` are caught earlier.) @@ -619,6 +639,58 @@ func (a App) runVersion(args []string) error { return nil } +// runUpgrade checks GitHub for a newer release and, unless --check is set, +// downloads it, verifies its SHA-256 against the release checksums, and replaces +// the running binary. There is no automatic background check: the user opts in +// by running this command. A dev build (version=dev) can't be compared to a +// release, so upgrading one requires --force. +func (a App) runUpgrade(ctx context.Context, args []string) error { + if wantsHelp(args) { + cmd, _ := lookupCommand("upgrade") + a.printCommandHelp(cmd) + return nil + } + flags := a.newFlagSet("upgrade") + check := flags.Bool("check", false, "report whether a newer release exists without installing") + dryRun := flags.Bool("dry-run", false, "download and verify but do not replace the binary") + force := flags.Bool("force", false, "install even if up to date; required to upgrade a dev build") + if err := flags.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return err + } + if flags.NArg() != 0 { + return fmt.Errorf("usage: agent-init upgrade [--check] [--dry-run] [--force]\nRun 'agent-init upgrade --help' for usage") + } + + updater := selfupdate.NewUpdater(selfupdate.NewGitHubSource(upgradeRepo), a.out) + current := a.version.Version + + if *check { + res, err := updater.Check(ctx, current) + if err != nil { + return err + } + if res.NewerAvailable { + _, _ = fmt.Fprintf(a.out, "A newer version is available: %s (current %s).\nRun 'agent-init upgrade' to install it.\n", res.Latest, current) + } else { + _, _ = fmt.Fprintf(a.out, "agent-init is up to date (%s).\n", current) + } + return nil + } + + if current == "dev" && !*force { + return fmt.Errorf("refusing to upgrade a dev build (version=dev): a dev build has no release version to compare against; pass --force to install the latest release anyway") + } + + return updater.Upgrade(ctx, selfupdate.UpgradeOptions{ + Current: current, + Force: *force, + DryRun: *dryRun, + }) +} + // printHelp renders the top-level overview from the commands table so the // subcommand list never drifts from what the binary actually dispatches. func (a App) printHelp() { diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 11865ed..a0be0a2 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -839,6 +839,44 @@ func TestHelpFlagsMatchDocs(t *testing.T) { } } +func TestUpgradeHelp(t *testing.T) { + t.Parallel() + var out bytes.Buffer + app := cli.New(&out, &bytes.Buffer{}, cli.Version{}) + if err := app.Run(context.Background(), []string{"upgrade", "--help"}); err != nil { + t.Fatalf("Run(upgrade --help) error = %v", err) + } + for _, want := range []string{"upgrade", "--check", "--dry-run", "--force", "Examples"} { + if !strings.Contains(out.String(), want) { + t.Errorf("upgrade --help missing %q:\n%s", want, out.String()) + } + } +} + +// A dev build has no release version to compare against, so `upgrade` without +// --force must refuse before making any network call. This keeps the test +// hermetic. +func TestUpgradeDevBuildRefusedWithoutForce(t *testing.T) { + t.Parallel() + app := cli.New(&bytes.Buffer{}, &bytes.Buffer{}, cli.Version{Version: "dev"}) + err := app.Run(context.Background(), []string{"upgrade"}) + if err == nil { + t.Fatal("Run(upgrade) on dev build = nil, want error") + } + if !strings.Contains(err.Error(), "--force") { + t.Errorf("dev-build refusal should mention --force; got: %v", err) + } +} + +func TestUpgradeRejectsPositionalArgs(t *testing.T) { + t.Parallel() + app := cli.New(&bytes.Buffer{}, &bytes.Buffer{}, cli.Version{Version: "v1.0.0"}) + err := app.Run(context.Background(), []string{"upgrade", "extra-arg"}) + if err == nil { + t.Fatal("Run(upgrade extra-arg) = nil, want usage error") + } +} + func TestVersion(t *testing.T) { t.Parallel() var out bytes.Buffer diff --git a/internal/selfupdate/github.go b/internal/selfupdate/github.go new file mode 100644 index 0000000..d492565 --- /dev/null +++ b/internal/selfupdate/github.go @@ -0,0 +1,122 @@ +package selfupdate + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +// maxDownloadBytes caps a single asset download. The release archives are a few +// MiB; 32 MiB is ~10x the largest plausible archive and stops a misbehaving or +// hostile endpoint from streaming unbounded data into memory. +const maxDownloadBytes = 32 << 20 // 32 MiB + +// GitHubSource implements Source against the GitHub releases REST API. +type GitHubSource struct { + Repo string // "owner/name" + APIBaseURL string // default "https://api.github.com" + HTTPClient *http.Client + // Token is sent as a bearer credential to lift the unauthenticated rate + // limit. Optional; release assets are public. + Token string +} + +// NewGitHubSource returns a source for repo, reading an optional token from +// GITHUB_TOKEN or GH_TOKEN so authenticated users avoid the low anonymous rate +// limit. +func NewGitHubSource(repo string) *GitHubSource { + return &GitHubSource{ + Repo: repo, + APIBaseURL: "https://api.github.com", + HTTPClient: &http.Client{Timeout: 30 * time.Second}, + Token: firstEnv("GITHUB_TOKEN", "GH_TOKEN"), + } +} + +func (g *GitHubSource) client() *http.Client { + if g.HTTPClient != nil { + return g.HTTPClient + } + return http.DefaultClient +} + +// Latest fetches the repo's latest published release. +func (g *GitHubSource) Latest(ctx context.Context) (Release, error) { + url := fmt.Sprintf("%s/repos/%s/releases/latest", strings.TrimRight(g.APIBaseURL, "/"), g.Repo) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return Release{}, fmt.Errorf("building request: %w", err) + } + req.Header.Set("Accept", "application/vnd.github+json") + if g.Token != "" { + req.Header.Set("Authorization", "Bearer "+g.Token) + } + resp, err := g.client().Do(req) + if err != nil { + return Release{}, fmt.Errorf("requesting latest release: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return Release{}, fmt.Errorf("github API %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + var payload struct { + TagName string `json:"tag_name"` + Assets []struct { + Name string `json:"name"` + URL string `json:"browser_download_url"` + } `json:"assets"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return Release{}, fmt.Errorf("decoding release: %w", err) + } + if payload.TagName == "" { + return Release{}, fmt.Errorf("github API returned a release with no tag") + } + rel := Release{Tag: payload.TagName} + for _, a := range payload.Assets { + rel.Assets = append(rel.Assets, Asset{Name: a.Name, URL: a.URL}) + } + return rel, nil +} + +// Download fetches the bytes at url, capped at maxDownloadBytes. +func (g *GitHubSource) Download(ctx context.Context, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("building request: %w", err) + } + if g.Token != "" { + req.Header.Set("Authorization", "Bearer "+g.Token) + } + resp, err := g.client().Do(req) + if err != nil { + return nil, fmt.Errorf("requesting %s: %w", url, err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("downloading %s: %s", url, resp.Status) + } + data, err := io.ReadAll(io.LimitReader(resp.Body, maxDownloadBytes+1)) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", url, err) + } + if len(data) > maxDownloadBytes { + return nil, fmt.Errorf("download from %s exceeds %d bytes", url, maxDownloadBytes) + } + return data, nil +} + +func firstEnv(keys ...string) string { + for _, k := range keys { + if v := os.Getenv(k); v != "" { + return v + } + } + return "" +} diff --git a/internal/selfupdate/github_test.go b/internal/selfupdate/github_test.go new file mode 100644 index 0000000..df8f7b7 --- /dev/null +++ b/internal/selfupdate/github_test.go @@ -0,0 +1,71 @@ +package selfupdate + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGitHubSourceLatest(t *testing.T) { + t.Parallel() + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "tag_name": "v3.1.4", + "assets": [ + {"name": "agent-init-linux-amd64.tar.gz", "browser_download_url": "https://dl.test/a.tar.gz"}, + {"name": "checksums.txt", "browser_download_url": "https://dl.test/checksums.txt"} + ] + }`)) + })) + defer srv.Close() + + g := &GitHubSource{Repo: "Lillevang/agent-init", APIBaseURL: srv.URL, HTTPClient: srv.Client()} + rel, err := g.Latest(context.Background()) + if err != nil { + t.Fatalf("Latest error = %v", err) + } + if gotPath != "/repos/Lillevang/agent-init/releases/latest" { + t.Errorf("requested path = %q", gotPath) + } + if rel.Tag != "v3.1.4" { + t.Errorf("tag = %q, want v3.1.4", rel.Tag) + } + if len(rel.Assets) != 2 || rel.Assets[0].Name != "agent-init-linux-amd64.tar.gz" { + t.Errorf("assets = %+v", rel.Assets) + } +} + +func TestGitHubSourceLatestNon200(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "not found", http.StatusNotFound) + })) + defer srv.Close() + + g := &GitHubSource{Repo: "x/y", APIBaseURL: srv.URL, HTTPClient: srv.Client()} + if _, err := g.Latest(context.Background()); err == nil { + t.Fatal("Latest(404) = nil, want error") + } +} + +func TestGitHubSourceDownload(t *testing.T) { + t.Parallel() + want := []byte("archive-bytes") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(want) + })) + defer srv.Close() + + g := &GitHubSource{Repo: "x/y", APIBaseURL: srv.URL, HTTPClient: srv.Client()} + got, err := g.Download(context.Background(), srv.URL+"/asset") + if err != nil { + t.Fatalf("Download error = %v", err) + } + if string(got) != string(want) { + t.Errorf("Download = %q, want %q", got, want) + } +} diff --git a/internal/selfupdate/selfupdate.go b/internal/selfupdate/selfupdate.go new file mode 100644 index 0000000..23f2cb4 --- /dev/null +++ b/internal/selfupdate/selfupdate.go @@ -0,0 +1,367 @@ +// Package selfupdate checks GitHub for newer agent-init releases and replaces +// the running binary in place. It is the engine behind `agent-init upgrade`. +// +// The flow is deliberately conservative: a download is never installed without +// first verifying its SHA-256 against the release's checksums.txt, and the +// binary is swapped atomically (write-temp-then-rename) so a failure can't leave +// a half-written executable on disk. +package selfupdate + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + + "golang.org/x/mod/semver" +) + +// DefaultRepo is the GitHub "owner/name" releases are pulled from. +const DefaultRepo = "Lillevang/agent-init" + +// maxBinaryBytes caps each archive-entry read so a release-pipeline-compromise +// scenario (gzip/zip bomb) cannot OOM/disk-DoS the upgrading user. The shipped +// binary is single-digit MiB; 64 MiB is ~10x headroom for plausible growth. +const maxBinaryBytes = 64 << 20 // 64 MiB + +// Release is the subset of a GitHub release this package consumes. +type Release struct { + Tag string + Assets []Asset +} + +// Asset is one downloadable file attached to a release. +type Asset struct { + Name string + URL string +} + +// Source fetches release metadata and downloads release assets. It is the seam +// the CLI fills with a GitHub client and tests fill with an in-memory fake. +type Source interface { + Latest(ctx context.Context) (Release, error) + Download(ctx context.Context, url string) ([]byte, error) +} + +// Updater drives a check or an upgrade against a Source. GOOS/GOARCH and ExePath +// are fields rather than hard-coded calls so tests can target arbitrary +// platforms and a throwaway file instead of the real executable. +type Updater struct { + Source Source + Out io.Writer + GOOS string + GOARCH string + ExePath func() (string, error) +} + +// NewUpdater returns an Updater wired to the current platform and the running +// executable's path. +func NewUpdater(src Source, out io.Writer) *Updater { + return &Updater{ + Source: src, + Out: out, + GOOS: runtime.GOOS, + GOARCH: runtime.GOARCH, + ExePath: defaultExePath, + } +} + +// CheckResult reports the outcome of a version check. +type CheckResult struct { + Current string + Latest string + NewerAvailable bool +} + +// Check fetches the latest release and compares it to current without +// downloading anything. +func (u *Updater) Check(ctx context.Context, current string) (CheckResult, error) { + rel, err := u.Source.Latest(ctx) + if err != nil { + return CheckResult{}, fmt.Errorf("fetching latest release: %w", err) + } + return CheckResult{ + Current: current, + Latest: rel.Tag, + NewerAvailable: compareVersions(current, rel.Tag) < 0, + }, nil +} + +// UpgradeOptions tunes an upgrade run. +type UpgradeOptions struct { + Current string + // Force installs the latest release even when current is already newest (or + // unparseable, e.g. a dev build). + Force bool + // DryRun downloads and verifies the release but stops before replacing the + // binary. + DryRun bool +} + +// Upgrade fetches the latest release, verifies its checksum, and atomically +// replaces the running binary. It is a no-op (with a notice) when the current +// version is already the newest and Force is unset. +func (u *Updater) Upgrade(ctx context.Context, opts UpgradeOptions) error { + rel, err := u.Source.Latest(ctx) + if err != nil { + return fmt.Errorf("fetching latest release: %w", err) + } + if !opts.Force && compareVersions(opts.Current, rel.Tag) >= 0 { + _, _ = fmt.Fprintf(u.Out, "agent-init is already up to date (%s).\n", opts.Current) + return nil + } + + assetName := u.assetName() + asset, ok := findAsset(rel.Assets, assetName) + if !ok { + return fmt.Errorf("release %s has no asset for %s/%s (expected %q)", rel.Tag, u.GOOS, u.GOARCH, assetName) + } + sums, ok := findAsset(rel.Assets, "checksums.txt") + if !ok { + return fmt.Errorf("release %s has no checksums.txt; refusing to install an unverified binary", rel.Tag) + } + + _, _ = fmt.Fprintf(u.Out, "Downloading %s (%s)...\n", assetName, rel.Tag) + archive, err := u.Source.Download(ctx, asset.URL) + if err != nil { + return fmt.Errorf("downloading %s: %w", assetName, err) + } + sumData, err := u.Source.Download(ctx, sums.URL) + if err != nil { + return fmt.Errorf("downloading checksums.txt: %w", err) + } + if err := verifyChecksum(assetName, archive, sumData); err != nil { + return err + } + + bin, err := extractBinary(archive, assetName, u.binaryName()) + if err != nil { + return fmt.Errorf("extracting %s: %w", u.binaryName(), err) + } + + target, err := u.resolveExePath() + if err != nil { + return err + } + if opts.DryRun { + _, _ = fmt.Fprintf(u.Out, "Verified %s. Would replace %s (dry-run).\n", assetName, target) + return nil + } + if err := replaceBinary(target, bin); err != nil { + return err + } + _, _ = fmt.Fprintf(u.Out, "Upgraded %s -> %s (%s).\n", opts.Current, rel.Tag, target) + return nil +} + +// assetName is the release archive for this platform, matching the names cut by +// the release workflow (agent-init--.tar.gz, .zip on Windows). +func (u *Updater) assetName() string { + base := fmt.Sprintf("agent-init-%s-%s", u.GOOS, u.GOARCH) + if u.GOOS == "windows" { + return base + ".zip" + } + return base + ".tar.gz" +} + +// binaryName is the executable file packed inside the archive. +func (u *Updater) binaryName() string { + base := fmt.Sprintf("agent-init-%s-%s", u.GOOS, u.GOARCH) + if u.GOOS == "windows" { + return base + ".exe" + } + return base +} + +func (u *Updater) resolveExePath() (string, error) { + fn := u.ExePath + if fn == nil { + fn = defaultExePath + } + return fn() +} + +func defaultExePath() (string, error) { + p, err := os.Executable() + if err != nil { + return "", fmt.Errorf("locating current executable: %w", err) + } + // Resolve symlinks so we replace the real binary, not a symlink pointing at + // it. If resolution fails, fall back to the reported path. + if resolved, err := filepath.EvalSymlinks(p); err == nil { + return resolved, nil + } + return p, nil +} + +func findAsset(assets []Asset, name string) (Asset, bool) { + for _, a := range assets { + if a.Name == name { + return a, true + } + } + return Asset{}, false +} + +// verifyChecksum confirms data hashes to the entry for name in a sha256sum-style +// checksums file. Lines look like "␣␣"; the binary-mode "*name" +// prefix is tolerated. +func verifyChecksum(name string, data, sums []byte) error { + want := "" + for _, line := range strings.Split(string(sums), "\n") { + fields := strings.Fields(line) + if len(fields) != 2 { + continue + } + if fields[1] == name || fields[1] == "*"+name { + want = strings.ToLower(fields[0]) + break + } + } + if want == "" { + return fmt.Errorf("checksums.txt has no entry for %s", name) + } + sum := sha256.Sum256(data) + got := hex.EncodeToString(sum[:]) + if got != want { + return fmt.Errorf("checksum mismatch for %s: got %s, want %s", name, got, want) + } + return nil +} + +func extractBinary(archive []byte, assetName, binName string) ([]byte, error) { + if strings.HasSuffix(assetName, ".zip") { + return extractFromZip(archive, binName) + } + return extractFromTarGz(archive, binName) +} + +func extractFromTarGz(archive []byte, binName string) ([]byte, error) { + gz, err := gzip.NewReader(bytes.NewReader(archive)) + if err != nil { + return nil, fmt.Errorf("opening gzip: %w", err) + } + defer func() { _ = gz.Close() }() + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("reading tar: %w", err) + } + if filepath.Base(hdr.Name) == binName { + return readCapped(tr, binName) + } + } + return nil, fmt.Errorf("%s not found in archive", binName) +} + +func extractFromZip(archive []byte, binName string) ([]byte, error) { + zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive))) + if err != nil { + return nil, fmt.Errorf("opening zip: %w", err) + } + for _, f := range zr.File { + if filepath.Base(f.Name) == binName { + rc, err := f.Open() + if err != nil { + return nil, fmt.Errorf("opening %s in zip: %w", binName, err) + } + defer func() { _ = rc.Close() }() + return readCapped(rc, binName) + } + } + return nil, fmt.Errorf("%s not found in archive", binName) +} + +// readCapped reads up to maxBinaryBytes from r. If the entry is larger it +// returns an explicit error instead of silently truncating, so a gzip/zip-bomb +// release archive can't slip past extraction as a partial binary. +func readCapped(r io.Reader, name string) ([]byte, error) { + data, err := io.ReadAll(io.LimitReader(r, maxBinaryBytes+1)) + if err != nil { + return nil, err + } + if len(data) > maxBinaryBytes { + return nil, fmt.Errorf("%s exceeds %d bytes uncompressed; refusing to extract", name, maxBinaryBytes) + } + return data, nil +} + +// replaceBinary installs data at target atomically. It writes a temp file in the +// same directory (so the final rename stays on one filesystem), then renames it +// over the target. When a direct rename fails — Windows can't replace a running +// executable — it moves the old binary aside first and rolls back on error. +func replaceBinary(target string, data []byte) error { + dir := filepath.Dir(target) + tmp, err := os.CreateTemp(dir, ".agent-init-upgrade-*") + if err != nil { + return fmt.Errorf("creating temp file in %s: %w (the install directory may need elevated write access)", dir, err) + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() + + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return fmt.Errorf("writing new binary: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("finalizing new binary: %w", err) + } + if err := os.Chmod(tmpName, 0o755); err != nil { + return fmt.Errorf("setting permissions on new binary: %w", err) + } + + if err := os.Rename(tmpName, target); err == nil { + return nil + } + // Fallback: move the existing binary aside, then move the new one in. + old := target + ".old" + _ = os.Remove(old) + if err := os.Rename(target, old); err != nil { + return fmt.Errorf("replacing %s: %w (the install directory may need elevated write access)", target, err) + } + if err := os.Rename(tmpName, target); err != nil { + _ = os.Rename(old, target) // roll back + return fmt.Errorf("installing new binary at %s: %w", target, err) + } + _ = os.Remove(old) + return nil +} + +// compareVersions orders two version strings as semver (-1, 0, 1). A version +// that doesn't parse (e.g. "dev") is treated as older than any real release, so +// a dev build always sees a release as newer. Delegates to golang.org/x/mod/semver +// after normalizing the leading v and filling in missing .MINOR/.PATCH so +// inputs like "1.2.3" or "v1.0" still compare cleanly. +func compareVersions(a, b string) int { + return semver.Compare(normalizeVersion(a), normalizeVersion(b)) +} + +// normalizeVersion makes a best-effort canonical "vMAJOR.MINOR.PATCH" string +// for input that may omit the leading v or trailing components. An empty +// return means the input is not a valid semver; semver.Compare then treats it +// as less than any valid version (per its "invalid < valid" contract). +func normalizeVersion(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + if s[0] != 'v' && s[0] != 'V' { + s = "v" + s + } else { + s = "v" + s[1:] + } + return semver.Canonical(s) +} diff --git a/internal/selfupdate/selfupdate_test.go b/internal/selfupdate/selfupdate_test.go new file mode 100644 index 0000000..7497dce --- /dev/null +++ b/internal/selfupdate/selfupdate_test.go @@ -0,0 +1,418 @@ +package selfupdate + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +// fakeSource serves a fixed release and a URL->bytes map for downloads. +type fakeSource struct { + rel Release + files map[string][]byte + err error +} + +func (f *fakeSource) Latest(_ context.Context) (Release, error) { return f.rel, f.err } + +func (f *fakeSource) Download(_ context.Context, url string) ([]byte, error) { + b, ok := f.files[url] + if !ok { + return nil, fmt.Errorf("no file at %s", url) + } + return b, nil +} + +// TestCompareVersions covers our normalization seam against +// golang.org/x/mod/semver: the leading v is optional, missing minor/patch +// expand to .0, and unparseable inputs (e.g. "dev") are treated as older than +// any real release. semver-internal ordering is upstream's job; we only smoke +// it here. +func TestCompareVersions(t *testing.T) { + t.Parallel() + cases := []struct { + a, b string + want int + }{ + {"v1.0.0", "v1.0.1", -1}, + {"1.2.3", "v1.2.3", 0}, // leading v optional + {"v1.0.0", "v1.0", 0}, // missing patch == .0 + {"dev", "v1.0.0", -1}, // unparseable current is older + {"v1.0.0", "dev", 1}, // unparseable latest is older + } + for _, tc := range cases { + if got := compareVersions(tc.a, tc.b); got != tc.want { + t.Errorf("compareVersions(%q, %q) = %d, want %d", tc.a, tc.b, got, tc.want) + } + } +} + +func TestAssetAndBinaryNames(t *testing.T) { + t.Parallel() + cases := []struct { + goos, goarch string + wantAsset, wantBin string + }{ + {"linux", "amd64", "agent-init-linux-amd64.tar.gz", "agent-init-linux-amd64"}, + {"darwin", "arm64", "agent-init-darwin-arm64.tar.gz", "agent-init-darwin-arm64"}, + {"windows", "amd64", "agent-init-windows-amd64.zip", "agent-init-windows-amd64.exe"}, + } + for _, tc := range cases { + u := &Updater{GOOS: tc.goos, GOARCH: tc.goarch} + if got := u.assetName(); got != tc.wantAsset { + t.Errorf("assetName(%s/%s) = %q, want %q", tc.goos, tc.goarch, got, tc.wantAsset) + } + if got := u.binaryName(); got != tc.wantBin { + t.Errorf("binaryName(%s/%s) = %q, want %q", tc.goos, tc.goarch, got, tc.wantBin) + } + } +} + +func TestVerifyChecksum(t *testing.T) { + t.Parallel() + data := []byte("the archive bytes") + sum := sha256.Sum256(data) + good := fmt.Sprintf("%s agent-init-linux-amd64.tar.gz\n", hex.EncodeToString(sum[:])) + + if err := verifyChecksum("agent-init-linux-amd64.tar.gz", data, []byte(good)); err != nil { + t.Errorf("verifyChecksum(matching) = %v, want nil", err) + } + // Tolerate the sha256sum binary-mode "*" prefix. + star := fmt.Sprintf("%s *agent-init-linux-amd64.tar.gz\n", hex.EncodeToString(sum[:])) + if err := verifyChecksum("agent-init-linux-amd64.tar.gz", data, []byte(star)); err != nil { + t.Errorf("verifyChecksum(star prefix) = %v, want nil", err) + } + if err := verifyChecksum("agent-init-linux-amd64.tar.gz", []byte("tampered"), []byte(good)); err == nil { + t.Error("verifyChecksum(mismatch) = nil, want error") + } + if err := verifyChecksum("missing.tar.gz", data, []byte(good)); err == nil { + t.Error("verifyChecksum(no entry) = nil, want error") + } +} + +func makeTarGz(t *testing.T, name string, content []byte) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o755, Size: int64(len(content))}); err != nil { + t.Fatalf("tar header: %v", err) + } + if _, err := tw.Write(content); err != nil { + t.Fatalf("tar write: %v", err) + } + if err := tw.Close(); err != nil { + t.Fatalf("tar close: %v", err) + } + if err := gz.Close(); err != nil { + t.Fatalf("gzip close: %v", err) + } + return buf.Bytes() +} + +func makeZip(t *testing.T, name string, content []byte) []byte { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + w, err := zw.Create(name) + if err != nil { + t.Fatalf("zip create: %v", err) + } + if _, err := w.Write(content); err != nil { + t.Fatalf("zip write: %v", err) + } + if err := zw.Close(); err != nil { + t.Fatalf("zip close: %v", err) + } + return buf.Bytes() +} + +func TestExtractBinary(t *testing.T) { + t.Parallel() + want := []byte("new binary contents") + + gz := makeTarGz(t, "agent-init-linux-amd64", want) + got, err := extractBinary(gz, "agent-init-linux-amd64.tar.gz", "agent-init-linux-amd64") + if err != nil { + t.Fatalf("extractBinary(tar.gz) error = %v", err) + } + if !bytes.Equal(got, want) { + t.Errorf("tar.gz extract = %q, want %q", got, want) + } + + z := makeZip(t, "agent-init-windows-amd64.exe", want) + got, err = extractBinary(z, "agent-init-windows-amd64.zip", "agent-init-windows-amd64.exe") + if err != nil { + t.Fatalf("extractBinary(zip) error = %v", err) + } + if !bytes.Equal(got, want) { + t.Errorf("zip extract = %q, want %q", got, want) + } + + if _, err := extractBinary(gz, "agent-init-linux-amd64.tar.gz", "not-present"); err == nil { + t.Error("extractBinary(missing entry) = nil, want error") + } +} + +// buildRelease wires a fakeSource for linux/amd64 with a valid archive and +// checksums file for the given tag and binary content. +func buildRelease(t *testing.T, tag string, binContent []byte) *fakeSource { + t.Helper() + archive := makeTarGz(t, "agent-init-linux-amd64", binContent) + sum := sha256.Sum256(archive) + checksums := fmt.Sprintf("%s agent-init-linux-amd64.tar.gz\n", hex.EncodeToString(sum[:])) + const archiveURL = "https://example.test/agent-init-linux-amd64.tar.gz" + const sumURL = "https://example.test/checksums.txt" + return &fakeSource{ + rel: Release{ + Tag: tag, + Assets: []Asset{ + {Name: "agent-init-linux-amd64.tar.gz", URL: archiveURL}, + {Name: "checksums.txt", URL: sumURL}, + }, + }, + files: map[string][]byte{ + archiveURL: archive, + sumURL: []byte(checksums), + }, + } +} + +func newTestUpdater(src Source, out *bytes.Buffer, target string) *Updater { + return &Updater{ + Source: src, + Out: out, + GOOS: "linux", + GOARCH: "amd64", + ExePath: func() (string, error) { return target, nil }, + } +} + +func TestUpgradeReplacesBinary(t *testing.T) { + t.Parallel() + target := filepath.Join(t.TempDir(), "agent-init") + if err := os.WriteFile(target, []byte("OLD BINARY"), 0o755); err != nil { + t.Fatal(err) + } + newBin := []byte("BRAND NEW BINARY") + src := buildRelease(t, "v2.0.0", newBin) + var out bytes.Buffer + u := newTestUpdater(src, &out, target) + + if err := u.Upgrade(context.Background(), UpgradeOptions{Current: "v1.0.0"}); err != nil { + t.Fatalf("Upgrade error = %v", err) + } + got, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, newBin) { + t.Errorf("binary after upgrade = %q, want %q", got, newBin) + } + info, err := os.Stat(target) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm()&0o100 == 0 { + t.Errorf("upgraded binary is not executable: mode %v", info.Mode()) + } +} + +func TestUpgradeAlreadyUpToDate(t *testing.T) { + t.Parallel() + target := filepath.Join(t.TempDir(), "agent-init") + if err := os.WriteFile(target, []byte("CURRENT"), 0o755); err != nil { + t.Fatal(err) + } + src := buildRelease(t, "v1.0.0", []byte("SHOULD NOT INSTALL")) + var out bytes.Buffer + u := newTestUpdater(src, &out, target) + + if err := u.Upgrade(context.Background(), UpgradeOptions{Current: "v1.0.0"}); err != nil { + t.Fatalf("Upgrade error = %v", err) + } + got, _ := os.ReadFile(target) + if string(got) != "CURRENT" { + t.Errorf("binary changed when already up to date: %q", got) + } + if !bytes.Contains(out.Bytes(), []byte("up to date")) { + t.Errorf("expected up-to-date notice, got %q", out.String()) + } +} + +func TestUpgradeForceReinstallsSameVersion(t *testing.T) { + t.Parallel() + target := filepath.Join(t.TempDir(), "agent-init") + if err := os.WriteFile(target, []byte("OLD"), 0o755); err != nil { + t.Fatal(err) + } + newBin := []byte("REINSTALLED") + src := buildRelease(t, "v1.0.0", newBin) + var out bytes.Buffer + u := newTestUpdater(src, &out, target) + + if err := u.Upgrade(context.Background(), UpgradeOptions{Current: "v1.0.0", Force: true}); err != nil { + t.Fatalf("Upgrade(force) error = %v", err) + } + got, _ := os.ReadFile(target) + if !bytes.Equal(got, newBin) { + t.Errorf("force did not reinstall: %q", got) + } +} + +func TestUpgradeDryRunDoesNotReplace(t *testing.T) { + t.Parallel() + target := filepath.Join(t.TempDir(), "agent-init") + if err := os.WriteFile(target, []byte("OLD"), 0o755); err != nil { + t.Fatal(err) + } + src := buildRelease(t, "v2.0.0", []byte("NEW")) + var out bytes.Buffer + u := newTestUpdater(src, &out, target) + + if err := u.Upgrade(context.Background(), UpgradeOptions{Current: "v1.0.0", DryRun: true}); err != nil { + t.Fatalf("Upgrade(dry-run) error = %v", err) + } + got, _ := os.ReadFile(target) + if string(got) != "OLD" { + t.Errorf("dry-run replaced the binary: %q", got) + } + if !bytes.Contains(out.Bytes(), []byte("dry-run")) { + t.Errorf("expected dry-run notice, got %q", out.String()) + } +} + +func TestUpgradeChecksumMismatchLeavesBinary(t *testing.T) { + t.Parallel() + target := filepath.Join(t.TempDir(), "agent-init") + if err := os.WriteFile(target, []byte("OLD"), 0o755); err != nil { + t.Fatal(err) + } + src := buildRelease(t, "v2.0.0", []byte("NEW")) + // Corrupt the published checksum so verification fails. + src.files["https://example.test/checksums.txt"] = []byte("deadbeef agent-init-linux-amd64.tar.gz\n") + var out bytes.Buffer + u := newTestUpdater(src, &out, target) + + err := u.Upgrade(context.Background(), UpgradeOptions{Current: "v1.0.0"}) + if err == nil { + t.Fatal("Upgrade(bad checksum) = nil, want error") + } + got, _ := os.ReadFile(target) + if string(got) != "OLD" { + t.Errorf("checksum failure replaced the binary: %q", got) + } +} + +func TestUpgradeNoAssetForPlatform(t *testing.T) { + t.Parallel() + src := buildRelease(t, "v2.0.0", []byte("NEW")) // only has linux/amd64 + var out bytes.Buffer + u := newTestUpdater(src, &out, filepath.Join(t.TempDir(), "agent-init")) + u.GOOS = "plan9" + u.GOARCH = "mips" + + if err := u.Upgrade(context.Background(), UpgradeOptions{Current: "v1.0.0"}); err == nil { + t.Fatal("Upgrade(no matching asset) = nil, want error") + } +} + +func TestUpgradeMissingChecksums(t *testing.T) { + t.Parallel() + src := buildRelease(t, "v2.0.0", []byte("NEW")) + // Drop the checksums asset entirely. + src.rel.Assets = src.rel.Assets[:1] + var out bytes.Buffer + u := newTestUpdater(src, &out, filepath.Join(t.TempDir(), "agent-init")) + + if err := u.Upgrade(context.Background(), UpgradeOptions{Current: "v1.0.0"}); err == nil { + t.Fatal("Upgrade(no checksums.txt) = nil, want error") + } +} + +// TestUpgradeReplaceBinaryFallback exercises replaceBinary's move-aside +// fallback path: when os.Rename(tmp, target) fails (here forced by making +// target a directory, the cross-platform proxy for Windows' "can't rename over +// a running executable"), the fallback renames target out of the way and +// installs the new binary. The Windows production case is symmetric. +func TestUpgradeReplaceBinaryFallback(t *testing.T) { + t.Parallel() + dir := t.TempDir() + target := filepath.Join(dir, "agent-init") + // Make the target an (empty) directory so the primary os.Rename(file, dir) + // fails with EISDIR on POSIX, driving the fallback path. + if err := os.Mkdir(target, 0o755); err != nil { + t.Fatal(err) + } + newBin := []byte("BRAND NEW BINARY") + src := buildRelease(t, "v2.0.0", newBin) + var out bytes.Buffer + u := newTestUpdater(src, &out, target) + + if err := u.Upgrade(context.Background(), UpgradeOptions{Current: "v1.0.0"}); err != nil { + t.Fatalf("Upgrade(fallback) error = %v", err) + } + got, err := os.ReadFile(target) + if err != nil { + t.Fatalf("reading installed binary: %v", err) + } + if !bytes.Equal(got, newBin) { + t.Errorf("binary after fallback = %q, want %q", got, newBin) + } +} + +// TestExtractBinaryRejectsOversizedEntry asserts that an archive entry whose +// uncompressed payload exceeds maxBinaryBytes errors out instead of being +// allocated wholesale — the defense-in-depth check against a gzip/zip-bomb +// release archive. +func TestExtractBinaryRejectsOversizedEntry(t *testing.T) { + t.Parallel() + // One byte over the cap; the bytes themselves are zeros so the gzip/zip + // blob stays tiny (highly compressible) and the test stays fast. + oversized := bytes.Repeat([]byte{0}, maxBinaryBytes+1) + + gz := makeTarGz(t, "agent-init-linux-amd64", oversized) + if _, err := extractBinary(gz, "agent-init-linux-amd64.tar.gz", "agent-init-linux-amd64"); err == nil { + t.Error("extractBinary(oversized tar.gz) = nil, want error") + } else if !strings.Contains(err.Error(), "exceeds") { + t.Errorf("extractBinary(oversized tar.gz) error = %v, want exceeds-bytes error", err) + } + + z := makeZip(t, "agent-init-windows-amd64.exe", oversized) + if _, err := extractBinary(z, "agent-init-windows-amd64.zip", "agent-init-windows-amd64.exe"); err == nil { + t.Error("extractBinary(oversized zip) = nil, want error") + } else if !strings.Contains(err.Error(), "exceeds") { + t.Errorf("extractBinary(oversized zip) error = %v, want exceeds-bytes error", err) + } +} + +func TestCheck(t *testing.T) { + t.Parallel() + src := buildRelease(t, "v2.0.0", []byte("NEW")) + u := newTestUpdater(src, &bytes.Buffer{}, "") + + res, err := u.Check(context.Background(), "v1.0.0") + if err != nil { + t.Fatalf("Check error = %v", err) + } + if !res.NewerAvailable || res.Latest != "v2.0.0" { + t.Errorf("Check = %+v, want NewerAvailable and Latest v2.0.0", res) + } + + res, err = u.Check(context.Background(), "v2.0.0") + if err != nil { + t.Fatalf("Check error = %v", err) + } + if res.NewerAvailable { + t.Errorf("Check(current==latest) reported newer available") + } +}