From 29c196e66555077ab4e95086ed1584238333f167 Mon Sep 17 00:00:00 2001 From: drew Date: Tue, 23 Jun 2026 16:57:52 +0400 Subject: [PATCH 1/2] chore: change auto-update for RCs Signed-off-by: drew --- .github/workflows/release-rc.yml | 3 +- .goreleaser.rc.yml | 24 +++++ internal/updater/updater.go | 151 +++++++++++++++++++++++++++---- internal/updater/updater_test.go | 103 +++++++++++++++++++++ 4 files changed, 263 insertions(+), 18 deletions(-) create mode 100644 internal/updater/updater_test.go diff --git a/.github/workflows/release-rc.yml b/.github/workflows/release-rc.yml index 44af0d3b..aa68dc6f 100644 --- a/.github/workflows/release-rc.yml +++ b/.github/workflows/release-rc.yml @@ -172,7 +172,8 @@ jobs: args: release --clean --config .goreleaser.rc.yml env: SDK_PATH: ${{ steps.macos_sdk.outputs.path }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} + HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} # Force the changelog base to the last stable tag so rc notes are # cumulative across every rc since that release. GORELEASER_PREVIOUS_TAG: ${{ needs.tag.outputs.prev_stable }} diff --git a/.goreleaser.rc.yml b/.goreleaser.rc.yml index 89c8c0f3..7e82e178 100644 --- a/.goreleaser.rc.yml +++ b/.goreleaser.rc.yml @@ -74,4 +74,28 @@ release: > [!WARNING] > This is a **release candidate** for v1 — not a final release. Expect bugs and report them. + - **Homebrew:** `brew upgrade floatpane/matcha/matcha` - **Snapcraft:** `snap install matcha --candidate` + +# Publish the RC to the Homebrew tap as the v1 cask. This intentionally points +# the main matcha cask at the latest RC so release/v1 users can update via +# `brew upgrade matcha@v1` during the stabilization period. +homebrew_casks: + - name: matcha@v1 + binaries: [matcha] + repository: + owner: floatpane + name: homebrew-matcha + token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" + commit_author: + name: Floatpane Bot + email: us@floatpane.com + description: "A beautiful and functional email client for your terminal." + homepage: "https://matcha.email" + # Strip Apple quarantine xattr so unsigned binary runs without Gatekeeper prompt. + hooks: + post: + install: | + if OS.mac? + system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/matcha"] + end diff --git a/internal/updater/updater.go b/internal/updater/updater.go index ba6eace9..10d2064a 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -11,6 +11,7 @@ import ( "os/exec" "path/filepath" "runtime" + "strconv" "strings" "time" @@ -47,6 +48,111 @@ type githubRelease struct { } `json:"assets"` } +// compareVersions reports whether a is a newer version than b, honoring the +// special v1.0.0-rcN ordering used by the release/v1 stabilization branch. RC +// versions are compared by their numeric suffix; otherwise the standard semver +// ordering is used, falling back to a simple suffix check and string comparison. +func isNewer(a, b string) bool { + a = strings.TrimPrefix(a, "v") + b = strings.TrimPrefix(b, "v") + if a == b { + return false + } + + // Compare v1.0.0-rcN versions by suffix number. + if isRC(a) && isRC(b) { + return rcNumber(a) > rcNumber(b) + } + + // If one side is an rc and the other is not, semver handles the + // prerelease ordering (rc < stable). We only get here when both sides + // have been normalized to the same family. + return semverLess(b, a) +} + +// isRC reports whether a version is a v1 release-candidate pre-release, +// e.g. v1.0.0-rc1 or 1.0.0-rc2. +func isRC(v string) bool { + return strings.Contains(v, "1.0.0-rc") +} + +// rcNumber extracts the numeric suffix from a v1.0.0-rcN version. If the +// suffix is not present or not numeric, it returns 0. +func rcNumber(v string) int { + v = strings.TrimPrefix(v, "v") + if !strings.HasPrefix(v, "1.0.0-rc") { + return 0 + } + n := strings.TrimPrefix(v, "1.0.0-rc") + if num, err := strconv.Atoi(n); err == nil { + return num + } + return 0 +} + +// semverLess reports whether a < b using a lightweight semver parse. It only +// supports numeric major/minor/patch components and a single optional +// pre-release suffix (e.g. rc1). It returns false when comparison is ambiguous. +func semverLess(a, b string) bool { + a = strings.TrimPrefix(a, "v") + b = strings.TrimPrefix(b, "v") + if a == b { + return false + } + + pa, preA := splitPre(a) + pb, preB := splitPre(b) + + sa := strings.Split(pa, ".") + sb := strings.Split(pb, ".") + max := len(sa) + if len(sb) > max { + max = len(sb) + } + for i := 0; i < max; i++ { + na, nb := 0, 0 + if i < len(sa) { + na, _ = strconv.Atoi(sa[i]) + } + if i < len(sb) { + nb, _ = strconv.Atoi(sb[i]) + } + if na < nb { + return true + } + if na > nb { + return false + } + } + + // Core version equal; decide by pre-release presence and suffix. + if preA == "" && preB != "" { + return false // a is stable, b is prerelease -> a > b -> a < b is false + } + if preA != "" && preB == "" { + return true // a is prerelease, b is stable -> a < b + } + if preA == preB { + return false + } + // Both have numeric suffixes; compare them. + na, _ := strconv.Atoi(preA) + nb, _ := strconv.Atoi(preB) + return na < nb +} + +// splitPre separates a version string into core and pre-release suffix. It +// looks for the first non-numeric, non-dot character and treats the remainder as +// the pre-release suffix (e.g. "1.0.0-rc1" -> ("1.0.0", "1")). +func splitPre(v string) (core, pre string) { + for i, r := range v { + if !(r >= '0' && r <= '9') && r != '.' { + return v[:i], strings.TrimLeft(v[i:], "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-+") + } + } + return v, "" +} + // SetVersion provides the build-injected version value to the updater package. // It should be called during program initialization before any update check is run. func SetVersion(v string) { @@ -154,7 +260,7 @@ func CheckForUpdatesCmd() tea.Cmd { latest := strings.TrimPrefix(rel.TagName, "v") installed := strings.TrimPrefix(DetectInstalledVersion(), "v") - if latest != "" && installed != "" && latest != installed { + if latest != "" && installed != "" && latest != installed && isNewer(latest, installed) { return UpdateAvailableMsg{Latest: latest, Current: installed} } return nil @@ -183,11 +289,18 @@ func RunUpdateCLI() (err error) { loglevel.Infof("Latest version: %s", latestTag) cur := strings.TrimPrefix(version, "v") - if latestTag == "" || cur == latestTag { + if latestTag == "" || cur == latestTag || !isNewer(latestTag, cur) { loglevel.Infof("Already up to date.") return nil } + // When running on a v1 release candidate, only allow update paths that are + // actually publishing v1 RCs: Homebrew (matcha@v1), Snap (candidate), and the + // manual GitHub release binary fallback. All other package managers are + // still tied to v0 stable releases and would downgrade or reinstall the + // wrong stream, so skip them. + isRCInstalled := isRC(cur) + switch runtime.GOOS { case goosDarwin: if tryHomebrewUpgrade() { @@ -197,21 +310,25 @@ func RunUpdateCLI() (err error) { if trySnapRefresh() { return nil } - if tryFlatpakUpdate() { - return nil - } - if tryAURUpdate() { - return nil - } - if tryNixUpdate() { - return nil + if !isRCInstalled { + if tryFlatpakUpdate() { + return nil + } + if tryAURUpdate() { + return nil + } + if tryNixUpdate() { + return nil + } } case goosWindows: - if tryWinGetUpgrade() { - return nil - } - if tryScoopUpdate() { - return nil + if !isRCInstalled { + if tryWinGetUpgrade() { + return nil + } + if tryScoopUpdate() { + return nil + } } } @@ -232,7 +349,7 @@ func tryHomebrewUpgrade() bool { loglevel.Infof("Homebrew update failed: %v", err) } - upgradeCmd := exec.Command("brew", "upgrade", "floatpane/matcha/matcha") //nolint:noctx + upgradeCmd := exec.Command("brew", "upgrade", "floatpane/matcha/matcha@v1") //nolint:noctx upgradeCmd.Stdout = os.Stdout upgradeCmd.Stderr = os.Stderr if err := upgradeCmd.Run(); err == nil { @@ -254,7 +371,7 @@ func trySnapRefresh() bool { } loglevel.Infof("Detected Snap package — attempting to refresh.") - cmd := exec.Command("snap", "refresh", "matcha") //nolint:noctx + cmd := exec.Command("snap", "refresh", "matcha", "--candidate") //nolint:noctx cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err == nil { diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go new file mode 100644 index 00000000..54dfb064 --- /dev/null +++ b/internal/updater/updater_test.go @@ -0,0 +1,103 @@ +package updater + +import ( + "testing" +) + +func TestIsRC(t *testing.T) { + cases := []struct { + v string + want bool + }{ + {"v1.0.0-rc1", true}, + {"1.0.0-rc2", true}, + {"v1.0.0", false}, + {"v0.9.0-rc1", false}, + {"v1.0.0-rc1-foo", true}, + {"dev", false}, + } + for _, tc := range cases { + if got := isRC(tc.v); got != tc.want { + t.Errorf("isRC(%q) = %v, want %v", tc.v, got, tc.want) + } + } +} + +func TestRCNumber(t *testing.T) { + cases := []struct { + v string + want int + }{ + {"v1.0.0-rc1", 1}, + {"1.0.0-rc12", 12}, + {"v1.0.0-rc0", 0}, + {"v1.0.0-rcfoo", 0}, + {"v1.0.0", 0}, + } + for _, tc := range cases { + if got := rcNumber(tc.v); got != tc.want { + t.Errorf("rcNumber(%q) = %d, want %d", tc.v, got, tc.want) + } + } +} + +func TestIsNewer(t *testing.T) { + cases := []struct { + latest, installed string + want bool + }{ + {"v1.0.0-rc2", "v1.0.0-rc1", true}, + {"v1.0.0-rc1", "v1.0.0-rc2", false}, + {"v1.0.0-rc1", "v1.0.0-rc1", false}, + {"1.0.0-rc1", "1.0.0-rc2", false}, + {"1.0.0-rc2", "1.0.0-rc1", true}, + {"v1.0.0", "v1.0.0-rc3", true}, + {"v1.0.0-rc3", "v1.0.0", false}, + {"v1.0.1", "v1.0.0", true}, + {"v1.0.0", "v1.0.1", false}, + {"v2.0.0", "v1.0.0-rc3", true}, + {"dev", "v1.0.0-rc3", false}, + } + for _, tc := range cases { + if got := isNewer(tc.latest, tc.installed); got != tc.want { + t.Errorf("isNewer(%q, %q) = %v, want %v", tc.latest, tc.installed, got, tc.want) + } + } +} + +func TestSemverLess(t *testing.T) { + cases := []struct { + a, b string + want bool + }{ + {"1.0.0-rc1", "1.0.0", true}, + {"1.0.0", "1.0.0-rc1", false}, + {"1.0.0", "1.0.1", true}, + {"1.0.1", "1.0.0", false}, + {"1.0.0-rc1", "1.0.0-rc2", true}, + {"1.0.0-rc2", "1.0.0-rc1", false}, + {"1.0.0", "1.0.0", false}, + {"2.0.0", "10.0.0", true}, + } + for _, tc := range cases { + if got := semverLess(tc.a, tc.b); got != tc.want { + t.Errorf("semverLess(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want) + } + } +} + +func TestSplitPre(t *testing.T) { + cases := []struct { + v, core, pre string + }{ + {"1.0.0-rc1", "1.0.0", "1"}, + {"1.0.0", "1.0.0", ""}, + {"1.0.0-alpha.2", "1.0.0", ".2"}, + } + for _, tc := range cases { + core, pre := splitPre(tc.v) + if core != tc.core || pre != tc.pre { + t.Errorf("splitPre(%q) = (%q, %q), want (%q, %q)", tc.v, core, pre, tc.core, tc.pre) + } + } +} From a4a0338b8e0a35fecc4aeadca77fbe8ad71bd4be Mon Sep 17 00:00:00 2001 From: drew Date: Tue, 23 Jun 2026 20:59:21 +0400 Subject: [PATCH 2/2] fix: lint errors Signed-off-by: drew --- internal/updater/updater.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/updater/updater.go b/internal/updater/updater.go index 10d2064a..7a09eb17 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -105,11 +105,11 @@ func semverLess(a, b string) bool { sa := strings.Split(pa, ".") sb := strings.Split(pb, ".") - max := len(sa) - if len(sb) > max { - max = len(sb) + limit := len(sa) + if len(sb) > limit { + limit = len(sb) } - for i := 0; i < max; i++ { + for i := 0; i < limit; i++ { na, nb := 0, 0 if i < len(sa) { na, _ = strconv.Atoi(sa[i]) @@ -146,7 +146,7 @@ func semverLess(a, b string) bool { // the pre-release suffix (e.g. "1.0.0-rc1" -> ("1.0.0", "1")). func splitPre(v string) (core, pre string) { for i, r := range v { - if !(r >= '0' && r <= '9') && r != '.' { + if (r < '0' || r > '9') && r != '.' { return v[:i], strings.TrimLeft(v[i:], "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-+") } }