Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/release-rc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
24 changes: 24 additions & 0 deletions .goreleaser.rc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
151 changes: 134 additions & 17 deletions internal/updater/updater.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -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, ".")
limit := len(sa)
if len(sb) > limit {
limit = len(sb)
}
for i := 0; i < limit; 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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand All @@ -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
}
}
}

Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
103 changes: 103 additions & 0 deletions internal/updater/updater_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading