From 7b02d592caa47a3aa22f481fad5e42d2bd3d56d7 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Wed, 29 Jul 2026 14:03:34 +0300 Subject: [PATCH] Add prerelease self-upgrade flag --- docs/docs/changelog.md | 1 + docs/docs/cli.md | 2 +- docs/docs/installation.mdx | 6 ++ internal/cmd/root_test.go | 36 +++++++++++ internal/cmd/upgrade.go | 13 +++- internal/upgrade/notifier_test.go | 4 ++ internal/upgrade/registry/registry.go | 73 ++++++++++++++++++++++ internal/upgrade/registry/registry_test.go | 53 ++++++++++++++++ internal/upgrade/upgrade.go | 25 +++++++- internal/upgrade/upgrade_test.go | 29 ++++++++- 10 files changed, 235 insertions(+), 7 deletions(-) diff --git a/docs/docs/changelog.md b/docs/docs/changelog.md index 3a2f7653..af613879 100644 --- a/docs/docs/changelog.md +++ b/docs/docs/changelog.md @@ -6,6 +6,7 @@ title: Changelog ## [Unreleased](https://github.com/lets-cli/lets/releases/tag/v0.0.X) * `[Added]` Add `checksum.files`, `checksum.sh`, and `checksum.persist` command checksum syntax while keeping the old checksum format compatible. +* `[Added]` Add `lets self upgrade --pre` to opt into upgrading to the latest prerelease. * `[Added]` Add `lets self fix` config migration command with `--dry-run` preview output for deprecated checksum syntax. * `[Fixed]` Make checksum calculation respect command-level `work_dir` overrides. * `[Fixed]` Restore the release checkout after the GoReleaser dry run so prerelease publishing does not fail on a dirty `go.mod`. diff --git a/docs/docs/cli.md b/docs/docs/cli.md index 8719e906..e9e52a64 100644 --- a/docs/docs/cli.md +++ b/docs/docs/cli.md @@ -18,6 +18,6 @@ title: CLI options |`-h, --help`|||help for lets| |`-v, --version`|||version for lets| -Upgrade the lets binary with `lets self upgrade`. +Upgrade the lets binary with `lets self upgrade`. Use `lets self upgrade --pre` to opt into the latest prerelease. Migrate deprecated config syntax with `lets self fix`. Use `lets self fix --dry-run` to print migrated config content before writing files. diff --git a/docs/docs/installation.mdx b/docs/docs/installation.mdx index 5d2263e2..8e2478b7 100644 --- a/docs/docs/installation.mdx +++ b/docs/docs/installation.mdx @@ -134,6 +134,12 @@ It updates the binary located at `which lets`. lets self upgrade ``` +To opt into the latest prerelease: + +```bash +lets self upgrade --pre +``` + Self upgrade is intended for installer-managed and manual user-owned installs. If `lets` was installed by Homebrew, Arch, or another package manager, use that package manager instead. diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go index ef556497..3a2650ae 100644 --- a/internal/cmd/root_test.go +++ b/internal/cmd/root_test.go @@ -450,6 +450,42 @@ func TestSelfCmd(t *testing.T) { } }) + t.Run("should pass pre flag to self upgrade factory", func(t *testing.T) { + bufOut := new(bytes.Buffer) + gotPre := false + + rootCmd := CreateRootCommand("v0.0.0-test", "") + rootCmd.SetArgs([]string{"self", "upgrade", "--pre"}) + rootCmd.SetOut(bufOut) + rootCmd.SetErr(bufOut) + selfCmd := &cobra.Command{ + Use: "self", + Short: "Manage lets CLI itself", + } + rootCmd.AddCommand(selfCmd) + + selfCmd.AddCommand(initUpgradeCommandWith(func(cmd *cobra.Command) (upgrade.Upgrader, error) { + var err error + gotPre, err = cmd.Flags().GetBool("pre") + if err != nil { + return nil, err + } + + return mockUpgraderFunc(func(ctx context.Context) error { + return nil + }), nil + })) + + err := rootCmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !gotPre { + t.Fatal("expected pre flag to be set") + } + }) + t.Run("should return upgrader error for self upgrade command", func(t *testing.T) { bufOut := new(bytes.Buffer) diff --git a/internal/cmd/upgrade.go b/internal/cmd/upgrade.go index 1d4d1047..51b2347b 100644 --- a/internal/cmd/upgrade.go +++ b/internal/cmd/upgrade.go @@ -18,8 +18,18 @@ type upgraderFactory func(cmd *cobra.Command) (upgrade.Upgrader, error) func initUpgradeCommand(version string, appSettings settings.Settings) *cobra.Command { return initUpgradeCommandWith(func(cmd *cobra.Command) (upgrade.Upgrader, error) { progress := upgradeProgress(cmd.ErrOrStderr(), appSettings) + options := []upgrade.BinaryUpgraderOption{upgrade.WithProgress(progress)} - return upgrade.NewBinaryUpgrader(registry.NewGithubRegistry(), version, upgrade.WithProgress(progress)) + pre, err := cmd.Flags().GetBool("pre") + if err != nil { + return nil, err + } + + if pre { + options = append(options, upgrade.WithPrerelease()) + } + + return upgrade.NewBinaryUpgrader(registry.NewGithubRegistry(), version, options...) }) } @@ -55,6 +65,7 @@ func initUpgradeCommandWith(createUpgrader upgraderFactory) *cobra.Command { return nil }, } + upgradeCmd.Flags().Bool("pre", false, "upgrade to latest prerelease version") return upgradeCmd } diff --git a/internal/upgrade/notifier_test.go b/internal/upgrade/notifier_test.go index 613a1f5b..136ca46a 100644 --- a/internal/upgrade/notifier_test.go +++ b/internal/upgrade/notifier_test.go @@ -30,6 +30,10 @@ func (m *mockNotifierRegistry) GetLatestRelease(ctx context.Context) (string, er return m.release.TagName, nil } +func (m *mockNotifierRegistry) GetLatestPrerelease(ctx context.Context) (string, error) { + return "", nil +} + func (m *mockNotifierRegistry) DownloadReleaseBinary( ctx context.Context, packageName string, diff --git a/internal/upgrade/registry/registry.go b/internal/upgrade/registry/registry.go index fb4c716f..a76fa7fc 100644 --- a/internal/upgrade/registry/registry.go +++ b/internal/upgrade/registry/registry.go @@ -3,6 +3,7 @@ package registry import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -28,6 +29,7 @@ var osMap = map[string]string{ type RepoRegistry interface { GetLatestReleaseInfo(ctx context.Context) (*ReleaseInfo, error) GetLatestRelease(ctx context.Context) (string, error) + GetLatestPrerelease(ctx context.Context) (string, error) DownloadReleaseBinary( ctx context.Context, packageName string, @@ -179,6 +181,8 @@ func (r progressReadCloser) Read(p []byte) (int, error) { type ReleaseInfo struct { TagName string `json:"tag_name"` PublishedAt time.Time `json:"published_at"` + Prerelease bool `json:"prerelease"` + Draft bool `json:"draft"` } func (reg *GithubRegistry) GetLatestRelease(ctx context.Context) (string, error) { @@ -232,3 +236,72 @@ func (reg *GithubRegistry) GetLatestReleaseInfo(ctx context.Context) (*ReleaseIn return &release, nil } + +func (reg *GithubRegistry) GetLatestPrerelease(ctx context.Context) (string, error) { + release, err := reg.GetLatestPrereleaseInfo(ctx) + if err != nil { + return "", err + } + + return release.TagName, nil +} + +func (reg *GithubRegistry) GetLatestPrereleaseInfo(ctx context.Context) (*ReleaseInfo, error) { + requestCtx, cancel := context.WithTimeout(ctx, reg.latestReleaseTimeout) + defer cancel() + + url := reg.apiURI + "/releases?per_page=100" + + req, err := http.NewRequestWithContext( + requestCtx, + http.MethodGet, + url, + nil, + ) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + 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 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 releases: %s", resp.Status) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read package body: %w", err) + } + + var releases []ReleaseInfo + if err := json.Unmarshal(body, &releases); err != nil { + return nil, fmt.Errorf("failed to decode package body: %w", err) + } + + var latestPrerelease *ReleaseInfo + + for i := range releases { + release := &releases[i] + if release.Draft || !release.Prerelease { + continue + } + + if latestPrerelease == nil || release.PublishedAt.After(latestPrerelease.PublishedAt) { + latestPrerelease = release + } + } + + if latestPrerelease == nil { + return nil, errors.New("no prerelease found") + } + + return latestPrerelease, nil +} diff --git a/internal/upgrade/registry/registry_test.go b/internal/upgrade/registry/registry_test.go index 4b2bcbf5..719af552 100644 --- a/internal/upgrade/registry/registry_test.go +++ b/internal/upgrade/registry/registry_test.go @@ -82,6 +82,59 @@ func TestGithubRegistryGetLatestRelease(t *testing.T) { } } +func TestGithubRegistryGetLatestPrerelease(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Path; got != "/releases" { + t.Fatalf("unexpected path %q", got) + } + if got := r.URL.Query().Get("per_page"); got != "100" { + t.Fatalf("unexpected per_page query %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.62","published_at":"2026-03-17T10:00:00Z","prerelease":false}, + {"tag_name":"v0.0.63-rc1","published_at":"2026-03-18T10:00:00Z","prerelease":true}, + {"tag_name":"v0.0.64-rc1","published_at":"2026-03-19T10:00:00Z","prerelease":true,"draft":true}, + {"tag_name":"v0.0.63-rc2","published_at":"2026-03-20T10:00:00Z","prerelease":true} + ]`)) + })) + defer server.Close() + + reg := NewGithubRegistry() + reg.apiURI = server.URL + + version, err := reg.GetLatestPrerelease(context.Background()) + if err != nil { + t.Fatalf("GetLatestPrerelease() error = %v", err) + } + if version != "v0.0.63-rc2" { + t.Fatalf("expected version v0.0.63-rc2, got %q", version) + } +} + +func TestGithubRegistryGetLatestPrereleaseNoMatch(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.62","published_at":"2026-03-17T10:00:00Z","prerelease":false}, + {"tag_name":"v0.0.63-rc1","published_at":"2026-03-18T10:00:00Z","prerelease":true,"draft":true} + ]`)) + })) + defer server.Close() + + reg := NewGithubRegistry() + reg.apiURI = server.URL + + _, err := reg.GetLatestPrerelease(context.Background()) + if err == nil { + t.Fatal("expected no prerelease error") + } +} + func TestGithubRegistryDownloadReleaseBinaryReportsProgress(t *testing.T) { archive := releaseArchive(t, "updated binary") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/upgrade/upgrade.go b/internal/upgrade/upgrade.go index bb1ec7d4..7c9c3ce9 100644 --- a/internal/upgrade/upgrade.go +++ b/internal/upgrade/upgrade.go @@ -23,6 +23,7 @@ type Upgrader interface { type BinaryUpgrader struct { registry registry.RepoRegistry currentVersion string + prerelease bool binaryPath string downloadPath string backupPath string @@ -37,6 +38,12 @@ func WithProgress(progress fetch.ProgressObserver) BinaryUpgraderOption { } } +func WithPrerelease() BinaryUpgraderOption { + return func(upgrader *BinaryUpgrader) { + upgrader.prerelease = true + } +} + func NewBinaryUpgrader(reg registry.RepoRegistry, currentVersion string, options ...BinaryUpgraderOption) (*BinaryUpgrader, error) { executablePath, err := binaryPath() if err != nil { @@ -66,9 +73,9 @@ func (up *BinaryUpgrader) Upgrade(ctx context.Context) error { return err } - latestVersion, err := up.registry.GetLatestRelease(ctx) + latestVersion, releaseKind, err := up.latestVersion(ctx) if err != nil { - return fmt.Errorf("failed to get latest release version: %w", err) + return fmt.Errorf("failed to get %s version: %w", releaseKind, err) } if up.currentVersion == latestVersion { @@ -82,7 +89,7 @@ func (up *BinaryUpgrader) Upgrade(ctx context.Context) error { return fmt.Errorf("failed to get package name: %w", err) } - log.Printf("Downloading latest release %s...", latestVersion) + log.Printf("Downloading %s %s...", releaseKind, latestVersion) err = up.registry.DownloadReleaseBinary( ctx, @@ -110,6 +117,18 @@ func (up *BinaryUpgrader) Upgrade(ctx context.Context) error { return nil } +func (up *BinaryUpgrader) latestVersion(ctx context.Context) (string, string, error) { + if up.prerelease { + version, err := up.registry.GetLatestPrerelease(ctx) + + return version, "latest prerelease", err + } + + version, err := up.registry.GetLatestRelease(ctx) + + return version, "latest release", err +} + func binaryPath() (string, error) { return os.Executable() } diff --git a/internal/upgrade/upgrade_test.go b/internal/upgrade/upgrade_test.go index d15807c6..f52da8f9 100644 --- a/internal/upgrade/upgrade_test.go +++ b/internal/upgrade/upgrade_test.go @@ -14,7 +14,8 @@ import ( ) type MockRegistry struct { - latestVersion string + latestVersion string + prereleaseVersion string } func (m MockRegistry) GetLatestRelease(ctx context.Context) (string, error) { @@ -25,6 +26,10 @@ func (m MockRegistry) GetLatestReleaseInfo(ctx context.Context) (*registry.Relea return ®istry.ReleaseInfo{TagName: m.latestVersion}, nil } +func (m MockRegistry) GetLatestPrerelease(ctx context.Context) (string, error) { + return m.prereleaseVersion, nil +} + func (m MockRegistry) DownloadReleaseBinary( ctx context.Context, packageName string, @@ -37,7 +42,7 @@ func (m MockRegistry) DownloadReleaseBinary( return err } - latest, _ := m.GetLatestRelease(ctx) + latest := version _, err = fmt.Fprint(file, latest) if err != nil { @@ -153,6 +158,26 @@ func TestSelfUpgrade(t *testing.T) { } }) + t.Run("should self-upgrade to latest prerelease version", func(t *testing.T) { + currentVersion := "v0.0.1" + prereleaseVersion := "v0.0.2-rc1" + + upgrader, err := newMockUpgrader(&MockRegistry{prereleaseVersion: prereleaseVersion}, currentVersion) + if err != nil { + t.Errorf("failed to create upgrader: %s", err) + } + WithPrerelease()(upgrader) + + err = upgrader.Upgrade(context.Background()) + if err != nil { + t.Errorf("failed to upgrade: %s", err) + } + + if !testVersion(upgrader.binaryPath, prereleaseVersion) { + t.Errorf("expected version %s", prereleaseVersion) + } + }) + t.Run("should self-upgrade symlink target", func(t *testing.T) { currentVersion := "v0.0.1" latestVersion := "v0.0.2"