@@ -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
}
@@ -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
}
@@ -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