From ee02f076a82ac6b0798d39388b528be7ce7f5713 Mon Sep 17 00:00:00 2001 From: Phillip Cloud <417981+cpcloud@users.noreply.github.com> Date: Tue, 2 Jun 2026 13:47:34 -0400 Subject: [PATCH] Add roborev release discovery support Roborev resolves releases through GitHub's HTML /releases/latest redirect to avoid unauthenticated api.github.com rate limits, then constructs asset and checksum URLs from the discovered tag. Kit's selfupdate package only supported API release discovery, so roborev could not migrate without either changing updater behavior or duplicating that redirect path. Add a configurable redirect discovery mode and tar.gz asset namer so roborev can keep its existing release layout, including tar.gz Windows artifacts, while moving checksum, download, and install logic into kit. The plan file captures the follow-on roborev migration for a separate Codex worker. Validation: - go build ./... - go tool gotestsum --format pkgname-and-test-fails -- ./... - go vet ./... - go run ./cmd/testify-helper-check ./... - go tool nilaway -include-pkgs=go.kenn.io/kit ./... - nix run 'nixpkgs#golangci-lint' -- run Generated with Codex Co-authored-by: OpenAI Codex --- .../2026-06-02-refactor-roborev-selfupdate.md | 497 ++++++++++++++++++ selfupdate/selfupdate.go | 154 +++++- selfupdate/selfupdate_test.go | 113 +++- 3 files changed, 760 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-02-refactor-roborev-selfupdate.md diff --git a/docs/superpowers/plans/2026-06-02-refactor-roborev-selfupdate.md b/docs/superpowers/plans/2026-06-02-refactor-roborev-selfupdate.md new file mode 100644 index 0000000..9e92e3f --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-refactor-roborev-selfupdate.md @@ -0,0 +1,497 @@ +# Refactor Roborev Self Update Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace roborev's duplicated `internal/update` implementation with a thin wrapper around `go.kenn.io/kit/selfupdate`. + +**Architecture:** Keep `internal/update` as roborev's compatibility boundary so `cmd/roborev/update.go` and `cmd/roborev/tui/fetch.go` keep calling `CheckForUpdate`, `PerformUpdate`, and `FormatSize`. The wrapper configures kit with roborev's current release discovery behavior: GitHub HTML `/releases/latest` redirect, constructed download URLs, `SHA256SUMS`, tar.gz assets on every platform, and unsigned checksum verification. + +**Tech Stack:** Go 1.26.3, `go.kenn.io/kit/selfupdate`, `github.com/stretchr/testify`, Cobra CLI, Bubble Tea TUI. + +--- + +## File Structure + +- Modify `go.mod` and `go.sum` to use a kit version that contains `selfupdate.Client.UseGitHubLatestRedirect`, `selfupdate.Client.GitHubWebBaseURL`, and `selfupdate.TarGzAssetName`. +- Modify `internal/update/update.go` into a small adapter over `go.kenn.io/kit/selfupdate`. +- Modify `internal/update/update_test.go` to keep behavior tests and remove white-box tests for deleted local helpers. +- Check `cmd/roborev/update.go` and `cmd/roborev/tui/fetch.go`; they should not need API changes because the wrapper preserves exported names. + +### Task 1: Add The Failing Kit Metadata Test + +**Files:** +- Modify: `internal/update/update_test.go` + +- [ ] **Step 1: Replace the release discovery test with a kit-shaped expectation** + +Replace `TestUpdaterCheckForUpdateUsesHTMLRedirect` with this test. It intentionally asserts fields that the old local `UpdateInfo` does not expose, so it should fail before the wrapper becomes a kit `selfupdate.Info` alias. + +```go +func TestUpdaterCheckForUpdateUsesKitLatestRedirectDiscovery(t *testing.T) { + const releaseTag = "v1.3.0" + const assetName = "roborev_1.3.0_darwin_arm64.tar.gz" + const checksum = "abc123def456789012345678901234567890123456789012345678901234abcd" + + downloadURL := fmt.Sprintf("https://github.com/roborev-dev/roborev/releases/download/%s/%s", releaseTag, assetName) + checksumsURL := fmt.Sprintf("https://github.com/roborev-dev/roborev/releases/download/%s/SHA256SUMS", releaseTag) + + updater := NewUpdater(Deps{ + Client: &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + switch req.URL.String() { + case "https://github.com/roborev-dev/roborev/releases/latest": + resp := newHTTPResponse(http.StatusFound, "") + resp.Header.Set("Location", "https://github.com/roborev-dev/roborev/releases/tag/"+releaseTag) + resp.Request = req + return resp, nil + case downloadURL: + require.Equal(t, http.MethodHead, req.Method) + resp := newHTTPResponse(http.StatusOK, "") + resp.ContentLength = 42 + return resp, nil + case checksumsURL: + require.Equal(t, http.MethodGet, req.Method) + return newHTTPResponse(http.StatusOK, fmt.Sprintf("%s %s\n", checksum, assetName)), nil + default: + return nil, fmt.Errorf("unexpected request to %s", req.URL.String()) + } + }), + }, + Now: func() time.Time { return time.Unix(0, 0) }, + Version: "v1.2.0", + GOOS: "darwin", + GOARCH: "arm64", + CacheDir: t.TempDir, + }) + + info, err := updater.CheckForUpdate(true) + require.NoError(t, err) + require.NotNil(t, info) + assert.Equal(t, "roborev-dev", info.Owner) + assert.Equal(t, "roborev", info.Repo) + assert.Equal(t, "darwin", info.GOOS) + assert.Equal(t, "arm64", info.GOARCH) + assert.Equal(t, "v1.2.0", info.CurrentVersion) + assert.Equal(t, releaseTag, info.LatestVersion) + assert.Equal(t, assetName, info.AssetName) + assert.Equal(t, downloadURL, info.DownloadURL) + assert.Equal(t, int64(42), info.Size) + assert.Equal(t, checksum, info.Checksum) + assert.False(t, info.IsDevBuild) +} +``` + +- [ ] **Step 2: Run the focused test and confirm it fails** + +Run: + +```bash +go test ./internal/update -run TestUpdaterCheckForUpdateUsesKitLatestRedirectDiscovery -count=1 +``` + +Expected: compile failure mentioning `info.Owner`, `info.Repo`, `info.GOOS`, or `info.GOARCH` is undefined on the current `UpdateInfo`. + +### Task 2: Replace The Local Updater With A Kit Wrapper + +**Files:** +- Modify: `go.mod` +- Modify: `go.sum` +- Replace: `internal/update/update.go` +- Modify: `internal/update/update_test.go` + +- [ ] **Step 1: Update kit dependency** + +Run this after the kit support branch has merged to `main`: + +```bash +go get go.kenn.io/kit@main +go mod tidy +``` + +Expected: `go.mod` still contains `go.kenn.io/kit`, and `go.sum` records the selected pseudo-version or tagged version from `main`. + +- [ ] **Step 2: Replace `internal/update/update.go`** + +Replace the file with this adapter: + +```go +package update + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "time" + + "go.kenn.io/kit/selfupdate" + + "go.kenn.io/roborev/internal/config" + "go.kenn.io/roborev/internal/version" +) + +const ( + releaseOwner = "roborev-dev" + releaseRepo = "roborev" + binaryBase = "roborev" + cacheFileName = "update_check.json" +) + +type UpdateInfo = selfupdate.Info + +type Reporter interface { + Stepf(format string, args ...any) + Progress(downloaded, total int64) +} + +type Deps struct { + Client *http.Client + Now func() time.Time + Version string + GOOS string + GOARCH string + CacheDir func() string + Executable func() (string, error) + GitHubWebBaseURL string +} + +type Updater struct { + deps Deps +} + +type stdoutReporter struct { + out io.Writer + progressFn func(downloaded, total int64) +} + +type nopReporter struct{} + +func CheckForUpdate(forceCheck bool) (*UpdateInfo, error) { + return defaultUpdater().CheckForUpdate(forceCheck) +} + +func PerformUpdate(info *UpdateInfo, progressFn func(downloaded, total int64)) error { + return defaultUpdater().PerformUpdate(info, stdoutReporter{ + out: os.Stdout, + progressFn: progressFn, + }) +} + +func RestartDaemon() error { + return nil +} + +func GetCacheDir() string { + return config.DataDir() +} + +func NewUpdater(deps Deps) *Updater { + if deps.Client == nil { + deps.Client = &http.Client{Timeout: 30 * time.Second} + } + if deps.Now == nil { + deps.Now = time.Now + } + if deps.Version == "" { + deps.Version = version.Version + } + if deps.GOOS == "" { + deps.GOOS = runtime.GOOS + } + if deps.GOARCH == "" { + deps.GOARCH = runtime.GOARCH + } + if deps.CacheDir == nil { + deps.CacheDir = config.DataDir + } + if deps.Executable == nil { + deps.Executable = os.Executable + } + return &Updater{deps: deps} +} + +func defaultUpdater() *Updater { + return NewUpdater(Deps{}) +} + +func (u *Updater) CheckForUpdate(forceCheck bool) (*UpdateInfo, error) { + if selfupdate.IsDevBuildVersion(u.deps.Version) && !forceCheck { + return nil, nil + } + return u.client().Check(context.Background(), selfupdate.CheckOptions{ + Force: forceCheck, + GOOS: u.deps.GOOS, + GOARCH: u.deps.GOARCH, + }) +} + +func (u *Updater) PerformUpdate(info *UpdateInfo, reporter Reporter) error { + reporter = normalizeReporter(reporter) + if info == nil { + return fmt.Errorf("update info is nil") + } + if info.Checksum == "" { + return fmt.Errorf("no checksum available for %s - refusing to install unverified binary", info.AssetName) + } + + installDir, err := u.installDir() + if err != nil { + return err + } + binaryName := executableName(u.deps.GOOS) + dstPath := filepath.Join(installDir, binaryName) + + reporter.Stepf("Downloading %s...\n", info.AssetName) + if err := u.client().Install(context.Background(), info, selfupdate.InstallOptions{ + DestinationPath: dstPath, + ArchiveBinaryName: binaryName, + Progress: reporter.Progress, + }); err != nil { + return err + } + reporter.Stepf("Installing %s... OK\n", binaryName) + return nil +} + +func (u *Updater) client() selfupdate.Client { + return selfupdate.Client{ + Owner: releaseOwner, + Repo: releaseRepo, + BinaryName: binaryBase, + CurrentVersion: u.deps.Version, + CacheDir: u.deps.CacheDir(), + HTTPClient: u.deps.Client, + Clock: u.deps.Now, + GitHubWebBaseURL: u.deps.GitHubWebBaseURL, + CacheFileName: cacheFileName, + UseGitHubLatestRedirect: true, + AssetName: selfupdate.TarGzAssetName, + AllowUnsignedChecksums: true, + } +} + +func (u *Updater) installDir() (string, error) { + currentExe, err := u.deps.Executable() + if err != nil { + return "", fmt.Errorf("find current executable: %w", err) + } + currentExe, err = filepath.EvalSymlinks(currentExe) + if err != nil { + return "", fmt.Errorf("resolve symlinks: %w", err) + } + return filepath.Dir(currentExe), nil +} + +func executableName(goos string) string { + if goos == "windows" { + return binaryBase + ".exe" + } + return binaryBase +} + +func FormatSize(bytes int64) string { + return selfupdate.FormatSize(bytes) +} + +func normalizeReporter(reporter Reporter) Reporter { + if reporter == nil { + return nopReporter{} + } + return reporter +} + +func (r stdoutReporter) Stepf(format string, args ...any) { + if r.out == nil { + return + } + fmt.Fprintf(r.out, format, args...) +} + +func (r stdoutReporter) Progress(downloaded, total int64) { + if r.progressFn != nil { + r.progressFn(downloaded, total) + } +} + +func (nopReporter) Stepf(string, ...any) {} + +func (nopReporter) Progress(int64, int64) {} +``` + +- [ ] **Step 3: Prune obsolete white-box tests** + +In `internal/update/update_test.go`, delete tests for helpers that no longer live in roborev: + +```text +TestSanitizeTarPath +TestExtractTarGzPathTraversal +TestExtractTarGzSymlinkSkipped +TestExtractTarGzExistingSymlinkDoesNotEscapeDestination +TestExtractChecksum +TestExtractBaseSemver +TestIsDevBuildVersion +TestIsNewer +TestParsedVersionCompare +TestResolveLatestTag +TestFetchContentLength +``` + +Also delete helper functions used only by those removed tests: + +```text +skipUnlessTargetOS +``` + +- [ ] **Step 4: Update the cache test helper** + +Replace `writeCachedCheck` with this helper so the test no longer depends on roborev's removed private `cachedCheck` type: + +```go +func writeCachedCheck(t *testing.T, cacheDir, cachedVersion string, checkedAt time.Time) { + t.Helper() + data, err := json.Marshal(struct { + CheckedAt time.Time `json:"checked_at"` + Version string `json:"version"` + }{ + CheckedAt: checkedAt, + Version: cachedVersion, + }) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(cacheDir, cacheFileName), data, 0o600)) +} +``` + +Update the cache test call to: + +```go +writeCachedCheck(t, cacheDir, "v1.2.3", now.Add(-15*time.Minute)) +``` + +- [ ] **Step 5: Update install output assertions** + +In `TestUpdaterPerformUpdateInstallsBinary`, keep the archive setup and install assertions, but replace the reporter step assertions with: + +```go +assert.Contains(t, reporter.steps.String(), "Downloading") +assert.Contains(t, reporter.steps.String(), "Installing "+binaryName+"... OK") +assert.NotEmpty(t, reporter.progress) +``` + +- [ ] **Step 6: Run update package tests** + +Run: + +```bash +go test ./internal/update -count=1 +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add go.mod go.sum internal/update/update.go internal/update/update_test.go +git commit -m "refactor: use kit selfupdate in roborev" +``` + +### Task 3: Verify CLI And TUI Consumers + +**Files:** +- Inspect: `cmd/roborev/update.go` +- Inspect: `cmd/roborev/tui/fetch.go` + +- [ ] **Step 1: Confirm call sites still compile** + +Run: + +```bash +go test ./cmd/roborev ./cmd/roborev/tui -count=1 +``` + +Expected: PASS. If either package fails because an `UpdateInfo` field moved, keep the field read the same and fix the adapter, not the CLI or TUI. + +- [ ] **Step 2: Search for stale update internals** + +Run: + +```bash +rg 'resolveLatestTag|githubLatestReleaseURL|githubReleaseDownloadBase|extractChecksum|extractTarGz|sanitizeTarPath|fetchContentLength|parseVersion' internal/update cmd/roborev +``` + +Expected: no matches, except comments in deleted diff context are gone. + +- [ ] **Step 3: Commit only if this task changed files** + +```bash +git add cmd/roborev/update.go cmd/roborev/tui/fetch.go internal/update/update.go internal/update/update_test.go +git commit -m "test: verify update command consumers" +``` + +### Task 4: Final Verification + +**Files:** +- Inspect: `go.mod` +- Inspect: `go.sum` +- Inspect: `internal/update/update.go` +- Inspect: `internal/update/update_test.go` + +- [ ] **Step 1: Format** + +Run: + +```bash +go fmt ./... +``` + +Expected: no output. + +- [ ] **Step 2: Vet** + +Run: + +```bash +go vet ./... +``` + +Expected: no output and exit code 0. + +- [ ] **Step 3: Full test suite** + +Run: + +```bash +go test ./... +``` + +Expected: PASS. + +- [ ] **Step 4: Repo lint** + +Run: + +```bash +make lint-ci +``` + +Expected: PASS with zero warnings. + +- [ ] **Step 5: Final diff check** + +Run: + +```bash +git diff --stat HEAD +git diff HEAD -- internal/update/update.go internal/update/update_test.go cmd/roborev/update.go cmd/roborev/tui/fetch.go go.mod go.sum +``` + +Expected: the diff only replaces local updater mechanics with kit calls and preserves the CLI/TUI user workflow. + +- [ ] **Step 6: Commit verification cleanup if formatting changed files** + +```bash +git add go.mod go.sum internal/update/update.go internal/update/update_test.go cmd/roborev/update.go cmd/roborev/tui/fetch.go +git commit -m "chore: format roborev selfupdate migration" +``` diff --git a/selfupdate/selfupdate.go b/selfupdate/selfupdate.go index f93bcfe..9d4efb4 100644 --- a/selfupdate/selfupdate.go +++ b/selfupdate/selfupdate.go @@ -14,6 +14,7 @@ import ( "fmt" "io" "net/http" + "net/url" "os" "path/filepath" "regexp" @@ -26,6 +27,7 @@ import ( const ( defaultGitHubAPIBaseURL = "https://api.github.com" + defaultGitHubWebBaseURL = "https://github.com" defaultCacheFileName = "update_check.json" defaultCacheDuration = time.Hour defaultDevCacheDuration = 15 * time.Minute @@ -74,8 +76,11 @@ type Client struct { Clock func() time.Time GitHubAPIBaseURL string + GitHubWebBaseURL string UserAgent string + UseGitHubLatestRedirect bool + CacheFileName string CacheDuration time.Duration DevCacheDuration time.Duration @@ -158,6 +163,30 @@ func (c Client) Check(ctx context.Context, opts CheckOptions) (*Info, error) { goos, goarch := platform(opts) assetName := c.platformAssetName(release, latestVersion, opts) + if c.UseGitHubLatestRedirect { + downloadURL := c.githubReleaseDownloadURL(release.TagName, assetName) + size, err := c.fetchAssetContentLength(ctx, downloadURL) + if err != nil { + return nil, fmt.Errorf("no release asset for %s/%s: %w", goos, goarch, err) + } + + checksum := c.fetchChecksumFromDownloadAssets(ctx, release.TagName, assetName) + return &Info{ + CurrentVersion: currentVersion, + LatestVersion: release.TagName, + DownloadURL: downloadURL, + AssetName: assetName, + SignatureURL: c.findSignatureDownloadURL(ctx, release.TagName, assetName), + Owner: c.Owner, + Repo: c.Repo, + GOOS: goos, + GOARCH: goarch, + Size: size, + Checksum: checksum, + IsDevBuild: isDevBuild, + }, nil + } + asset, checksumsAsset, signatureAsset := c.findAssets(release.Assets, assetName) if asset == nil { return nil, fmt.Errorf("no release asset for %s/%s", goos, goarch) @@ -575,7 +604,17 @@ func ExtractChecksum(body, assetName string) string { lines := strings.Split(body, "\n") re := regexp.MustCompile(`(?i)[a-f0-9]{64}`) for _, line := range lines { - fields := strings.Fields(strings.TrimSpace(line)) + line = strings.TrimSpace(line) + if name, value, ok := strings.Cut(line, ":"); ok { + fname := strings.TrimPrefix(strings.TrimSpace(name), "*") + if fname == assetName { + if match := re.FindString(value); match != "" { + return strings.ToLower(match) + } + } + } + + fields := strings.Fields(line) if len(fields) < 2 { continue } @@ -595,6 +634,12 @@ func DefaultAssetName(req AssetRequest) string { return fmt.Sprintf("%s_%s_%s_%s%s", req.BinaryName, req.Version, req.GOOS, req.GOARCH, req.Extension) } +// TarGzAssetName returns binary_version_goos_goarch.tar.gz for every platform. +func TarGzAssetName(req AssetRequest) string { + req.Extension = ".tar.gz" + return DefaultAssetName(req) +} + // IsDevBuildVersion reports whether v is a non-release or git-describe build. func IsDevBuildVersion(v string) bool { v = strings.TrimPrefix(v, "v") @@ -667,6 +712,13 @@ func (c Client) apiBaseURL() string { return defaultGitHubAPIBaseURL } +func (c Client) githubWebBaseURL() string { + if c.GitHubWebBaseURL != "" { + return strings.TrimRight(c.GitHubWebBaseURL, "/") + } + return defaultGitHubWebBaseURL +} + func (c Client) userAgent() string { if c.UserAgent != "" { return c.UserAgent @@ -780,6 +832,10 @@ func (c Client) checkCache(currentVersion, cleanVersion string, isDevBuild bool) } func (c Client) fetchLatestRelease(ctx context.Context) (*Release, error) { + if c.UseGitHubLatestRedirect { + return c.fetchLatestReleaseByRedirect(ctx) + } + url := fmt.Sprintf("%s/repos/%s/%s/releases/latest", c.apiBaseURL(), c.Owner, c.Repo) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { @@ -805,6 +861,40 @@ func (c Client) fetchLatestRelease(ctx context.Context) (*Release, error) { return &release, nil } +func (c Client) fetchLatestReleaseByRedirect(ctx context.Context) (*Release, error) { + url := fmt.Sprintf("%s/%s/%s/releases/latest", c.githubWebBaseURL(), c.Owner, c.Repo) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", c.userAgent()) + + baseClient := c.httpClient() + client := *baseClient + client.CheckRedirect = func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode < 300 || resp.StatusCode >= 400 { + return nil, fmt.Errorf("expected redirect from %s, got %s", url, resp.Status) + } + + loc, err := resp.Location() + if err != nil { + return nil, fmt.Errorf("read Location header: %w", err) + } + tag, err := releaseTagFromRedirect(loc) + if err != nil { + return nil, err + } + return &Release{TagName: tag}, nil +} + func (c Client) fetchChecksumFromFile(ctx context.Context, url, assetName string) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { @@ -829,6 +919,51 @@ func (c Client) fetchChecksumFromFile(ctx context.Context, url, assetName string return ExtractChecksum(string(body), assetName), nil } +func (c Client) fetchAssetContentLength(ctx context.Context, url string) (int64, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil) + if err != nil { + return 0, err + } + req.Header.Set("User-Agent", c.userAgent()) + + resp, err := c.httpClient().Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("HEAD %s returned %s", url, resp.Status) + } + if resp.ContentLength < 0 { + return 0, nil + } + return resp.ContentLength, nil +} + +func (c Client) fetchChecksumFromDownloadAssets(ctx context.Context, tag, assetName string) string { + for _, checksumAssetName := range c.checksumAssetNames() { + checksum, err := c.fetchChecksumFromFile(ctx, c.githubReleaseDownloadURL(tag, checksumAssetName), assetName) + if err == nil && checksum != "" { + return checksum + } + } + return "" +} + +func (c Client) findSignatureDownloadURL(ctx context.Context, tag, assetName string) string { + if !c.RequireSignature && len(c.TrustedPublicKeys) == 0 { + return "" + } + for _, signatureAssetName := range []string{assetName + ".sha256.sig", assetName + ".sig"} { + url := c.githubReleaseDownloadURL(tag, signatureAssetName) + if _, err := c.fetchAssetContentLength(ctx, url); err == nil { + return url + } + } + return "" +} + func (c Client) downloadFile(ctx context.Context, url, dest string, totalSize int64, progress func(downloaded, total int64)) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { @@ -997,6 +1132,23 @@ func (c Client) validateInfoMetadata(info *Info) error { return nil } +func (c Client) githubReleaseDownloadURL(tag, assetName string) string { + return fmt.Sprintf("%s/%s/%s/releases/download/%s/%s", c.githubWebBaseURL(), c.Owner, c.Repo, tag, assetName) +} + +func releaseTagFromRedirect(loc *url.URL) (string, error) { + const marker = "/releases/tag/" + idx := strings.Index(loc.Path, marker) + if idx < 0 { + return "", fmt.Errorf("unexpected redirect target %q", loc.String()) + } + tag := loc.Path[idx+len(marker):] + if tag == "" { + return "", fmt.Errorf("empty tag in redirect target %q", loc.String()) + } + return tag, nil +} + func assetURL(asset *Asset) string { if asset == nil { return "" diff --git a/selfupdate/selfupdate_test.go b/selfupdate/selfupdate_test.go index 1e259fd..518055e 100644 --- a/selfupdate/selfupdate_test.go +++ b/selfupdate/selfupdate_test.go @@ -20,6 +20,9 @@ import ( "sync/atomic" "testing" "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) const ( @@ -127,6 +130,98 @@ func TestCheckUsesReleaseBodyChecksumFallback(t *testing.T) { } } +func TestCheckUsesGitHubLatestRedirectDiscovery(t *testing.T) { + t.Parallel() + + const ( + releaseTag = "v1.2.0" + assetName = "tool_1.2.0_linux_amd64.tar.gz" + ) + + var headRequests atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/kenn/tool/releases/latest": + if r.Method != http.MethodGet { + http.Error(w, "expected GET", http.StatusMethodNotAllowed) + return + } + http.Redirect(w, r, "/kenn/tool/releases/tag/"+releaseTag, http.StatusFound) + case "/kenn/tool/releases/download/" + releaseTag + "/" + assetName: + if r.Method != http.MethodHead { + http.Error(w, "expected HEAD", http.StatusMethodNotAllowed) + return + } + headRequests.Add(1) + w.Header().Set("Content-Length", "123") + case "/kenn/tool/releases/download/" + releaseTag + "/SHA256SUMS": + if r.Method != http.MethodGet { + http.Error(w, "expected GET", http.StatusMethodNotAllowed) + return + } + _, _ = fmt.Fprintf(w, "%s %s\n", testHash64, assetName) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + client := Client{ + Owner: "kenn", + Repo: "tool", + BinaryName: "tool", + CurrentVersion: "v1.1.0", + CacheDir: t.TempDir(), + GitHubWebBaseURL: server.URL, + UseGitHubLatestRedirect: true, + AllowUnsignedChecksums: true, + } + + info, err := client.Check(context.Background(), CheckOptions{GOOS: "linux", GOARCH: "amd64"}) + require := require.New(t) + require.NoError(err) + require.NotNil(info) + + assert := assert.New(t) + assert.Equal("v1.1.0", info.CurrentVersion) + assert.Equal(releaseTag, info.LatestVersion) + assert.Equal(assetName, info.AssetName) + assert.Equal(server.URL+"/kenn/tool/releases/download/"+releaseTag+"/"+assetName, info.DownloadURL) + assert.Equal(int64(123), info.Size) + assert.Equal(testHash64, info.Checksum) + assert.Equal(int64(1), headRequests.Load()) +} + +func TestCheckGitHubLatestRedirectDiscoveryRequiresAsset(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/kenn/tool/releases/latest": + http.Redirect(w, r, "/kenn/tool/releases/tag/v1.2.0", http.StatusFound) + case "/kenn/tool/releases/download/v1.2.0/tool_1.2.0_linux_arm64.tar.gz": + http.NotFound(w, r) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + client := Client{ + Owner: "kenn", + Repo: "tool", + BinaryName: "tool", + CurrentVersion: "v1.1.0", + GitHubWebBaseURL: server.URL, + UseGitHubLatestRedirect: true, + } + + info, err := client.Check(context.Background(), CheckOptions{GOOS: "linux", GOARCH: "arm64"}) + require.Error(t, err) + assert.Nil(t, info) + assert.Contains(t, err.Error(), "no release asset for linux/arm64") +} + func TestCheckCache(t *testing.T) { t.Parallel() @@ -884,11 +979,11 @@ func TestInstallBinary(t *testing.T) { } }() - for i := range 1000 { + for range 1000 { if err := InstallBinary(srcPath, dstPath); err != nil { close(stop) <-done - t.Fatalf("iteration %d: %v", i, err) + t.Fatalf("install iteration: %v", err) } } close(stop) @@ -919,6 +1014,7 @@ func TestExtractChecksum(t *testing.T) { {"substring filename", fmt.Sprintf("%s tool_darwin_arm64.tar.gz.sig", testHash64), "tool_darwin_arm64.tar.gz", ""}, {"binary star", fmt.Sprintf("%s *tool_darwin_arm64.tar.gz", testHash64), "tool_darwin_arm64.tar.gz", testHash64}, {"trailing comment", fmt.Sprintf("%s tool_darwin_arm64.tar.gz # comment", testHash64), "tool_darwin_arm64.tar.gz", testHash64}, + {"colon", fmt.Sprintf("tool_darwin_arm64.tar.gz: %s", testHash64), "tool_darwin_arm64.tar.gz", testHash64}, } for _, tt := range tests { @@ -980,7 +1076,7 @@ func TestVersionHelpers(t *testing.T) { } } -func TestDefaultAssetNameAndFormatSize(t *testing.T) { +func TestAssetNamesAndFormatSize(t *testing.T) { t.Parallel() name := DefaultAssetName(AssetRequest{ @@ -994,6 +1090,17 @@ func TestDefaultAssetNameAndFormatSize(t *testing.T) { t.Fatalf("asset name = %q", name) } + name = TarGzAssetName(AssetRequest{ + BinaryName: "roborev", + Version: "0.56.0", + GOOS: "windows", + GOARCH: "amd64", + Extension: ".zip", + }) + if name != "roborev_0.56.0_windows_amd64.tar.gz" { + t.Fatalf("tar.gz asset name = %q", name) + } + tests := []struct { bytes int64 want string