build(release): add fork command

Amolith created

Change summary

Taskfile.yaml                  |  10 +
internal/update/update.go      |  74 ++++++-----
internal/update/update_test.go |  36 +++++-
release-fork.fish              | 216 ++++++++++++++++++++++++++++++++++++
4 files changed, 296 insertions(+), 40 deletions(-)

Detailed changes

Taskfile.yaml 🔗

@@ -184,3 +184,13 @@ tasks:
       - go get charm.land/fantasy
       - go get charm.land/catwalk
       - go mod tidy
+
+  release:fork:
+    desc: Create and push a fork release tag
+    cmds:
+      - ./release-fork.fish
+
+  release:fork:build:
+    desc: Cross-compile and pack for all release targets (no tag/push)
+    cmds:
+      - ./release-fork.fish --from build

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
 }

release-fork.fish 🔗

@@ -0,0 +1,216 @@
+#!/usr/bin/env fish
+
+function __release_fork_usage
+    echo "Usage: release-fork.fish [--from <stage>] [--only <stage>] [--help]
+
+Stages (run in order):
+    tag       Compute next fork tag, push branch, create and push tag
+    build     Cross-compile for all release targets
+    pack      Compress Linux binaries with UPX
+    upload    Upload artifacts via release(1)
+
+Flags:
+    --from <stage>   Start at this stage and continue through the rest
+    --only <stage>   Run just this one stage
+    --help, -h       Show this help
+
+With no flags, all stages run in order (tag → build → pack → upload).
+
+Examples:
+    ./release-fork.fish                 Full release
+    ./release-fork.fish --from build    Rebuild, pack, and upload (skip tagging)
+    ./release-fork.fish --only build    Just cross-compile
+    ./release-fork.fish --only upload   Re-upload existing dist/ artifacts"
+end
+
+set -l targets \
+    linux/amd64 \
+    linux/arm64 \
+    darwin/amd64 \
+    darwin/arm64 \
+    windows/amd64 \
+    freebsd/amd64
+
+# UPX 5.x dropped macOS support; windows/amd64 is PE32 only and
+# freebsd/amd64 is ELF32 only, so only Linux targets are packed.
+set -l pack_targets \
+    linux-amd64 \
+    linux-arm64
+
+# Global so __should_run can see them. Prefixed to avoid collisions.
+set -g __rf_stages tag build pack upload
+set -g __rf_from ""
+set -g __rf_only ""
+
+# --- Parse flags ---
+
+set -l i 1
+while test $i -le (count $argv)
+    switch $argv[$i]
+        case --from
+            set i (math $i + 1)
+            set -g __rf_from $argv[$i]
+        case --only
+            set i (math $i + 1)
+            set -g __rf_only $argv[$i]
+        case --help -h
+            __release_fork_usage
+            exit 0
+        case '*'
+            echo "Error: unknown flag '$argv[$i]'" >&2
+            __release_fork_usage >&2
+            exit 1
+    end
+    set i (math $i + 1)
+end
+
+if test -n "$__rf_from" -a -n "$__rf_only"
+    echo "Error: --from and --only are mutually exclusive" >&2
+    exit 1
+end
+
+if test -n "$__rf_from"; and not contains -- "$__rf_from" $__rf_stages
+    echo "Error: unknown stage '$__rf_from' (valid: $__rf_stages)" >&2
+    exit 1
+end
+
+if test -n "$__rf_only"; and not contains -- "$__rf_only" $__rf_stages
+    echo "Error: unknown stage '$__rf_only' (valid: $__rf_stages)" >&2
+    exit 1
+end
+
+# --- Determine which stages to run ---
+
+# Returns 0 if the given stage should execute, 1 otherwise.
+function __should_run -a stage
+    if test -n "$__rf_only"
+        test "$stage" = "$__rf_only"
+        return
+    end
+
+    if test -z "$__rf_from"
+        return 0
+    end
+
+    # Walk the stage list; once we reach __rf_from, every subsequent
+    # stage (including __rf_from itself) should run.
+    set -l reached 0
+    for s in $__rf_stages
+        if test "$s" = "$__rf_from"
+            set reached 1
+        end
+        if test $reached -eq 1 -a "$s" = "$stage"
+            return 0
+        end
+    end
+    return 1
+end
+
+# --- Resolve tag ---
+#
+# When skipping the tag stage we still need a tag for filenames and
+# ldflags, so fall back to git describe.
+
+set -l tag
+
+if __should_run tag
+    # --- Guards ---
+
+    set -l branch (git symbolic-ref --short HEAD)
+    if test "$branch" != dev
+        echo "Error: not on dev branch (on $branch)" >&2
+        exit 1
+    end
+
+    if test (git status --porcelain=2 | wc -l) -ne 0
+        echo "Error: git working tree is dirty" >&2
+        exit 1
+    end
+
+    # --- Fetch tags ---
+
+    git tag -d nightly 2>/dev/null; or true
+    git fetch upstream --tags
+
+    # --- Compute next fork tag ---
+
+    set -l upstream_ver (git tag -l "v*" | grep -v -- '-fork\.' | sort -V | tail -1; or echo v0.0.0)
+    set -l existing (git tag -l "$upstream_ver-fork.*" | wc -l | string trim)
+    set -l next_num (math $existing + 1)
+    set tag "$upstream_ver-fork.$next_num"
+
+    # --- Confirm creation ---
+
+    read -P "Create fork release $tag? [y/N] " confirm
+    if test "$confirm" != y -a "$confirm" != Y
+        echo Aborted.
+        exit 0
+    end
+
+    # --- Push branch (force because we amended) ---
+
+    git push --force-with-lease; or begin
+        echo "Error: branch push failed" >&2
+        exit 1
+    end
+
+    # --- Tag and push ---
+
+    git tag -a $tag; or begin
+        echo "Error: tagging failed" >&2
+        exit 1
+    end
+
+    git push soft $tag; or begin
+        echo "Error: tag push failed — deleting local tag" >&2
+        git tag -d $tag 2>/dev/null
+        exit 1
+    end
+
+    echo "Released $tag"
+else
+    set tag (git describe --tags --always 2>/dev/null; or echo dev)
+end
+
+# --- Build ---
+
+if __should_run build
+    set -l ldflags "-s -w -X git.secluded.site/crush/internal/version.Version=$tag"
+
+    rm -rf dist
+    mkdir -p dist
+
+    for target in $targets
+        set -l os (echo $target | cut -d/ -f1)
+        set -l arch (echo $target | cut -d/ -f2)
+        set -l ext ""
+        if test "$os" = windows
+            set ext .exe
+        end
+
+        echo "Building $os/$arch..."
+        env CGO_ENABLED=0 GOEXPERIMENT=greenteagc GOOS=$os GOARCH=$arch \
+            go build -o "dist/crush-$tag-$os-$arch$ext" -ldflags "$ldflags"; or begin
+            echo "Error: build failed for $os/$arch" >&2
+            exit 1
+        end
+    end
+end
+
+# --- Pack ---
+
+if __should_run pack
+    for suffix in $pack_targets
+        set -l bin "dist/crush-$tag-$suffix"
+        if test -f "$bin"
+            echo "Packing $suffix..."
+            upx -q "$bin"
+        end
+    end
+end
+
+# --- Upload ---
+
+if __should_run upload
+    fish -c "release upload crush $tag --latest dist/*"
+end