From 6479d079a40badf0c6bf1e9b40f56828caac63db Mon Sep 17 00:00:00 2001 From: Amolith Date: Wed, 29 Oct 2025 12:26:29 -0600 Subject: [PATCH] build(release): add fork command --- 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(-) create mode 100755 release-fork.fish diff --git a/Taskfile.yaml b/Taskfile.yaml index 476626fde4f0ed33d26fa20c2dc8b00ecd557af6..3ab73a880573ab71a226ccaeaa518dce108f5a84 100644 --- a/Taskfile.yaml +++ b/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 diff --git a/internal/update/update.go b/internal/update/update.go index dd733da542259e60cc166c3ef48e645a527a548f..4080a6a99976eb8ce9953d5d08bf250880853f77 100644 --- a/internal/update/update.go +++ b/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 } diff --git a/internal/update/update_test.go b/internal/update/update_test.go index 87e3849eb5a9ddc06b1e22c15c0bdde0b7739085..aa42c882b9beede322931f62c2f8dc8bcecec319 100644 --- a/internal/update/update_test.go +++ b/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 } diff --git a/release-fork.fish b/release-fork.fish new file mode 100755 index 0000000000000000000000000000000000000000..d243f7f123c885adb1caf4c4dcd9ffa9939352d0 --- /dev/null +++ b/release-fork.fish @@ -0,0 +1,216 @@ +#!/usr/bin/env fish + +function __release_fork_usage + echo "Usage: release-fork.fish [--from ] [--only ] [--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 Start at this stage and continue through the rest + --only 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