diff --git a/Taskfile.yaml b/Taskfile.yaml index cdd8ab8afc39089834284f5cdf444ba56e864acd..b39300837eae9d0566d23a19bac7d1e4595b4743 100644 --- a/Taskfile.yaml +++ b/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}} diff --git a/internal/update/update.go b/internal/update/update.go index a813fe3516dc28233e3df01c77d4d62d4d97db18..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 }