diff --git a/cmd/wfctl/update.go b/cmd/wfctl/update.go index 6c37040b..83e66dd1 100644 --- a/cmd/wfctl/update.go +++ b/cmd/wfctl/update.go @@ -13,6 +13,8 @@ import ( "runtime" "strings" "time" + + "golang.org/x/mod/semver" ) const ( @@ -67,7 +69,7 @@ Options: current := strings.TrimPrefix(version, "v") if *checkOnly { - if current == "dev" || latest == current { + if current == "dev" || !isNewerVersion(latest, current) { fmt.Printf("wfctl is up to date (version %s)\n", version) } else { fmt.Printf("Update available: %s → %s\n", version, rel.TagName) @@ -77,7 +79,7 @@ Options: return nil } - if latest == current && current != "dev" { + if current != "dev" && !isNewerVersion(latest, current) { fmt.Printf("wfctl %s is already the latest version.\n", version) return nil } @@ -151,13 +153,32 @@ func checkForUpdateNotice() <-chan struct{} { } latest := strings.TrimPrefix(rel.TagName, "v") current := strings.TrimPrefix(version, "v") - if latest != "" && latest != current { + if isNewerVersion(latest, current) { fmt.Fprintf(os.Stderr, "\n⚡ wfctl %s is available (you have %s). Run 'wfctl update' to upgrade.\n\n", rel.TagName, version) } }() return done } +// isNewerVersion reports whether latestVer is strictly greater than currentVer +// using semantic versioning. Both arguments may optionally include a "v" prefix. +// Returns false if either version string is not valid semver. +func isNewerVersion(latestVer, currentVer string) bool { + // golang.org/x/mod/semver requires the "v" prefix. + lv := latestVer + if !strings.HasPrefix(lv, "v") { + lv = "v" + lv + } + cv := currentVer + if !strings.HasPrefix(cv, "v") { + cv = "v" + cv + } + if !semver.IsValid(lv) || !semver.IsValid(cv) { + return false + } + return semver.Compare(lv, cv) > 0 +} + // fetchLatestRelease queries the GitHub releases API for the latest release. func fetchLatestRelease() (*githubRelease, error) { url := githubReleasesURL diff --git a/cmd/wfctl/update_test.go b/cmd/wfctl/update_test.go index e96a86be..5a05b3f3 100644 --- a/cmd/wfctl/update_test.go +++ b/cmd/wfctl/update_test.go @@ -276,6 +276,133 @@ func TestDownloadWithTimeout_Success(t *testing.T) { } } +func TestIsNewerVersion(t *testing.T) { + tests := []struct { + latest string + current string + want bool + }{ + // Newer available + {"v0.3.43", "v0.3.42", true}, + {"0.3.43", "0.3.42", true}, + {"v1.0.0", "v0.9.9", true}, + // Same version + {"v0.3.42", "v0.3.42", false}, + // Older version reported as "latest" (the bug scenario) + {"v0.3.41", "v0.3.42", false}, + {"v0.2.0", "v1.0.0", false}, + // Invalid semver + {"not-a-version", "v1.0.0", false}, + {"v1.0.0", "not-a-version", false}, + {"", "v1.0.0", false}, + } + for _, tt := range tests { + t.Run(fmt.Sprintf("latest=%s current=%s", tt.latest, tt.current), func(t *testing.T) { + got := isNewerVersion(tt.latest, tt.current) + if got != tt.want { + t.Errorf("isNewerVersion(%q, %q) = %v, want %v", tt.latest, tt.current, got, tt.want) + } + }) + } +} + +func TestCheckForUpdateNotice_OlderReleaseSuppressed(t *testing.T) { + // Regression test: when running a newer version than the latest GitHub release, + // no update notice should be printed. + origVersion := version + version = "v0.3.42" + defer func() { version = origVersion }() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + rel := githubRelease{ + TagName: "v0.3.41", // older than current + HTMLURL: "https://github.com/GoCodeAlone/workflow/releases/tag/v0.3.41", + Assets: []githubAsset{}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(rel) + })) + defer srv.Close() + + githubReleasesURLOverride = srv.URL + defer func() { githubReleasesURLOverride = "" }() + + // Capture stderr to ensure no update notice is printed. + origStderr := os.Stderr + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + os.Stderr = w + t.Cleanup(func() { + os.Stderr = origStderr + r.Close() + }) + + done := checkForUpdateNotice() + <-done + + w.Close() + var buf [512]byte + n, _ := r.Read(buf[:]) + + output := string(buf[:n]) + if output != "" { + t.Errorf("expected no update notice for older release, got: %q", output) + } +} + +func TestRunUpdate_CheckOnly_OlderRelease(t *testing.T) { + // When current version is newer than the GitHub release, --check should + // report "up to date" rather than showing a spurious update notice. + origVersion := version + version = "v0.3.42" + defer func() { version = origVersion }() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + rel := githubRelease{ + TagName: "v0.3.41", + HTMLURL: "https://example.com", + Assets: []githubAsset{}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(rel) + })) + defer srv.Close() + + githubReleasesURLOverride = srv.URL + defer func() { githubReleasesURLOverride = "" }() + + if err := runUpdate([]string{"--check"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRunUpdate_OlderRelease_NoDownload(t *testing.T) { + // When the current version is newer, runUpdate should not attempt to download. + origVersion := version + version = "v0.3.42" + defer func() { version = origVersion }() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + rel := githubRelease{ + TagName: "v0.3.41", + HTMLURL: "https://example.com", + Assets: []githubAsset{}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(rel) + })) + defer srv.Close() + + githubReleasesURLOverride = srv.URL + defer func() { githubReleasesURLOverride = "" }() + + if err := runUpdate([]string{}); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + func TestDownloadWithTimeout_HTTPError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "gone", http.StatusGone)