From ef14908a2bf984bf41915c569f3edf104e5b1fcc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Mar 2026 03:58:50 +0000 Subject: [PATCH 1/3] Initial plan From 4fa0ddf0a7feaf62fa09750f17d6b52c3b2dd730 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Mar 2026 04:07:28 +0000 Subject: [PATCH 2/3] fix: use semver comparison for version update check Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- cmd/wfctl/update.go | 27 ++++++++- cmd/wfctl/update_test.go | 121 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 3 deletions(-) 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..8bc1ff17 100644 --- a/cmd/wfctl/update_test.go +++ b/cmd/wfctl/update_test.go @@ -276,6 +276,127 @@ 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, _ := os.Pipe() + os.Stderr = w + + done := checkForUpdateNotice() + <-done + + w.Close() + var buf [512]byte + n, _ := r.Read(buf[:]) + os.Stderr = origStderr + + 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) From d34fb69f585034f57e4b0b673c3ada066ce01f8f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Mar 2026 17:44:56 +0000 Subject: [PATCH 3/3] fix: handle os.Pipe error and close read end in update test Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- cmd/wfctl/update_test.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/cmd/wfctl/update_test.go b/cmd/wfctl/update_test.go index 8bc1ff17..5a05b3f3 100644 --- a/cmd/wfctl/update_test.go +++ b/cmd/wfctl/update_test.go @@ -329,8 +329,15 @@ func TestCheckForUpdateNotice_OlderReleaseSuppressed(t *testing.T) { // Capture stderr to ensure no update notice is printed. origStderr := os.Stderr - r, w, _ := os.Pipe() + 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 @@ -338,7 +345,6 @@ func TestCheckForUpdateNotice_OlderReleaseSuppressed(t *testing.T) { w.Close() var buf [512]byte n, _ := r.Read(buf[:]) - os.Stderr = origStderr output := string(buf[:n]) if output != "" {