build(release): add fork command

Amolith created

Change summary

Taskfile.yaml                  | 70 ++++++++++++++++++++++++++++++++++
internal/update/update.go      | 74 +++++++++++++++++++----------------
internal/update/update_test.go | 36 ++++++++++++++--
3 files changed, 140 insertions(+), 40 deletions(-)

Detailed changes

Taskfile.yaml 🔗

@@ -137,3 +137,73 @@ tasks:
       - go get charm.land/fantasy
       - go get github.com/charmbracelet/catwalk
       - go mod tidy
+
+  release:fork:
+    desc: Create and push a fork release tag (combined workflow)
+    cmds:
+      - task: release:fork:tag
+      - task: release:fork:push
+
+  release:fork:tag:
+    desc: Create a fork release tag locally (without pushing)
+    vars:
+      UPSTREAM_VERSION:
+        sh: git tag -l "v*" | sort -V | tail -1 || echo "v0.0.0"
+      EXISTING_FORK_TAGS:
+        sh: git tag -l "{{.UPSTREAM_VERSION}}-fork.*" | wc -l
+      NEXT_NUM:
+        sh: echo $(({{.EXISTING_FORK_TAGS}} + 1))
+      TAG: "{{.UPSTREAM_VERSION}}-fork.{{.NEXT_NUM}}"
+    prompt: "Create fork release {{.TAG}}?"
+    preconditions:
+      - sh: '[ $(git symbolic-ref --short HEAD) = "dev" ]'
+        msg: Not on dev branch
+      - sh: "[ $(git status --porcelain=2 | wc -l) = 0 ]"
+        msg: "Git is dirty"
+    cmds:
+      - git tag -d nightly || true
+      - git fetch upstream --tags
+      - >-
+        crush run "Please update the mentioned version in the top of the README to {{.TAG}}.
+        You only need to read the first 65 lines for the relevant bits.
+        Commit with 'docs(README): bump to [ver]' when you're done."
+      - git show --stat HEAD
+      - defer:
+          task: release:fork:cleanup
+          vars:
+            TAG: "{{.TAG}}"
+      - task: release:fork:confirm
+      - git push soft
+      - git tag -a {{.TAG}}
+      - echo "Tagged {{.TAG}} locally. Run 'task release:fork:push' to push."
+
+  release:fork:push:
+    desc: Push an existing fork release tag and notify module proxy
+    vars:
+      TAG:
+        sh: git describe --tags --abbrev=0 --match "*-fork.*" 2>/dev/null || echo ""
+    preconditions:
+      - sh: '[ -n "{{.TAG}}" ]'
+        msg: "No fork tag found. Run 'task release:fork:tag' first."
+      - sh: 'git tag -l "{{.TAG}}" | grep -q "{{.TAG}}"'
+        msg: "Tag {{.TAG}} does not exist locally"
+    prompt: "Push {{.TAG}} to remote and notify module proxy?"
+    cmds:
+      - git push soft {{.TAG}}
+      - echo "Released {{.TAG}}"
+      - go list -m git.secluded.site/crush@{{.TAG}} > /dev/null
+      - echo "Module proxy notified"
+
+  release:fork:confirm:
+    desc: Review LLM-generated commit and confirm release
+    internal: true
+    prompt: "Proceed with tagging?"
+
+  release:fork:cleanup:
+    desc: Undo LLM commit if release aborted
+    internal: true
+    cmds:
+      - git reset --hard HEAD~1
+      - git tag -d {{.TAG}} 2>/dev/null || true
+    status:
+      - git tag -l {{.TAG}} | grep -q {{.TAG}}

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,22 +38,27 @@ func (i Info) IsDevelopment() bool {
 
 // Available returns true if there's an update available.
 //
-// If both current and latest are stable versions, returns true if versions are
-// different.
-// If current is a pre-release and latest isn't, returns true.
-// If latest is a pre-release and current isn't, returns false.
+// Fork prereleases are treated as latest for their base version. Other
+// prereleases follow standard semver comparison.
 func (i Info) Available() bool {
-	cpr := strings.Contains(i.Current, "-")
-	lpr := strings.Contains(i.Latest, "-")
-	// current is pre release && latest isn't a prerelease
-	if cpr && !lpr {
-		return true
-	}
-	// latest is pre release && current isn't a prerelease
-	if lpr && !cpr {
+	vCurrent := "v" + i.Current
+	vLatest := "v" + i.Latest
+
+	if semver.Compare(vLatest, vCurrent) <= 0 {
 		return false
 	}
-	return i.Current != i.Latest
+
+	// 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.
@@ -59,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 {
@@ -106,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"
 )
@@ -19,7 +20,7 @@ func TestCheckForUpdate_Beta(t *testing.T) {
 		info, err := Check(t.Context(), "v0.10.0", testClient{"v0.11.0-beta.1"})
 		require.NoError(t, err)
 		require.NotNil(t, info)
-		require.False(t, info.Available())
+		require.True(t, info.Available())
 	})
 
 	t.Run("current is also beta", func(t *testing.T) {
@@ -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
 }