fix(update): treat fork releases as latest

Amolith created

Assisted-by: GLM 4.6 via Crush

Change summary

go.mod                         |  1 
go.sum                         |  2 +
internal/update/update.go      | 66 ++++++++++++++++++++++-------------
internal/update/update_test.go | 34 +++++++++++++++--
4 files changed, 74 insertions(+), 29 deletions(-)

Detailed changes

go.mod 🔗

@@ -55,6 +55,7 @@ require (
 	github.com/stretchr/testify v1.11.1
 	github.com/tidwall/sjson v1.2.5
 	github.com/zeebo/xxh3 v1.0.2
+	golang.org/x/mod v0.30.0
 	golang.org/x/sync v0.18.0
 	golang.org/x/text v0.31.0
 	gopkg.in/natefinch/lumberjack.v2 v2.2.1

go.sum 🔗

@@ -401,6 +401,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
 golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
+golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=

internal/update/update.go 🔗

@@ -9,15 +9,17 @@ import (
 	"regexp"
 	"strings"
 	"time"
+
+	"golang.org/x/mod/semver"
 )
 
 const (
-	githubApiUrl = "https://api.github.com/repos/charmbracelet/crush/releases/latest"
-	userAgent    = "crush/1.0"
+	moduleProxyURL = "https://proxy.golang.org/git.secluded.site/crush/@latest"
+	userAgent      = "crush/1.0"
 )
 
 // Default is the default [Client].
-var Default Client = &github{}
+var Default Client = &moduleProxy{}
 
 // Info contains information about an available update.
 type Info struct {
@@ -36,10 +38,27 @@ func (i Info) IsDevelopment() bool {
 
 // Available returns true if there's an update available.
 //
-// Returns true if the current and latest versions are different, treating
-// pre-releases as valid latest versions.
+// Fork prereleases are treated as latest for their base version. Other
+// prereleases follow standard semver comparison.
 func (i Info) Available() bool {
-	return i.Current != i.Latest
+	vCurrent := "v" + i.Current
+	vLatest := "v" + i.Latest
+
+	if semver.Compare(vLatest, vCurrent) <= 0 {
+		return false
+	}
+
+	// Fork vs stable override:
+	// If current is a fork prerelease (contains "-fork") and latest is the
+	// stable version of the same base, don't upgrade.
+	if strings.Contains(i.Current, "-fork") && !strings.Contains(i.Latest, "-") {
+		baseCurrent := strings.Split(vCurrent, "-")[0]
+		if baseCurrent == vLatest {
+			return false
+		}
+	}
+
+	return true
 }
 
 // Check checks if a new version is available.
@@ -47,44 +66,43 @@ func Check(ctx context.Context, current string, client Client) (Info, error) {
 	info := Info{
 		Current: current,
 		Latest:  current,
+		URL:     "https://git.secluded.site/crush",
 	}
 
-	release, err := client.Latest(ctx)
+	version, err := client.Latest(ctx)
 	if err != nil {
-		return info, fmt.Errorf("failed to fetch latest release: %w", err)
+		return info, fmt.Errorf("failed to fetch latest version: %w", err)
 	}
 
-	info.Latest = strings.TrimPrefix(release.TagName, "v")
+	info.Latest = strings.TrimPrefix(version.Version, "v")
 	info.Current = strings.TrimPrefix(info.Current, "v")
-	info.URL = release.HTMLURL
 	return info, nil
 }
 
-// Release represents a GitHub release.
-type Release struct {
-	TagName string `json:"tag_name"`
-	HTMLURL string `json:"html_url"`
+// ModuleVersion represents a version from the Go module proxy.
+type ModuleVersion struct {
+	Version string    `json:"Version"`
+	Time    time.Time `json:"Time"`
 }
 
-// Client is a client that can get the latest release.
+// Client is a client that can get the latest version.
 type Client interface {
-	Latest(ctx context.Context) (*Release, error)
+	Latest(ctx context.Context) (*ModuleVersion, error)
 }
 
-type github struct{}
+type moduleProxy struct{}
 
 // Latest implements [Client].
-func (c *github) Latest(ctx context.Context) (*Release, error) {
+func (c *moduleProxy) Latest(ctx context.Context) (*ModuleVersion, error) {
 	client := &http.Client{
 		Timeout: 30 * time.Second,
 	}
 
-	req, err := http.NewRequestWithContext(ctx, "GET", githubApiUrl, nil)
+	req, err := http.NewRequestWithContext(ctx, "GET", moduleProxyURL, nil)
 	if err != nil {
 		return nil, err
 	}
 	req.Header.Set("User-Agent", userAgent)
-	req.Header.Set("Accept", "application/vnd.github.v3+json")
 
 	resp, err := client.Do(req)
 	if err != nil {
@@ -94,13 +112,13 @@ func (c *github) Latest(ctx context.Context) (*Release, error) {
 
 	if resp.StatusCode != http.StatusOK {
 		body, _ := io.ReadAll(resp.Body)
-		return nil, fmt.Errorf("github api returned status %d: %s", resp.StatusCode, string(body))
+		return nil, fmt.Errorf("module proxy returned status %d: %s", resp.StatusCode, string(body))
 	}
 
-	var release Release
-	if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
+	var version ModuleVersion
+	if err := json.NewDecoder(resp.Body).Decode(&version); err != nil {
 		return nil, err
 	}
 
-	return &release, nil
+	return &version, nil
 }

internal/update/update_test.go 🔗

@@ -3,6 +3,7 @@ package update
 import (
 	"context"
 	"testing"
+	"time"
 
 	"github.com/stretchr/testify/require"
 )
@@ -37,12 +38,35 @@ func TestCheckForUpdate_Beta(t *testing.T) {
 	})
 }
 
-type testClient struct{ tag string }
+func TestCheckForUpdate_Fork(t *testing.T) {
+	t.Run("fork version vs stable", func(t *testing.T) {
+		info, err := Check(t.Context(), "v0.18.5-fork.1", testClient{"v0.18.5"})
+		require.NoError(t, err)
+		require.NotNil(t, info)
+		require.False(t, info.Available())
+	})
+
+	t.Run("fork version vs newer stable", func(t *testing.T) {
+		info, err := Check(t.Context(), "v0.18.5-fork.1", testClient{"v0.18.6"})
+		require.NoError(t, err)
+		require.NotNil(t, info)
+		require.True(t, info.Available())
+	})
+
+	t.Run("fork version vs newer fork version", func(t *testing.T) {
+		info, err := Check(t.Context(), "v0.18.5-fork.1", testClient{"v0.18.5-fork.2"})
+		require.NoError(t, err)
+		require.NotNil(t, info)
+		require.True(t, info.Available())
+	})
+}
+
+type testClient struct{ version string }
 
 // Latest implements Client.
-func (t testClient) Latest(ctx context.Context) (*Release, error) {
-	return &Release{
-		TagName: t.tag,
-		HTMLURL: "https://example.org",
+func (t testClient) Latest(ctx context.Context) (*ModuleVersion, error) {
+	return &ModuleVersion{
+		Version: t.version,
+		Time:    time.Now(),
 	}, nil
 }