From 1877c75a142256aba6c600b311c492e37e8a7d18 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Thu, 19 Mar 2026 22:05:58 +0200 Subject: [PATCH] Reshape update notifier plan --- docs/docs/changelog.md | 1 + docs/docs/env.md | 1 + internal/cli/cli.go | 89 ++++++++ internal/cli/cli_test.go | 39 ++++ internal/upgrade/notifier.go | 251 +++++++++++++++++++++ internal/upgrade/notifier_test.go | 218 ++++++++++++++++++ internal/upgrade/registry/registry.go | 47 +++- internal/upgrade/registry/registry_test.go | 56 +++++ internal/upgrade/upgrade_test.go | 5 + internal/util/version.go | 3 + 10 files changed, 698 insertions(+), 12 deletions(-) create mode 100644 internal/cli/cli_test.go create mode 100644 internal/upgrade/notifier.go create mode 100644 internal/upgrade/notifier_test.go create mode 100644 internal/upgrade/registry/registry_test.go diff --git a/docs/docs/changelog.md b/docs/docs/changelog.md index 63c9bff1..9989962f 100644 --- a/docs/docs/changelog.md +++ b/docs/docs/changelog.md @@ -14,6 +14,7 @@ title: Changelog * `[Added]` Support `env_file` in global config and commands. File names are expanded after `env` is resolved, and values loaded from env files override values from `env`. * `[Changed]` Migrate the LSP YAML parser from the CGO-based tree-sitter bindings to pure-Go [`gotreesitter`](https://github.com/odvcencio/gotreesitter), removing the C toolchain requirement from normal builds and release packaging. * `[Refactoring]` Move CLI startup flow from `cmd/lets/main.go` into `internal/cli/cli.go`, keeping `main.go` as a thin launcher. +* `[Added]` Show background update notifications for interactive sessions, with Homebrew-aware guidance and `LETS_NO_UPDATE_NOTIFIER` opt-out. ## [0.0.59](https://github.com/lets-cli/lets/releases/tag/v0.0.59) diff --git a/docs/docs/env.md b/docs/docs/env.md index a6c56c4f..d2e61782 100644 --- a/docs/docs/env.md +++ b/docs/docs/env.md @@ -10,6 +10,7 @@ title: Environment * `LETS_DEBUG` - enable debug messages * `LETS_CONFIG` - changes default `lets.yaml` file path (e.g. LETS_CONFIG=lets.my.yaml) * `LETS_CONFIG_DIR` - changes path to dir where `lets.yaml` file placed +* `LETS_NO_UPDATE_NOTIFIER` - disables background update checks and notifications * `NO_COLOR` - disables colored output. See https://no-color.org/ ### Environment variables available at command runtime diff --git a/internal/cli/cli.go b/internal/cli/cli.go index eb0b1107..f0f735ca 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -3,10 +3,12 @@ package cli import ( "context" "errors" + "fmt" "os" "os/signal" "strings" "syscall" + "time" "github.com/lets-cli/lets/internal/cmd" "github.com/lets-cli/lets/internal/config" @@ -17,10 +19,18 @@ import ( "github.com/lets-cli/lets/internal/upgrade" "github.com/lets-cli/lets/internal/upgrade/registry" "github.com/lets-cli/lets/internal/workdir" + "github.com/mattn/go-isatty" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) +const updateCheckTimeout = 3 * time.Second + +type updateCheckResult struct { + notifier *upgrade.UpdateNotifier + notice *upgrade.UpdateNotice +} + func Main(version string, buildDate string) int { ctx := getContext() @@ -118,6 +128,9 @@ func Main(version string, buildDate string) int { return 0 } + updateCh, cancelUpdateCheck := maybeStartUpdateCheck(ctx, version, command) + defer cancelUpdateCheck() + if err := rootCmd.ExecuteContext(ctx); err != nil { var depErr *executor.DependencyError if errors.As(err, &depErr) { @@ -128,6 +141,8 @@ func Main(version string, buildDate string) int { return getExitCode(err, 1) } + printUpdateNotice(updateCh) + return 0 } @@ -167,6 +182,80 @@ func failOnConfigError(root *cobra.Command, current *cobra.Command, rootFlags *f return (root.Flags().NFlag() == 0 && !rootCommands.Contains(current.Name())) && !rootFlags.help && !rootFlags.init } +func maybeStartUpdateCheck( + ctx context.Context, + version string, + command *cobra.Command, +) (<-chan updateCheckResult, context.CancelFunc) { + if !shouldCheckForUpdate(command.Name(), isInteractiveStderr()) { + return nil, func() {} + } + + notifier, err := upgrade.NewUpdateNotifier(registry.NewGithubRegistry(ctx)) + if err != nil { + log.Debugf("lets: update notifier init failed: %s", err) + return nil, func() {} + } + + ch := make(chan updateCheckResult, 1) + checkCtx, cancel := context.WithTimeout(ctx, updateCheckTimeout) + + go func() { + notice, err := notifier.Check(checkCtx, version) + if err != nil { + upgrade.LogUpdateCheckError(err) + } + + ch <- updateCheckResult{ + notifier: notifier, + notice: notice, + } + }() + + return ch, cancel +} + +func printUpdateNotice(updateCh <-chan updateCheckResult) { + if updateCh == nil { + return + } + + select { + case result := <-updateCh: + if result.notice == nil { + return + } + + if _, err := fmt.Fprintln(os.Stderr, result.notice.Message()); err != nil { + log.Debugf("lets: update notifier print failed: %s", err) + return + } + + if err := result.notifier.MarkNotified(result.notice); err != nil { + upgrade.LogUpdateCheckError(err) + } + default: + } +} + +func shouldCheckForUpdate(commandName string, interactive bool) bool { + if !interactive || os.Getenv("CI") != "" || os.Getenv("LETS_NO_UPDATE_NOTIFIER") != "" { + return false + } + + switch commandName { + case "completion", "help", "lsp", "self": + return false + default: + return true + } +} + +func isInteractiveStderr() bool { + fd := os.Stderr.Fd() + return isatty.IsTerminal(fd) || isatty.IsCygwinTerminal(fd) +} + type flags struct { config string debug int diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 00000000..ca109917 --- /dev/null +++ b/internal/cli/cli_test.go @@ -0,0 +1,39 @@ +package cli + +import "testing" + +func TestShouldCheckForUpdate(t *testing.T) { + t.Run("should allow normal interactive commands", func(t *testing.T) { + if !shouldCheckForUpdate("lets", true) { + t.Fatal("expected update check to be enabled") + } + }) + + t.Run("should skip non interactive sessions", func(t *testing.T) { + if shouldCheckForUpdate("lets", false) { + t.Fatal("expected non-interactive session to skip update check") + } + }) + + t.Run("should skip when CI is set", func(t *testing.T) { + t.Setenv("CI", "1") + if shouldCheckForUpdate("lets", true) { + t.Fatal("expected CI to skip update check") + } + }) + + t.Run("should skip when notifier disabled", func(t *testing.T) { + t.Setenv("LETS_NO_UPDATE_NOTIFIER", "1") + if shouldCheckForUpdate("lets", true) { + t.Fatal("expected opt-out env to skip update check") + } + }) + + t.Run("should skip internal commands", func(t *testing.T) { + for _, name := range []string{"completion", "help", "lsp", "self"} { + if shouldCheckForUpdate(name, true) { + t.Fatalf("expected %q to skip update check", name) + } + } + }) +} diff --git a/internal/upgrade/notifier.go b/internal/upgrade/notifier.go new file mode 100644 index 00000000..49a2e619 --- /dev/null +++ b/internal/upgrade/notifier.go @@ -0,0 +1,251 @@ +package upgrade + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/coreos/go-semver/semver" + "github.com/lets-cli/lets/internal/upgrade/registry" + "github.com/lets-cli/lets/internal/util" + log "github.com/sirupsen/logrus" + "gopkg.in/yaml.v3" +) + +const ( + updateCheckInterval = 24 * time.Hour + updateNotifyInterval = 24 * time.Hour + homebrewNoticeDelay = 24 * time.Hour +) + +type UpdateNotice struct { + CurrentVersion string + LatestVersion string + command string +} + +func (n *UpdateNotice) Message() string { + return fmt.Sprintf( + "lets: new version available %s (current %s). Run '%s' or see https://lets-cli.org/docs/installation", + n.LatestVersion, + n.CurrentVersion, + n.command, + ) +} + +type notifierState struct { + CheckedAt time.Time `yaml:"checked_at"` + LatestVersion string `yaml:"latest_version"` + LatestPublishedAt time.Time `yaml:"latest_published_at"` + NotifiedAt time.Time `yaml:"notified_at"` +} + +type UpdateNotifier struct { + registry registry.RepoRegistry + statePath string + executablePath string + now func() time.Time +} + +func NewUpdateNotifier(reg registry.RepoRegistry) (*UpdateNotifier, error) { + statePath, err := letsStatePath() + if err != nil { + return nil, err + } + + executablePath, err := binaryPath() + if err != nil { + return nil, err + } + + return newUpdateNotifier(reg, statePath, executablePath, time.Now), nil +} + +func newUpdateNotifier( + reg registry.RepoRegistry, + statePath string, + executablePath string, + now func() time.Time, +) *UpdateNotifier { + return &UpdateNotifier{ + registry: reg, + statePath: statePath, + executablePath: executablePath, + now: now, + } +} + +func (n *UpdateNotifier) Check(ctx context.Context, currentVersion string) (*UpdateNotice, error) { + current, ok := parseStableVersion(currentVersion) + if !ok { + return nil, nil + } + + state, err := n.readState() + if err != nil { + return nil, err + } + + now := n.now() + if now.Sub(state.CheckedAt) < updateCheckInterval { + return n.noticeFromState(state, currentVersion, current, now), nil + } + + release, err := n.registry.GetLatestReleaseInfo(ctx) + if err != nil { + return n.noticeFromState(state, currentVersion, current, now), err + } + + state.CheckedAt = now + state.LatestVersion = release.TagName + state.LatestPublishedAt = release.PublishedAt + + if err := n.writeState(state); err != nil { + return nil, err + } + + return n.noticeFromState(state, currentVersion, current, now), nil +} + +func (n *UpdateNotifier) MarkNotified(notice *UpdateNotice) error { + if notice == nil { + return nil + } + + state, err := n.readState() + if err != nil { + return err + } + + if state.LatestVersion != notice.LatestVersion { + return nil + } + + state.NotifiedAt = n.now() + + return n.writeState(state) +} + +func (n *UpdateNotifier) noticeFromState( + state notifierState, + currentVersion string, + current *semver.Version, + now time.Time, +) *UpdateNotice { + latest, ok := parseStableVersion(state.LatestVersion) + if !ok { + return nil + } + + if !current.LessThan(*latest) { + return nil + } + + if now.Sub(state.NotifiedAt) < updateNotifyInterval { + return nil + } + + command := "lets --upgrade" + if isHomebrewInstall(n.executablePath) { + if !state.LatestPublishedAt.IsZero() && now.Sub(state.LatestPublishedAt) < homebrewNoticeDelay { + return nil + } + + command = "brew upgrade lets-cli/tap/lets" + } + + return &UpdateNotice{ + CurrentVersion: currentVersion, + LatestVersion: state.LatestVersion, + command: command, + } +} + +func (n *UpdateNotifier) readState() (notifierState, error) { + var state notifierState + + file, err := os.Open(n.statePath) + if err != nil { + if os.IsNotExist(err) { + return state, nil + } + + return state, fmt.Errorf("failed to open update state file: %w", err) + } + + defer file.Close() + + if err := yaml.NewDecoder(file).Decode(&state); err != nil { + return notifierState{}, fmt.Errorf("failed to decode update state file: %w", err) + } + + return state, nil +} + +func (n *UpdateNotifier) writeState(state notifierState) error { + dir := filepath.Dir(n.statePath) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("failed to create update state dir: %w", err) + } + + tmpFile, err := os.CreateTemp(dir, "state.*.yaml") + if err != nil { + return fmt.Errorf("failed to create update state temp file: %w", err) + } + + tmpPath := tmpFile.Name() + + defer os.Remove(tmpPath) + + if err := yaml.NewEncoder(tmpFile).Encode(state); err != nil { + tmpFile.Close() + return fmt.Errorf("failed to encode update state file: %w", err) + } + + if err := tmpFile.Close(); err != nil { + return fmt.Errorf("failed to close update state temp file: %w", err) + } + + if err := os.Rename(tmpPath, n.statePath); err != nil { + return fmt.Errorf("failed to replace update state file: %w", err) + } + + return nil +} + +func letsStatePath() (string, error) { + configDir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("failed to get user config dir: %w", err) + } + + return filepath.Join(configDir, "lets", "state.yaml"), nil +} + +func parseStableVersion(version string) (*semver.Version, bool) { + parsed, err := util.ParseVersion(version) + if err != nil { + return nil, false + } + + if parsed.PreRelease != "" { + return nil, false + } + + return parsed, true +} + +func isHomebrewInstall(path string) bool { + return strings.Contains(path, "/Cellar/lets/") +} + +func LogUpdateCheckError(err error) { + if err == nil { + return + } + + log.Debugf("lets: update notifier: %s", err) +} diff --git a/internal/upgrade/notifier_test.go b/internal/upgrade/notifier_test.go new file mode 100644 index 00000000..84925df1 --- /dev/null +++ b/internal/upgrade/notifier_test.go @@ -0,0 +1,218 @@ +package upgrade + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/lets-cli/lets/internal/upgrade/registry" +) + +type mockNotifierRegistry struct { + release *registry.ReleaseInfo + calls int +} + +func (m *mockNotifierRegistry) GetLatestReleaseInfo(ctx context.Context) (*registry.ReleaseInfo, error) { + m.calls++ + return m.release, nil +} + +func (m *mockNotifierRegistry) GetLatestRelease() (string, error) { + m.calls++ + if m.release == nil { + return "", nil + } + + return m.release.TagName, nil +} + +func (m *mockNotifierRegistry) DownloadReleaseBinary(packageName string, version string, dstPath string) error { + return nil +} + +func (m *mockNotifierRegistry) GetPackageName(os string, arch string) (string, error) { + return "", nil +} + +func (m *mockNotifierRegistry) GetDownloadURL(repoURI string, packageName string, version string) string { + return "" +} + +func TestUpdateNotifierCheck(t *testing.T) { + now := time.Date(2026, 3, 19, 12, 0, 0, 0, time.UTC) + + t.Run("should use cached state without network call", func(t *testing.T) { + tmpDir := t.TempDir() + statePath := filepath.Join(tmpDir, "state.yaml") + reg := &mockNotifierRegistry{ + release: ®istry.ReleaseInfo{ + TagName: "v0.0.9", + PublishedAt: now.Add(-48 * time.Hour), + }, + } + + notifier := newUpdateNotifier(reg, statePath, "/usr/local/bin/lets", func() time.Time { return now }) + err := notifier.writeState(notifierState{ + CheckedAt: now.Add(-time.Hour), + LatestVersion: "v0.0.9", + LatestPublishedAt: now.Add(-48 * time.Hour), + }) + if err != nil { + t.Fatalf("writeState() error = %v", err) + } + + notice, err := notifier.Check(context.Background(), "0.0.8") + if err != nil { + t.Fatalf("Check() error = %v", err) + } + if notice == nil { + t.Fatal("expected cached notice") + } + if reg.calls != 0 { + t.Fatalf("expected no network calls, got %d", reg.calls) + } + }) + + t.Run("should skip dev builds", func(t *testing.T) { + tmpDir := t.TempDir() + reg := &mockNotifierRegistry{ + release: ®istry.ReleaseInfo{ + TagName: "v0.0.9", + PublishedAt: now.Add(-48 * time.Hour), + }, + } + + notifier := newUpdateNotifier(reg, filepath.Join(tmpDir, "state.yaml"), "/usr/local/bin/lets", func() time.Time { return now }) + + notice, err := notifier.Check(context.Background(), "0.0.8-dev") + if err != nil { + t.Fatalf("Check() error = %v", err) + } + if notice != nil { + t.Fatal("expected no notice for dev build") + } + if reg.calls != 0 { + t.Fatalf("expected no registry calls, got %d", reg.calls) + } + }) + + t.Run("should persist latest release after successful check", func(t *testing.T) { + tmpDir := t.TempDir() + statePath := filepath.Join(tmpDir, "state.yaml") + reg := &mockNotifierRegistry{ + release: ®istry.ReleaseInfo{ + TagName: "v0.0.9", + PublishedAt: now.Add(-48 * time.Hour), + }, + } + + notifier := newUpdateNotifier(reg, statePath, "/usr/local/bin/lets", func() time.Time { return now }) + + notice, err := notifier.Check(context.Background(), "0.0.8") + if err != nil { + t.Fatalf("Check() error = %v", err) + } + if notice == nil { + t.Fatal("expected update notice") + } + + state, err := notifier.readState() + if err != nil { + t.Fatalf("readState() error = %v", err) + } + if state.LatestVersion != "v0.0.9" { + t.Fatalf("expected latest version to be persisted, got %q", state.LatestVersion) + } + if state.CheckedAt != now { + t.Fatalf("expected checkedAt %s, got %s", now, state.CheckedAt) + } + }) + + t.Run("should suppress homebrew notices until release ages", func(t *testing.T) { + tmpDir := t.TempDir() + reg := &mockNotifierRegistry{ + release: ®istry.ReleaseInfo{ + TagName: "v0.0.9", + PublishedAt: now.Add(-2 * time.Hour), + }, + } + + notifier := newUpdateNotifier( + reg, + filepath.Join(tmpDir, "state.yaml"), + "/opt/homebrew/Cellar/lets/0.0.8/bin/lets", + func() time.Time { return now }, + ) + + notice, err := notifier.Check(context.Background(), "0.0.8") + if err != nil { + t.Fatalf("Check() error = %v", err) + } + if notice != nil { + t.Fatal("expected no notice during homebrew delay window") + } + }) + + t.Run("should suppress repeated notices after mark notified", func(t *testing.T) { + tmpDir := t.TempDir() + reg := &mockNotifierRegistry{ + release: ®istry.ReleaseInfo{ + TagName: "v0.0.9", + PublishedAt: now.Add(-48 * time.Hour), + }, + } + + notifier := newUpdateNotifier(reg, filepath.Join(tmpDir, "state.yaml"), "/usr/local/bin/lets", func() time.Time { return now }) + + notice, err := notifier.Check(context.Background(), "0.0.8") + if err != nil { + t.Fatalf("Check() error = %v", err) + } + if notice == nil { + t.Fatal("expected update notice") + } + + if err := notifier.MarkNotified(notice); err != nil { + t.Fatalf("MarkNotified() error = %v", err) + } + + secondNotice, err := notifier.Check(context.Background(), "0.0.8") + if err != nil { + t.Fatalf("second Check() error = %v", err) + } + if secondNotice != nil { + t.Fatal("expected repeated notice to be suppressed") + } + }) +} + +func TestLetsStatePath(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmpDir) + t.Setenv("HOME", tmpDir) + + path, err := letsStatePath() + if err != nil { + t.Fatalf("letsStatePath() error = %v", err) + } + if filepath.Base(path) != "state.yaml" { + t.Fatalf("unexpected state file %q", path) + } + if filepath.Base(filepath.Dir(path)) != "lets" { + t.Fatalf("unexpected state dir %q", filepath.Dir(path)) + } + if !filepath.IsAbs(path) { + t.Fatalf("expected absolute state path, got %q", path) + } +} + +func TestIsHomebrewInstall(t *testing.T) { + if !isHomebrewInstall("/opt/homebrew/Cellar/lets/0.0.1/bin/lets") { + t.Fatal("expected homebrew path to be detected") + } + if isHomebrewInstall("/usr/local/bin/lets") { + t.Fatal("did not expect generic install path to be detected as homebrew") + } +} diff --git a/internal/upgrade/registry/registry.go b/internal/upgrade/registry/registry.go index ec581101..7c992f3c 100644 --- a/internal/upgrade/registry/registry.go +++ b/internal/upgrade/registry/registry.go @@ -25,6 +25,7 @@ var osMap = map[string]string{ } type RepoRegistry interface { + GetLatestReleaseInfo(ctx context.Context) (*ReleaseInfo, error) GetLatestRelease() (string, error) DownloadReleaseBinary(packageName string, version string, dstPath string) error GetPackageName(os string, arch string) (string, error) @@ -35,6 +36,7 @@ type GithubRegistry struct { client *http.Client ctx context.Context repoURI string + apiURI string downloadURL string downloadPackageTimeout time.Duration latestReleaseTimeout time.Duration @@ -49,6 +51,7 @@ func NewGithubRegistry(ctx context.Context) *GithubRegistry { client: client, ctx: ctx, repoURI: "https://github.com/lets-cli/lets", + apiURI: "https://api.github.com/repos/lets-cli/lets", downloadURL: "", downloadPackageTimeout: 60 * 5 * time.Second, latestReleaseTimeout: 60 * time.Second, @@ -139,44 +142,64 @@ func (reg *GithubRegistry) DownloadReleaseBinary( return nil } -type release struct { - TagName string `json:"tag_name"` +type ReleaseInfo struct { + TagName string `json:"tag_name"` + PublishedAt time.Time `json:"published_at"` } func (reg *GithubRegistry) GetLatestRelease() (string, error) { - ctx, cancel := context.WithTimeout(reg.ctx, reg.latestReleaseTimeout) + release, err := reg.GetLatestReleaseInfo(reg.ctx) + if err != nil { + return "", err + } + + return release.TagName, nil +} + +func (reg *GithubRegistry) GetLatestReleaseInfo(ctx context.Context) (*ReleaseInfo, error) { + requestCtx := reg.ctx + if ctx != nil { + requestCtx = ctx + } + + requestCtx, cancel := context.WithTimeout(requestCtx, reg.latestReleaseTimeout) defer cancel() - url := reg.repoURI + "/releases/latest" + url := reg.apiURI + "/releases/latest" req, err := http.NewRequestWithContext( - ctx, + requestCtx, http.MethodGet, url, nil, ) if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) + return nil, fmt.Errorf("failed to create request: %w", err) } - req.Header.Add("Accept", "application/json") + req.Header.Add("Accept", "application/vnd.github+json") + req.Header.Add("User-Agent", "lets-cli") resp, err := reg.client.Do(req) if err != nil { - return "", fmt.Errorf("failed to make request: %w", err) + return nil, fmt.Errorf("failed to make request: %w", err) } defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return nil, fmt.Errorf("failed to fetch latest release: %s", resp.Status) + } + body, err := io.ReadAll(resp.Body) if err != nil { - return "", fmt.Errorf("failed to read package body: %w", err) + return nil, fmt.Errorf("failed to read package body: %w", err) } - var release release + var release ReleaseInfo if err := json.Unmarshal(body, &release); err != nil { - return "", fmt.Errorf("failed to decode package body: %w", err) + return nil, fmt.Errorf("failed to decode package body: %w", err) } - return release.TagName, nil + return &release, nil } diff --git a/internal/upgrade/registry/registry_test.go b/internal/upgrade/registry/registry_test.go new file mode 100644 index 00000000..2e26cdd4 --- /dev/null +++ b/internal/upgrade/registry/registry_test.go @@ -0,0 +1,56 @@ +package registry + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGithubRegistryGetLatestReleaseInfo(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Path; got != "/releases/latest" { + t.Fatalf("unexpected path %q", got) + } + if got := r.Header.Get("Accept"); got != "application/vnd.github+json" { + t.Fatalf("unexpected accept header %q", got) + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"tag_name":"v0.0.59","published_at":"2026-03-17T10:00:00Z"}`)) + })) + defer server.Close() + + reg := NewGithubRegistry(context.Background()) + reg.apiURI = server.URL + + release, err := reg.GetLatestReleaseInfo(context.Background()) + if err != nil { + t.Fatalf("GetLatestReleaseInfo() error = %v", err) + } + if release.TagName != "v0.0.59" { + t.Fatalf("expected tag v0.0.59, got %q", release.TagName) + } + if release.PublishedAt.IsZero() { + t.Fatal("expected publishedAt to be parsed") + } +} + +func TestGithubRegistryGetLatestRelease(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"tag_name":"v0.0.59","published_at":"2026-03-17T10:00:00Z"}`)) + })) + defer server.Close() + + reg := NewGithubRegistry(context.Background()) + reg.apiURI = server.URL + + version, err := reg.GetLatestRelease() + if err != nil { + t.Fatalf("GetLatestRelease() error = %v", err) + } + if version != "v0.0.59" { + t.Fatalf("expected version v0.0.59, got %q", version) + } +} diff --git a/internal/upgrade/upgrade_test.go b/internal/upgrade/upgrade_test.go index 485db661..6e168872 100644 --- a/internal/upgrade/upgrade_test.go +++ b/internal/upgrade/upgrade_test.go @@ -1,6 +1,7 @@ package upgrade import ( + "context" "fmt" "os" "path" @@ -18,6 +19,10 @@ func (m MockRegistry) GetLatestRelease() (string, error) { return m.latestVersion, nil } +func (m MockRegistry) GetLatestReleaseInfo(ctx context.Context) (*registry.ReleaseInfo, error) { + return ®istry.ReleaseInfo{TagName: m.latestVersion}, nil +} + func (m MockRegistry) DownloadReleaseBinary(packageName string, version string, dstPath string) error { file, err := os.Create(dstPath) if err != nil { diff --git a/internal/util/version.go b/internal/util/version.go index 8866bfef..069d4e24 100644 --- a/internal/util/version.go +++ b/internal/util/version.go @@ -2,11 +2,14 @@ package util import ( "fmt" + "strings" "github.com/coreos/go-semver/semver" ) func ParseVersion(version string) (*semver.Version, error) { + version = strings.TrimPrefix(version, "v") + v, err := semver.NewVersion(version) if err != nil { return nil, fmt.Errorf("can not create semver version from %s: %w", version, err)