Skip to content

Commit 6187853

Browse files
authored
Ignore prerelease releases when fetching GitHub releases (#23810)
1 parent b9cec0b commit 6187853

3 files changed

Lines changed: 130 additions & 11 deletions

File tree

pkg/cli/update_check.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,10 @@ const (
2525

2626
// Release represents a GitHub release
2727
type Release struct {
28-
TagName string `json:"tag_name"`
29-
Name string `json:"name"`
30-
HTMLURL string `json:"html_url"`
28+
TagName string `json:"tag_name"`
29+
Name string `json:"name"`
30+
HTMLURL string `json:"html_url"`
31+
Prerelease bool `json:"prerelease"`
3132
}
3233

3334
// shouldCheckForUpdate determines if we should check for updates based on:
@@ -222,7 +223,14 @@ func getLatestRelease() (string, error) {
222223
return "", fmt.Errorf("failed to query latest release: %w", err)
223224
}
224225

225-
updateCheckLog.Printf("Latest release: %s", release.TagName)
226+
updateCheckLog.Printf("Latest release: %s (prerelease: %v)", release.TagName, release.Prerelease)
227+
228+
// /releases/latest already excludes prereleases per the GitHub API contract,
229+
// but guard defensively in case the response ever changes.
230+
if release.Prerelease {
231+
return "", nil
232+
}
233+
226234
return release.TagName, nil
227235
}
228236

pkg/cli/update_workflows.go

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,13 @@ func getLatestBranchCommitSHA(repo, branch string) (string, error) {
276276
return sha, nil
277277
}
278278

279+
// runWorkflowReleasesAPIFn calls the GitHub Releases API for the given repository and
280+
// returns the newline-delimited tag names. It is a package-level variable so that
281+
// tests can replace it without spawning real gh CLI processes.
282+
var runWorkflowReleasesAPIFn = func(repo string) ([]byte, error) {
283+
return workflow.RunGH("Fetching releases...", "api", fmt.Sprintf("/repos/%s/releases", repo), "--jq", ".[].tag_name")
284+
}
285+
279286
// resolveLatestRelease resolves the latest compatible release for a workflow source
280287
func resolveLatestRelease(repo, currentRef string, allowMajor, verbose bool) (string, error) {
281288
updateLog.Printf("Resolving latest release for repo %s (current: %s, allowMajor=%v)", repo, currentRef, allowMajor)
@@ -285,7 +292,7 @@ func resolveLatestRelease(repo, currentRef string, allowMajor, verbose bool) (st
285292
}
286293

287294
// Get all releases using gh CLI
288-
output, err := workflow.RunGH("Fetching releases...", "api", fmt.Sprintf("/repos/%s/releases", repo), "--jq", ".[].tag_name")
295+
output, err := runWorkflowReleasesAPIFn(repo)
289296
if err != nil {
290297
return "", fmt.Errorf("failed to fetch releases: %w", err)
291298
}
@@ -298,21 +305,42 @@ func resolveLatestRelease(repo, currentRef string, allowMajor, verbose bool) (st
298305
// Parse current version
299306
currentVer := parseVersion(currentRef)
300307
if currentVer == nil {
301-
// If current version is not a valid semantic version, just return the latest release
302-
latestRelease := releases[0]
308+
// If current version is not a valid semantic version, select the latest stable release
309+
// by semantic version so we are not sensitive to the ordering of the API response.
310+
var latestStable string
311+
var latestStableVersion *semverutil.SemanticVersion
312+
313+
for _, release := range releases {
314+
releaseVer := parseVersion(release)
315+
if releaseVer == nil || releaseVer.Pre != "" {
316+
continue
317+
}
318+
if latestStableVersion == nil || releaseVer.IsNewer(latestStableVersion) {
319+
latestStable = release
320+
latestStableVersion = releaseVer
321+
}
322+
}
323+
324+
if latestStable == "" {
325+
return "", fmt.Errorf("no stable releases found for %s", repo)
326+
}
327+
303328
if verbose {
304-
fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Current version is not valid, using latest release: "+latestRelease))
329+
fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Current version is not valid, using latest stable release: "+latestStable))
305330
}
306-
return latestRelease, nil
331+
332+
return latestStable, nil
307333
}
308334

309-
// Find the latest compatible release
335+
// Find the latest compatible non-prerelease release.
336+
// Per semver rules, v1.1.0-beta.1 > v1.0.0, so without this filter a prerelease
337+
// of a higher base version could be incorrectly selected as the upgrade target.
310338
var latestCompatible string
311339
var latestCompatibleVersion *semverutil.SemanticVersion
312340

313341
for _, release := range releases {
314342
releaseVer := parseVersion(release)
315-
if releaseVer == nil {
343+
if releaseVer == nil || releaseVer.Pre != "" {
316344
continue
317345
}
318346

pkg/cli/update_workflows_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
//go:build !integration
2+
3+
package cli
4+
5+
import (
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
// mockWorkflowReleasesAPI stubs runWorkflowReleasesAPIFn for the duration of a test.
13+
func mockWorkflowReleasesAPI(t *testing.T, mockFn func(string) ([]byte, error)) {
14+
t.Helper()
15+
orig := runWorkflowReleasesAPIFn
16+
t.Cleanup(func() { runWorkflowReleasesAPIFn = orig })
17+
runWorkflowReleasesAPIFn = mockFn
18+
}
19+
20+
// TestResolveLatestRelease_PrereleaseTagsSkipped verifies that prerelease tags are
21+
// not selected as the upgrade target even when they have a higher base version than
22+
// the latest stable release. Per semver rules, v1.1.0-beta.1 > v1.0.0, so without
23+
// explicit filtering a prerelease could be picked incorrectly.
24+
func TestResolveLatestRelease_PrereleaseTagsSkipped(t *testing.T) {
25+
mockWorkflowReleasesAPI(t, func(_ string) ([]byte, error) {
26+
return []byte("v1.1.0-beta.1\nv1.0.0"), nil
27+
})
28+
29+
result, err := resolveLatestRelease("owner/repo", "v1.0.0", true, false)
30+
require.NoError(t, err, "should not error when stable release exists")
31+
assert.Equal(t, "v1.0.0", result, "should select latest stable release, not prerelease")
32+
}
33+
34+
// TestResolveLatestRelease_PrereleaseSkippedWhenCurrentVersionInvalid verifies that when
35+
// the current version is not a valid semantic version, the highest stable release by
36+
// semver is returned rather than the first item in the list (which could be a prerelease
37+
// or an older release listed first by the API).
38+
func TestResolveLatestRelease_PrereleaseSkippedWhenCurrentVersionInvalid(t *testing.T) {
39+
mockWorkflowReleasesAPI(t, func(_ string) ([]byte, error) {
40+
// Prerelease appears first, and older stable release appears before newer one.
41+
return []byte("v2.0.0-rc.1\nv1.3.0\nv1.5.0"), nil
42+
})
43+
44+
result, err := resolveLatestRelease("owner/repo", "not-a-version", true, false)
45+
require.NoError(t, err, "should not error when stable release exists")
46+
assert.Equal(t, "v1.5.0", result, "should skip prerelease and return highest stable release by semver")
47+
}
48+
49+
// TestResolveLatestRelease_ErrorWhenOnlyPrereleasesExist verifies that an error is
50+
// returned when the releases list contains only prerelease versions.
51+
func TestResolveLatestRelease_ErrorWhenOnlyPrereleasesExist(t *testing.T) {
52+
mockWorkflowReleasesAPI(t, func(_ string) ([]byte, error) {
53+
return []byte("v2.0.0-beta.1\nv1.0.0-rc.1"), nil
54+
})
55+
56+
_, err := resolveLatestRelease("owner/repo", "v1.0.0", true, false)
57+
assert.Error(t, err, "should error when no stable releases exist")
58+
}
59+
60+
// TestResolveLatestRelease_StableReleaseSelected verifies that stable releases are
61+
// correctly selected when there are no prereleases.
62+
func TestResolveLatestRelease_StableReleaseSelected(t *testing.T) {
63+
mockWorkflowReleasesAPI(t, func(_ string) ([]byte, error) {
64+
return []byte("v1.2.0\nv1.1.0\nv1.0.0"), nil
65+
})
66+
67+
result, err := resolveLatestRelease("owner/repo", "v1.0.0", false, false)
68+
require.NoError(t, err, "should not error when stable releases exist")
69+
assert.Equal(t, "v1.2.0", result, "should select highest compatible stable release")
70+
}
71+
72+
// TestResolveLatestRelease_MixedPrereleaseAndStable verifies correct selection when
73+
// releases include both prerelease and stable versions across major versions.
74+
func TestResolveLatestRelease_MixedPrereleaseAndStable(t *testing.T) {
75+
mockWorkflowReleasesAPI(t, func(_ string) ([]byte, error) {
76+
return []byte("v2.0.0-alpha.1\nv1.3.0\nv1.2.0-rc.1\nv1.1.0"), nil
77+
})
78+
79+
// Without allowMajor, should stay on v1.x and skip prereleases.
80+
result, err := resolveLatestRelease("owner/repo", "v1.1.0", false, false)
81+
require.NoError(t, err, "should not error when stable v1.x releases exist")
82+
assert.Equal(t, "v1.3.0", result, "should select latest stable v1.x release, skipping prereleases")
83+
}

0 commit comments

Comments
 (0)