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
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,16 @@ func LastStableReleaseBeforeTag(ctx context.Context, client *githubv4.Client, ow
return LatestStableSemverRange(ctx, client, owner, repo, "< "+tagSemver.String())
}

// LatestStableSemverRange returns the highest-semver stable release whose tag
// satisfies tagRangeExpr. Releases are paginated via the GitHub API ordered by
// CREATED_AT (the only meaningful ordering the API supports), but the winner
// is picked by semver comparison across all matches, not by creation date.
//
// Ordering by creation date is wrong for repositories that maintain multiple
// stable release lines in parallel: a patch on an older minor (e.g. v4.6.3)
// can be cut moments before a patch on a newer minor (e.g. v4.8.2), and a
// creation-date-first match would return v4.6.3 as the predecessor of v4.8.2
// rather than v4.8.1. See DEVOPS-874.
func LatestStableSemverRange(ctx context.Context, client *githubv4.Client, owner, repo, tagRangeExpr string) (string, error) {
tagRange, err := semver.NewConstraint(tagRangeExpr)
if err != nil {
Expand All @@ -64,9 +74,12 @@ func LatestStableSemverRange(ctx context.Context, client *githubv4.Client, owner
} `graphql:"repository(owner: $owner, name: $repo)"`
}

var cursor *githubv4.String
var (
cursor *githubv4.String
best *semver.Version
bestTag string
)

// Paginate through the Releases
for {
if err := client.Query(ctx, &query, map[string]interface{}{
"owner": githubv4.String(owner),
Expand All @@ -93,8 +106,13 @@ func LatestStableSemverRange(ctx context.Context, client *githubv4.Client, owner
continue
}

if tagRange.Check(releaseSemver) {
return release.TagName, nil
if !tagRange.Check(releaseSemver) {
continue
}

if best == nil || releaseSemver.GreaterThan(best) {
best = releaseSemver
bestTag = release.TagName
}
}

Expand All @@ -103,7 +121,7 @@ func LatestStableSemverRange(ctx context.Context, client *githubv4.Client, owner
}
}

return "", nil
return bestTag, nil
}

type Release struct {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,111 @@ func TestLastStableReleaseBeforeTag(t *testing.T) {
}
}

func TestLastStableReleaseBeforeTag_PrefersSemverOverCreationDate(t *testing.T) {
// Regression test for DEVOPS-874.
//
// Releases are paginated by GitHub in CREATED_AT DESC order. With multiple
// stable release lines maintained in parallel, the most-recently-cut release
// on a different line can come before the true semver predecessor in the
// response. The lookup must compare by semver, not by creation date.
//
// Scenario mirrors loft-sh/loft around 2026-04-28: v4.6.3 was cut two
// minutes before v4.8.2, putting it ahead of v4.8.1 in CREATED_AT order.
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := map[string]any{
"data": map[string]any{
"repository": map[string]any{
"releases": map[string]any{
"pageInfo": map[string]any{
"endCursor": "",
"hasNextPage": false,
},
// Order mirrors a CREATED_AT DESC response with parallel
// release lines. v4.6.3 was cut after v4.8.1 in real time
// but is a lower semver.
"nodes": []any{
map[string]any{"tagName": "v4.8.2", "isPrerelease": false},
map[string]any{"tagName": "v4.6.3", "isPrerelease": false},
map[string]any{"tagName": "v4.8.1", "isPrerelease": false},
map[string]any{"tagName": "v4.8.0", "isPrerelease": false},
},
},
},
},
}
json.NewEncoder(w).Encode(resp)
})

client := newTestClient(handler)
result, err := LastStableReleaseBeforeTag(context.Background(), client, "owner", "repo", "v4.8.2")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

if result != "v4.8.1" {
t.Errorf("expected v4.8.1 (highest semver < 4.8.2), got %q", result)
}
}

func TestLatestStableSemverRange_HighestSemverAcrossPages(t *testing.T) {
// The semver winner can live on a later page than other matches. Verify
// pagination continues even after a match is found.
callCount := 0
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
var resp map[string]any
if callCount == 1 {
resp = map[string]any{
"data": map[string]any{
"repository": map[string]any{
"releases": map[string]any{
"pageInfo": map[string]any{
"endCursor": "cursor1",
"hasNextPage": true,
},
"nodes": []any{
map[string]any{"tagName": "v4.6.3", "isPrerelease": false},
map[string]any{"tagName": "v4.7.2", "isPrerelease": false},
},
},
},
},
}
} else {
resp = map[string]any{
"data": map[string]any{
"repository": map[string]any{
"releases": map[string]any{
"pageInfo": map[string]any{
"endCursor": "",
"hasNextPage": false,
},
"nodes": []any{
map[string]any{"tagName": "v4.8.1", "isPrerelease": false},
map[string]any{"tagName": "v4.8.0", "isPrerelease": false},
},
},
},
},
}
}
json.NewEncoder(w).Encode(resp)
})

client := newTestClient(handler)
result, err := LatestStableSemverRange(context.Background(), client, "owner", "repo", "< 4.8.2")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

if callCount != 2 {
t.Errorf("expected 2 API calls (full pagination), got %d", callCount)
}
if result != "v4.8.1" {
t.Errorf("expected v4.8.1, got %q", result)
}
}

func TestLatestStableSemverRange_Pagination(t *testing.T) {
callCount := 0
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down
Loading