diff --git a/README.md b/README.md index 9a9752b..91f110e 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # actupdate `actupdate` updates GitHub Action references in workflow YAML files to the latest -stable major version. +eligible stable version. ## What It Does @@ -76,10 +76,11 @@ Each release asset is named like `actupdate_linux_amd64.tar.gz` and contains the - Only stable semver tags are considered - Pre-release tags such as `-rc`, `-beta`, and `-alpha` are ignored -- Updates only move to the latest stable major +- Updates move to the latest eligible stable version - `--cooldown-days` can exclude newer tags until they have aged past the configured threshold -- Same-major patch or minor bumps are not applied in v1 +- When a newer major exists, moving major tags such as `v6` are preferred over + exact tags such as `v6.2.1` - If any candidate update cannot be verified, the tool prints the failures and does not rewrite any files diff --git a/cmd/actupdate/main.go b/cmd/actupdate/main.go index 2683c0e..cf79ae4 100644 --- a/cmd/actupdate/main.go +++ b/cmd/actupdate/main.go @@ -210,7 +210,6 @@ func useColor(out io.Writer) bool { func buildReport(ctx context.Context, scans []workflows.FileScan, client *gh.Client, cooldown time.Duration) (plan.Report, []workflows.Change, bool, error) { report := plan.Report{} var changes []workflows.Change - repoResults := map[string]repoOutcome{} hadVerificationFailure := false for _, scan := range scans { @@ -254,7 +253,7 @@ func buildReport(ctx context.Context, scans []workflows.FileScan, client *gh.Cli continue } - currentMajor, err := actionspec.ParseMajor(spec.Ref) + currentVersion, err := actionspec.ParseStableVersion(spec.Ref) if err != nil { entry.Status = plan.StatusSkipped entry.Reason = "non-semver ref" @@ -262,12 +261,8 @@ func buildReport(ctx context.Context, scans []workflows.FileScan, client *gh.Cli continue } - outcome, ok := repoResults[spec.Repo] - if !ok { - resolution, resolveErr := client.ResolveLatestMajor(ctx, spec.Repo, currentMajor, cooldown) - outcome = repoOutcome{Resolution: resolution, Err: resolveErr} - repoResults[spec.Repo] = outcome - } + resolution, resolveErr := client.ResolveLatestStable(ctx, spec.Repo, currentVersion, cooldown) + outcome := repoOutcome{Resolution: resolution, Err: resolveErr} if outcome.Err != nil { entry.Status = plan.StatusError diff --git a/cmd/actupdate/main_test.go b/cmd/actupdate/main_test.go index 9ed53cb..c5d515f 100644 --- a/cmd/actupdate/main_test.go +++ b/cmd/actupdate/main_test.go @@ -205,6 +205,47 @@ func TestRunCooldownDaysLeavesTooNewMajorUnchanged(t *testing.T) { } } +func TestRunUpdatesOlderExactTagEvenWhenSameRepoHasNewerRef(t *testing.T) { + repo := t.TempDir() + workflowDir := filepath.Join(repo, ".github", "workflows") + if err := os.MkdirAll(workflowDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + workflowPath := filepath.Join(workflowDir, "build_wheels.yml") + original := "steps:\n - uses: pypa/cibuildwheel@v3.3\n - uses: pypa/cibuildwheel@v3.0\n" + if err := os.WriteFile(workflowPath, []byte(original), 0o644); err != nil { + t.Fatalf("write workflow: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/repos/pypa/cibuildwheel/tags" { + http.NotFound(w, r) + return + } + fmt.Fprint(w, `[{"name":"v3.3"},{"name":"v3.0"},{"name":"v2.9"}]`) + })) + defer server.Close() + + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode := run([]string{"--repo", repo, "--yes"}, strings.NewReader(""), &stdout, &stderr, server.Client(), server.URL) + if exitCode != exitOK { + t.Fatalf("expected exit 0, got %d stderr=%s", exitCode, stderr.String()) + } + + updated, err := os.ReadFile(workflowPath) + if err != nil { + t.Fatalf("read workflow: %v", err) + } + got := string(updated) + if strings.Count(got, "pypa/cibuildwheel@v3.3") != 2 { + t.Fatalf("expected both refs to end at v3.3, got %q", got) + } + if !strings.Contains(stdout.String(), "pypa/cibuildwheel@v3.0 -> @v3.3") { + t.Fatalf("expected same-major upgrade in output, got %q", stdout.String()) + } +} + func TestRunVerificationFailurePreventsWrites(t *testing.T) { repo := t.TempDir() workflowDir := filepath.Join(repo, ".github", "workflows") diff --git a/internal/github/client.go b/internal/github/client.go index a3df8e4..5fd38a8 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -31,6 +31,19 @@ type Resolution struct { LatestMajor int } +type majorCandidates struct { + Major int + MovingMajor actionspec.StableVersion + HasMovingMajor bool + MovingMajorEligible bool + MovingMinor actionspec.StableVersion + HasMovingMinor bool + MovingMinorEligible bool + Exact actionspec.StableVersion + HasExact bool + ExactEligible bool +} + type tagResponse struct { Name string `json:"name"` } @@ -83,64 +96,41 @@ func (c *Client) ResolveLatestMajor(ctx context.Context, repo string, currentMaj return Resolution{}, fmt.Errorf("%s: no stable semver tags found", repo) } - latestMajor := currentMajor - for _, tag := range tags { - if tag.Major > latestMajor { - latestMajor = tag.Major - } + cutoff := time.Time{} + if cooldown > 0 { + cutoff = c.now().Add(-cooldown) } - if latestMajor <= currentMajor { - return Resolution{ - HasUpgrade: false, - LatestMajor: latestMajor, - Reason: "already on latest stable major", - }, nil + candidates := collectMajorCandidates(tags) + if _, err := c.populateEligibilityForNewerMajors(ctx, repo, candidates, currentMajor, cutoff); err != nil { + return Resolution{}, err + } + return resolveLatestMajorPolicy(currentMajor, candidates), nil +} + +func (c *Client) ResolveLatestStable(ctx context.Context, repo string, current actionspec.StableVersion, cooldown time.Duration) (Resolution, error) { + tags, err := c.stableTags(ctx, repo) + if err != nil { + return Resolution{}, err + } + if len(tags) == 0 { + return Resolution{}, fmt.Errorf("%s: no stable semver tags found", repo) } cutoff := time.Time{} if cooldown > 0 { cutoff = c.now().Add(-cooldown) } - - for _, major := range newerMajors(tags, currentMajor) { - if moving, ok := findMovingMajor(tags, major); ok { - eligible, err := c.tagEligible(ctx, repo, moving.Original, cutoff) - if err != nil { - return Resolution{}, err - } - if eligible { - return Resolution{ - TargetRef: moving.Original, - HasUpgrade: true, - Reason: "moving major tag", - LatestMajor: major, - }, nil - } - } - - best, ok := findHighestForMajor(tags, major) - if !ok { - return Resolution{}, fmt.Errorf("%s: no stable tag found for major v%d", repo, major) - } - eligible, err := c.tagEligible(ctx, repo, best.Original, cutoff) - if err != nil { + candidates := collectMajorCandidates(tags) + foundNewerEligible, err := c.populateEligibilityForNewerMajors(ctx, repo, candidates, current.Major, cutoff) + if err != nil { + return Resolution{}, err + } + if !foundNewerEligible { + if err := c.populateEligibilityForCurrentMajorUpgrades(ctx, repo, candidates, current, cutoff); err != nil { return Resolution{}, err } - if eligible { - return Resolution{ - TargetRef: best.Original, - HasUpgrade: true, - Reason: "exact tag fallback", - LatestMajor: major, - }, nil - } } - - return Resolution{ - HasUpgrade: false, - LatestMajor: latestMajor, - Reason: "newer major tags are still within cooldown", - }, nil + return resolveLatestStablePolicy(current, candidates), nil } func (c *Client) stableTags(ctx context.Context, repo string) ([]actionspec.StableVersion, error) { @@ -345,6 +335,204 @@ func parseGitHubTime(repo, value, field string) (time.Time, error) { return parsed, nil } +func (c *Client) populateEligibilityForNewerMajors(ctx context.Context, repo string, candidates []majorCandidates, currentMajor int, cutoff time.Time) (bool, error) { + for i := range candidates { + if candidates[i].Major <= currentMajor { + continue + } + if candidates[i].HasMovingMajor { + eligible, err := c.tagEligible(ctx, repo, candidates[i].MovingMajor.Original, cutoff) + if err != nil { + return false, err + } + candidates[i].MovingMajorEligible = eligible + if eligible { + return true, nil + } + } + if candidates[i].HasMovingMinor { + eligible, err := c.tagEligible(ctx, repo, candidates[i].MovingMinor.Original, cutoff) + if err != nil { + return false, err + } + candidates[i].MovingMinorEligible = eligible + if eligible && !candidates[i].HasMovingMajor { + return true, nil + } + } + if candidates[i].HasExact { + eligible, err := c.tagEligible(ctx, repo, candidates[i].Exact.Original, cutoff) + if err != nil { + return false, err + } + candidates[i].ExactEligible = eligible + if eligible { + return true, nil + } + } + } + return false, nil +} + +func (c *Client) populateEligibilityForCurrentMajorUpgrades(ctx context.Context, repo string, candidates []majorCandidates, current actionspec.StableVersion, cutoff time.Time) error { + for i := range candidates { + if candidates[i].Major != current.Major { + continue + } + if isMajorMovingRef(current) && candidates[i].HasMovingMajor && isSameMajorMovingUpgrade(current, candidates[i].MovingMajor) { + eligible, err := c.tagEligible(ctx, repo, candidates[i].MovingMajor.Original, cutoff) + if err != nil { + return err + } + candidates[i].MovingMajorEligible = eligible + } + if isMinorMovingRef(current) && candidates[i].HasMovingMinor && isSameMajorMovingUpgrade(current, candidates[i].MovingMinor) { + eligible, err := c.tagEligible(ctx, repo, candidates[i].MovingMinor.Original, cutoff) + if err != nil { + return err + } + candidates[i].MovingMinorEligible = eligible + } + if candidates[i].HasExact && isSameMajorExactUpgrade(current, candidates[i].Exact) { + eligible, err := c.tagEligible(ctx, repo, candidates[i].Exact.Original, cutoff) + if err != nil { + return err + } + candidates[i].ExactEligible = eligible + } + return nil + } + return nil +} + +func resolveLatestMajorPolicy(currentMajor int, candidates []majorCandidates) Resolution { + latestMajor := latestPublishedMajorFromCandidates(candidates) + if latestMajor <= currentMajor { + return Resolution{ + HasUpgrade: false, + LatestMajor: latestMajor, + Reason: "already on latest stable major", + } + } + + for _, candidate := range candidates { + if candidate.Major <= currentMajor { + continue + } + if movingRef, eligible, ok := preferredMovingUpgrade(candidate); ok { + if eligible { + return Resolution{ + TargetRef: movingRef.Original, + HasUpgrade: true, + Reason: movingUpgradeReason(movingRef), + LatestMajor: latestMajor, + } + } + } + if candidate.HasExact { + if candidate.ExactEligible { + return Resolution{ + TargetRef: candidate.Exact.Original, + HasUpgrade: true, + Reason: "exact tag fallback", + LatestMajor: latestMajor, + } + } + } + } + + return Resolution{ + HasUpgrade: false, + LatestMajor: latestMajor, + Reason: "newer major tags are still within cooldown", + } +} + +func resolveLatestStablePolicy(current actionspec.StableVersion, candidates []majorCandidates) Resolution { + latestMajor := latestPublishedMajorFromCandidates(candidates) + foundBlockedNewerMajor := false + var currentMajor majorCandidates + hasCurrentMajor := false + + for _, candidate := range candidates { + if candidate.Major == current.Major { + currentMajor = candidate + hasCurrentMajor = true + } + if candidate.Major <= current.Major { + continue + } + if movingRef, eligible, ok := preferredMovingUpgrade(candidate); ok { + if eligible { + return Resolution{ + TargetRef: movingRef.Original, + HasUpgrade: true, + Reason: movingUpgradeReason(movingRef), + LatestMajor: latestMajor, + } + } + foundBlockedNewerMajor = true + } + if candidate.HasExact { + if candidate.ExactEligible { + return Resolution{ + TargetRef: candidate.Exact.Original, + HasUpgrade: true, + Reason: "exact tag fallback", + LatestMajor: latestMajor, + } + } + foundBlockedNewerMajor = true + } + } + + if hasCurrentMajor { + if movingTarget, eligible, ok := samePrecisionMovingCandidate(current, currentMajor); ok && isSameMajorMovingUpgrade(current, movingTarget) { + if eligible { + return Resolution{ + TargetRef: movingTarget.Original, + HasUpgrade: true, + Reason: "newer moving version in current major", + LatestMajor: latestMajor, + } + } + return Resolution{ + HasUpgrade: false, + LatestMajor: latestMajor, + Reason: "newer moving tags in current major are still within cooldown", + } + } + } + if hasCurrentMajor && currentMajor.HasExact && isSameMajorExactUpgrade(current, currentMajor.Exact) { + if currentMajor.ExactEligible { + return Resolution{ + TargetRef: currentMajor.Exact.Original, + HasUpgrade: true, + Reason: "newer stable version in current major", + LatestMajor: latestMajor, + } + } + return Resolution{ + HasUpgrade: false, + LatestMajor: latestMajor, + Reason: "newer stable tags in current major are still within cooldown", + } + } + if foundBlockedNewerMajor { + return Resolution{ + HasUpgrade: false, + LatestMajor: latestMajor, + Reason: "newer major tags are still within cooldown", + } + } + + return Resolution{ + HasUpgrade: false, + LatestMajor: latestMajor, + Reason: "already on latest stable version", + } +} + func compareVersionDesc(a, b actionspec.StableVersion) int { if a.Major != b.Major { return b.Major - a.Major @@ -355,8 +543,14 @@ func compareVersionDesc(a, b actionspec.StableVersion) int { if a.Patch != b.Patch { return b.Patch - a.Patch } - if isMovingMajor(a) != isMovingMajor(b) { - if isMovingMajor(a) { + if a.HasMinor != b.HasMinor { + if a.HasMinor { + return -1 + } + return 1 + } + if a.HasPatch != b.HasPatch { + if a.HasPatch { return -1 } return 1 @@ -372,7 +566,7 @@ func compareVersionDesc(a, b actionspec.StableVersion) int { func findMovingMajor(tags []actionspec.StableVersion, major int) (actionspec.StableVersion, bool) { for _, tag := range tags { - if tag.Major == major && isMovingMajor(tag) { + if tag.Major == major && isMajorMovingRef(tag) { return tag, true } } @@ -388,6 +582,129 @@ func findHighestForMajor(tags []actionspec.StableVersion, major int) (actionspec return actionspec.StableVersion{}, false } -func isMovingMajor(tag actionspec.StableVersion) bool { +func isMovingRef(tag actionspec.StableVersion) bool { + return !tag.HasPatch +} + +func isMajorMovingRef(tag actionspec.StableVersion) bool { return !tag.HasMinor && !tag.HasPatch } + +func isMinorMovingRef(tag actionspec.StableVersion) bool { + return tag.HasMinor && !tag.HasPatch +} + +func isSameMajorExactUpgrade(current, candidate actionspec.StableVersion) bool { + if current.Major != candidate.Major { + return false + } + if isMovingRef(current) || isMovingRef(candidate) { + return false + } + return compareNumericVersion(current, candidate) < 0 +} + +func isSameMajorMovingUpgrade(current, candidate actionspec.StableVersion) bool { + if current.Major != candidate.Major { + return false + } + if !isMovingRef(current) || !isMovingRef(candidate) { + return false + } + return compareVersionDesc(current, candidate) > 0 +} + +func compareNumericVersion(a, b actionspec.StableVersion) int { + if a.Major != b.Major { + if a.Major < b.Major { + return -1 + } + return 1 + } + if a.Minor != b.Minor { + if a.Minor < b.Minor { + return -1 + } + return 1 + } + if a.Patch != b.Patch { + if a.Patch < b.Patch { + return -1 + } + return 1 + } + return 0 +} + +func collectMajorCandidates(tags []actionspec.StableVersion) []majorCandidates { + byMajor := make([]majorCandidates, 0) + indexByMajor := map[int]int{} + for _, tag := range tags { + index, ok := indexByMajor[tag.Major] + if !ok { + index = len(byMajor) + indexByMajor[tag.Major] = index + byMajor = append(byMajor, majorCandidates{Major: tag.Major}) + } + candidate := &byMajor[index] + if isMajorMovingRef(tag) { + if !candidate.HasMovingMajor { + candidate.MovingMajor = tag + candidate.HasMovingMajor = true + } + continue + } + if isMinorMovingRef(tag) { + if !candidate.HasMovingMinor { + candidate.MovingMinor = tag + candidate.HasMovingMinor = true + } + continue + } + if !candidate.HasExact { + candidate.Exact = tag + candidate.HasExact = true + } + } + return byMajor +} + +func preferredMovingUpgrade(candidate majorCandidates) (actionspec.StableVersion, bool, bool) { + if candidate.HasMovingMajor { + return candidate.MovingMajor, candidate.MovingMajorEligible, true + } + if candidate.HasMovingMinor { + return candidate.MovingMinor, candidate.MovingMinorEligible, true + } + return actionspec.StableVersion{}, false, false +} + +func movingUpgradeReason(tag actionspec.StableVersion) string { + if isMajorMovingRef(tag) { + return "moving major tag" + } + return "moving minor tag" +} + +func samePrecisionMovingCandidate(current actionspec.StableVersion, candidate majorCandidates) (actionspec.StableVersion, bool, bool) { + if isMajorMovingRef(current) { + if candidate.HasMovingMajor { + return candidate.MovingMajor, candidate.MovingMajorEligible, true + } + return actionspec.StableVersion{}, false, false + } + if isMinorMovingRef(current) { + if candidate.HasMovingMinor { + return candidate.MovingMinor, candidate.MovingMinorEligible, true + } + return actionspec.StableVersion{}, false, false + } + return actionspec.StableVersion{}, false, false +} + +func latestPublishedMajorFromCandidates(candidates []majorCandidates) int { + if len(candidates) == 0 { + return 0 + } + return candidates[0].Major +} diff --git a/internal/github/client_test.go b/internal/github/client_test.go index 5bc47c9..f7c5d10 100644 --- a/internal/github/client_test.go +++ b/internal/github/client_test.go @@ -4,12 +4,17 @@ import ( "context" "encoding/json" "fmt" + "math/rand" "net/http" "net/http/httptest" "net/url" + "reflect" "strings" "testing" + "testing/quick" "time" + + "actupdate/internal/actionspec" ) func TestResolveLatestMajorPrefersMovingTag(t *testing.T) { @@ -44,6 +49,149 @@ func TestResolveLatestMajorFallsBackToExactTag(t *testing.T) { } } +func TestResolveLatestMajorLabelsMovingMinorUpgrade(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `[{"name":"v6.2"},{"name":"v6.2.1"},{"name":"v5.9.0"}]`) + })) + defer server.Close() + + client := NewClient(server.Client(), server.URL, "") + result, err := client.ResolveLatestMajor(context.Background(), "actions/checkout", 4, 0) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if !result.HasUpgrade || result.TargetRef != "v6.2" || result.Reason != "moving minor tag" { + t.Fatalf("unexpected result: %+v", result) + } +} + +func TestResolveLatestStableUpdatesWithinCurrentMajor(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `[{"name":"v3.3"},{"name":"v3.0"},{"name":"v2.9"}]`) + })) + defer server.Close() + + client := NewClient(server.Client(), server.URL, "") + result, err := client.ResolveLatestStable(context.Background(), "pypa/cibuildwheel", mustParseStableVersion(t, "v3.0"), 0) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if !result.HasUpgrade || result.TargetRef != "v3.3" || result.Reason != "newer moving version in current major" { + t.Fatalf("unexpected result: %+v", result) + } +} + +func TestResolveLatestStableLeavesCurrentMajorMovingTagUnchanged(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `[{"name":"v3.3"},{"name":"v3"},{"name":"v2.9"}]`) + })) + defer server.Close() + + client := NewClient(server.Client(), server.URL, "") + result, err := client.ResolveLatestStable(context.Background(), "pypa/cibuildwheel", mustParseStableVersion(t, "v3"), 0) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if result.HasUpgrade { + t.Fatalf("expected no upgrade, got %+v", result) + } + if result.Reason != "already on latest stable version" { + t.Fatalf("unexpected reason: %q", result.Reason) + } +} + +func TestResolveLatestStableLabelsNewerMajorMovingMinorUpgrade(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `[{"name":"v6.2"},{"name":"v6.2.1"},{"name":"v5.1.0"}]`) + })) + defer server.Close() + + client := NewClient(server.Client(), server.URL, "") + result, err := client.ResolveLatestStable(context.Background(), "pypa/cibuildwheel", mustParseStableVersion(t, "v5.1.0"), 0) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if !result.HasUpgrade || result.TargetRef != "v6.2" || result.Reason != "moving minor tag" { + t.Fatalf("unexpected result: %+v", result) + } +} + +func TestResolveLatestStableLeavesCurrentMinorMovingTagUnchanged(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `[{"name":"v3.4.1"},{"name":"v3.4"},{"name":"v3.3.9"}]`) + })) + defer server.Close() + + client := NewClient(server.Client(), server.URL, "") + result, err := client.ResolveLatestStable(context.Background(), "pypa/cibuildwheel", mustParseStableVersion(t, "v3.4"), 0) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if result.HasUpgrade { + t.Fatalf("expected no upgrade, got %+v", result) + } + if result.Reason != "already on latest stable version" { + t.Fatalf("unexpected reason: %q", result.Reason) + } +} + +func TestResolveLatestStableReportsPublishedLatestMajor(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `[{"name":"v6.2.1"},{"name":"v6"},{"name":"v5.9"}]`) + })) + defer server.Close() + + client := NewClient(server.Client(), server.URL, "") + result, err := client.ResolveLatestStable(context.Background(), "actions/checkout", mustParseStableVersion(t, "v99.0.0"), 0) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if result.HasUpgrade { + t.Fatalf("expected no upgrade, got %+v", result) + } + if result.LatestMajor != 6 { + t.Fatalf("expected published latest major 6, got %d", result.LatestMajor) + } +} + +func TestResolveLatestStableTreatsEquivalentExactTagsAsUnchanged(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `[{"name":"v3.0"},{"name":"v3.0.0"},{"name":"v2.9"}]`) + })) + defer server.Close() + + client := NewClient(server.Client(), server.URL, "") + result, err := client.ResolveLatestStable(context.Background(), "pypa/cibuildwheel", mustParseStableVersion(t, "v3.0.0"), 0) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if result.HasUpgrade { + t.Fatalf("expected no upgrade, got %+v", result) + } + if result.TargetRef != "" { + t.Fatalf("expected no target ref, got %q", result.TargetRef) + } +} + +func TestResolveLatestStableTreatsEquivalentExactTagsAsUnchangedForShorterCurrentRef(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `[{"name":"v3.0.0"},{"name":"v3.0"},{"name":"v2.9"}]`) + })) + defer server.Close() + + client := NewClient(server.Client(), server.URL, "") + result, err := client.ResolveLatestStable(context.Background(), "pypa/cibuildwheel", mustParseStableVersion(t, "v3.0"), 0) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if result.HasUpgrade { + t.Fatalf("expected no upgrade, got %+v", result) + } + if result.TargetRef != "" { + t.Fatalf("expected no target ref, got %q", result.TargetRef) + } +} + func TestResolveLatestMajorIgnoresPrerelease(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `[{"name":"v6.0.0-rc1"},{"name":"v5"},{"name":"v4"}]`) @@ -126,6 +274,160 @@ func TestResolveLatestMajorWithCooldownFallsBackToOlderExactTag(t *testing.T) { } } +func TestResolveLatestMajorWithCooldownFallsBackToExactTagWhenMovingTagBlocked(t *testing.T) { + now := time.Date(2026, 5, 13, 12, 0, 0, 0, time.UTC) + server := newGitHubTestServer(t, githubTestData{ + tags: []string{"v6", "v6.0.0", "v4"}, + refs: map[string]gitRef{ + "v6": {Type: "tag", SHA: "tag-v6"}, + "v6.0.0": {Type: "tag", SHA: "tag-v600"}, + }, + tagObjects: map[string]string{ + "tag-v6": now.Add(-2 * 24 * time.Hour).Format(time.RFC3339), + "tag-v600": now.Add(-9 * 24 * time.Hour).Format(time.RFC3339), + }, + }) + defer server.Close() + + client := NewClient(server.Client(), server.URL, "") + client.now = func() time.Time { return now } + + result, err := client.ResolveLatestMajor(context.Background(), "actions/checkout", 4, 7*24*time.Hour) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if !result.HasUpgrade || result.TargetRef != "v6.0.0" || result.Reason != "exact tag fallback" { + t.Fatalf("unexpected result: %+v", result) + } +} + +func TestResolveLatestMajorWithCooldownSkipsMovingOnlyBlockedMajor(t *testing.T) { + now := time.Date(2026, 5, 13, 12, 0, 0, 0, time.UTC) + server := newGitHubTestServer(t, githubTestData{ + tags: []string{"v7", "v6.2.1", "v4"}, + refs: map[string]gitRef{ + "v7": {Type: "tag", SHA: "tag-v7"}, + "v6.2.1": {Type: "tag", SHA: "tag-v621"}, + }, + tagObjects: map[string]string{ + "tag-v7": now.Add(-2 * 24 * time.Hour).Format(time.RFC3339), + "tag-v621": now.Add(-9 * 24 * time.Hour).Format(time.RFC3339), + }, + }) + defer server.Close() + + client := NewClient(server.Client(), server.URL, "") + client.now = func() time.Time { return now } + + result, err := client.ResolveLatestMajor(context.Background(), "actions/checkout", 4, 7*24*time.Hour) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if !result.HasUpgrade || result.TargetRef != "v6.2.1" || result.Reason != "exact tag fallback" { + t.Fatalf("unexpected result: %+v", result) + } +} + +func TestResolveLatestMajorWithCooldownMovingOnlyBlockedMajorReturnsCooldown(t *testing.T) { + now := time.Date(2026, 5, 13, 12, 0, 0, 0, time.UTC) + server := newGitHubTestServer(t, githubTestData{ + tags: []string{"v6", "v4"}, + refs: map[string]gitRef{ + "v6": {Type: "tag", SHA: "tag-v6"}, + }, + tagObjects: map[string]string{ + "tag-v6": now.Add(-2 * 24 * time.Hour).Format(time.RFC3339), + }, + }) + defer server.Close() + + client := NewClient(server.Client(), server.URL, "") + client.now = func() time.Time { return now } + + result, err := client.ResolveLatestMajor(context.Background(), "actions/checkout", 4, 7*24*time.Hour) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if result.HasUpgrade { + t.Fatalf("expected no upgrade, got %+v", result) + } + if result.Reason != "newer major tags are still within cooldown" { + t.Fatalf("unexpected reason: %q", result.Reason) + } +} + +func TestResolvePoliciesMatchReferenceModel(t *testing.T) { + for currentMajor := 1; currentMajor <= 3; currentMajor++ { + for currentKind := 0; currentKind < 2; currentKind++ { + for currentState := 0; currentState <= 8; currentState++ { + for newerState := 0; newerState <= 8; newerState++ { + for newestState := 0; newestState <= 8; newestState++ { + candidates := makePolicyCandidates(currentMajor, currentState, newerState, newestState) + current := makeCurrentVersion(currentMajor, currentKind) + + gotMajor := resolveLatestMajorPolicy(currentMajor, candidates) + wantMajor := resolveLatestMajorPolicyModel(currentMajor, candidates) + if gotMajor != wantMajor { + t.Fatalf("major policy mismatch currentMajor=%d currentState=%d newerState=%d newestState=%d got=%+v want=%+v", + currentMajor, currentState, newerState, newestState, gotMajor, wantMajor) + } + + gotStable := resolveLatestStablePolicy(current, candidates) + wantStable := resolveLatestStablePolicyModel(current, candidates) + if gotStable != wantStable { + t.Fatalf("stable policy mismatch current=%+v currentState=%d newerState=%d newestState=%d got=%+v want=%+v", + current, currentState, newerState, newestState, gotStable, wantStable) + } + } + } + } + } + } +} + +func TestResolveLatestMajorPolicyQuick(t *testing.T) { + config := &quick.Config{MaxCount: 1000} + property := func(s policyScenario) bool { + candidates := s.candidates() + got := resolveLatestMajorPolicy(int(s.CurrentMajor), candidates) + want := resolveLatestMajorPolicyModel(int(s.CurrentMajor), candidates) + if got != want { + t.Logf("major policy mismatch scenario=%+v got=%+v want=%+v", s, got, want) + return false + } + if err := assertResolutionInvariants(got, candidates, s.currentVersion()); err != nil { + t.Logf("major invariant failed scenario=%+v resolution=%+v err=%v", s, got, err) + return false + } + return true + } + if err := quick.Check(property, config); err != nil { + t.Fatal(err) + } +} + +func TestResolveLatestStablePolicyQuick(t *testing.T) { + config := &quick.Config{MaxCount: 1000} + property := func(s policyScenario) bool { + candidates := s.candidates() + current := s.currentVersion() + got := resolveLatestStablePolicy(current, candidates) + want := resolveLatestStablePolicyModel(current, candidates) + if got != want { + t.Logf("stable policy mismatch scenario=%+v got=%+v want=%+v", s, got, want) + return false + } + if err := assertResolutionInvariants(got, candidates, current); err != nil { + t.Logf("stable invariant failed scenario=%+v resolution=%+v err=%v", s, got, err) + return false + } + return true + } + if err := quick.Check(property, config); err != nil { + t.Fatal(err) + } +} + func TestResolveLatestMajorWithCooldownSkipsTooNewMajor(t *testing.T) { now := time.Date(2026, 5, 13, 12, 0, 0, 0, time.UTC) server := newGitHubTestServer(t, githubTestData{ @@ -242,6 +544,39 @@ func TestResolveLatestMajorCachesTagTimestamps(t *testing.T) { } } +func TestResolveLatestStableCooldownStopsAfterFirstEligibleNewerMajor(t *testing.T) { + now := time.Date(2026, 5, 13, 12, 0, 0, 0, time.UTC) + hits := map[string]int{} + server := newGitHubTestServer(t, githubTestData{ + tags: []string{"v7", "v7.1.0", "v6", "v6.2.1", "v4"}, + refs: map[string]gitRef{ + "v7": {Type: "tag", SHA: "tag-v7"}, + }, + tagObjects: map[string]string{ + "tag-v7": now.Add(-10 * 24 * time.Hour).Format(time.RFC3339), + }, + hits: hits, + }) + defer server.Close() + + client := NewClient(server.Client(), server.URL, "") + client.now = func() time.Time { return now } + + result, err := client.ResolveLatestStable(context.Background(), "actions/checkout", mustParseStableVersion(t, "v4"), 7*24*time.Hour) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if !result.HasUpgrade || result.TargetRef != "v7" || result.Reason != "moving major tag" { + t.Fatalf("unexpected result: %+v", result) + } + if hits["/repos/actions/checkout/git/ref/tags/v6"] != 0 { + t.Fatalf("expected no lower-major moving lookup, got %d", hits["/repos/actions/checkout/git/ref/tags/v6"]) + } + if hits["/repos/actions/checkout/git/ref/tags/v6.2.1"] != 0 { + t.Fatalf("expected no lower-major exact lookup, got %d", hits["/repos/actions/checkout/git/ref/tags/v6.2.1"]) + } +} + func TestResolveLatestMajorEscapesTagPathSegments(t *testing.T) { now := time.Date(2026, 5, 13, 12, 0, 0, 0, time.UTC) hits := map[string]int{} @@ -274,6 +609,247 @@ func TestResolveLatestMajorEscapesTagPathSegments(t *testing.T) { } } +func mustParseStableVersion(t *testing.T, ref string) actionspec.StableVersion { + if t != nil { + t.Helper() + } + version, err := actionspec.ParseStableVersion(ref) + if err != nil { + if t != nil { + t.Fatalf("parse stable version %q: %v", ref, err) + } + panic(fmt.Sprintf("parse stable version %q: %v", ref, err)) + } + return version +} + +func makePolicyCandidates(currentMajor, currentState, newerState, newestState int) []majorCandidates { + states := []struct { + major int + state int + }{ + {currentMajor + 2, newestState}, + {currentMajor + 1, newerState}, + {currentMajor, currentState}, + } + candidates := make([]majorCandidates, 0, len(states)) + for _, item := range states { + candidate, ok := candidateFromState(item.major, item.state) + if ok { + candidates = append(candidates, candidate) + } + } + return candidates +} + +func candidateFromState(major, state int) (majorCandidates, bool) { + candidate := majorCandidates{Major: major} + switch state { + case 0: + return majorCandidates{}, false + case 1: + candidate.MovingMajor = mustParseStableVersion(nil, fmt.Sprintf("v%d", major)) + candidate.HasMovingMajor = true + candidate.MovingMajorEligible = true + case 2: + candidate.MovingMajor = mustParseStableVersion(nil, fmt.Sprintf("v%d", major)) + candidate.HasMovingMajor = true + case 3: + candidate.Exact = mustParseStableVersion(nil, fmt.Sprintf("v%d.2.0", major)) + candidate.HasExact = true + candidate.ExactEligible = true + case 4: + candidate.Exact = mustParseStableVersion(nil, fmt.Sprintf("v%d.2.0", major)) + candidate.HasExact = true + case 5: + candidate.MovingMajor = mustParseStableVersion(nil, fmt.Sprintf("v%d", major)) + candidate.HasMovingMajor = true + candidate.MovingMajorEligible = true + candidate.Exact = mustParseStableVersion(nil, fmt.Sprintf("v%d.2.0", major)) + candidate.HasExact = true + candidate.ExactEligible = true + case 6: + candidate.MovingMajor = mustParseStableVersion(nil, fmt.Sprintf("v%d", major)) + candidate.HasMovingMajor = true + candidate.Exact = mustParseStableVersion(nil, fmt.Sprintf("v%d.2.0", major)) + candidate.HasExact = true + candidate.ExactEligible = true + case 7: + candidate.MovingMajor = mustParseStableVersion(nil, fmt.Sprintf("v%d", major)) + candidate.HasMovingMajor = true + candidate.Exact = mustParseStableVersion(nil, fmt.Sprintf("v%d.2.0", major)) + candidate.HasExact = true + case 8: + candidate.MovingMajor = mustParseStableVersion(nil, fmt.Sprintf("v%d", major)) + candidate.HasMovingMajor = true + candidate.MovingMajorEligible = true + candidate.Exact = mustParseStableVersion(nil, fmt.Sprintf("v%d.2.0", major)) + candidate.HasExact = true + default: + panic(fmt.Sprintf("unknown state %d", state)) + } + return candidate, true +} + +func makeCurrentVersion(currentMajor, currentKind int) actionspec.StableVersion { + if currentKind == 0 { + return mustParseStableVersion(nil, fmt.Sprintf("v%d", currentMajor)) + } + return mustParseStableVersion(nil, fmt.Sprintf("v%d.1.0", currentMajor)) +} + +func resolveLatestMajorPolicyModel(currentMajor int, candidates []majorCandidates) Resolution { + latestMajor := latestPublishedMajorFromCandidates(candidates) + if latestMajor <= currentMajor { + return Resolution{LatestMajor: latestMajor, Reason: "already on latest stable major"} + } + blocked := false + for _, candidate := range candidates { + if candidate.Major <= currentMajor { + continue + } + if movingRef, eligible, ok := preferredMovingUpgrade(candidate); ok { + if eligible { + return Resolution{TargetRef: movingRef.Original, HasUpgrade: true, Reason: movingUpgradeReason(movingRef), LatestMajor: latestMajor} + } + blocked = true + } + if candidate.HasExact { + if candidate.ExactEligible { + return Resolution{TargetRef: candidate.Exact.Original, HasUpgrade: true, Reason: "exact tag fallback", LatestMajor: latestMajor} + } + blocked = true + } + } + reason := "already on latest stable major" + if latestMajor > currentMajor || blocked { + reason = "newer major tags are still within cooldown" + } + return Resolution{LatestMajor: latestMajor, Reason: reason} +} + +func resolveLatestStablePolicyModel(current actionspec.StableVersion, candidates []majorCandidates) Resolution { + latestMajor := latestPublishedMajorFromCandidates(candidates) + blockedNewer := false + currentExact, hasCurrentExact, currentExactEligible := actionspec.StableVersion{}, false, false + for _, candidate := range candidates { + if candidate.Major == current.Major && candidate.HasExact { + currentExact = candidate.Exact + hasCurrentExact = true + currentExactEligible = candidate.ExactEligible + } + if candidate.Major <= current.Major { + continue + } + if movingRef, eligible, ok := preferredMovingUpgrade(candidate); ok { + if eligible { + return Resolution{TargetRef: movingRef.Original, HasUpgrade: true, Reason: movingUpgradeReason(movingRef), LatestMajor: latestMajor} + } + blockedNewer = true + } + if candidate.HasExact { + if candidate.ExactEligible { + return Resolution{TargetRef: candidate.Exact.Original, HasUpgrade: true, Reason: "exact tag fallback", LatestMajor: latestMajor} + } + blockedNewer = true + } + } + if movingTarget, eligible, ok := samePrecisionMovingCandidate(current, currentMajorCandidateForModel(current.Major, candidates)); ok && isSameMajorMovingUpgradeModel(current, movingTarget) { + if eligible { + return Resolution{TargetRef: movingTarget.Original, HasUpgrade: true, Reason: "newer moving version in current major", LatestMajor: latestMajor} + } + return Resolution{LatestMajor: latestMajor, Reason: "newer moving tags in current major are still within cooldown"} + } + if hasCurrentExact && isSameMajorExactUpgradeModel(current, currentExact) { + if currentExactEligible { + return Resolution{TargetRef: currentExact.Original, HasUpgrade: true, Reason: "newer stable version in current major", LatestMajor: latestMajor} + } + return Resolution{LatestMajor: latestMajor, Reason: "newer stable tags in current major are still within cooldown"} + } + if blockedNewer { + return Resolution{LatestMajor: latestMajor, Reason: "newer major tags are still within cooldown"} + } + return Resolution{LatestMajor: latestMajor, Reason: "already on latest stable version"} +} + +func isSameMajorExactUpgradeModel(current, candidate actionspec.StableVersion) bool { + if current.Major != candidate.Major { + return false + } + if isMovingRef(current) || isMovingRef(candidate) { + return false + } + return compareNumericVersion(current, candidate) < 0 +} + +func isSameMajorMovingUpgradeModel(current, candidate actionspec.StableVersion) bool { + if current.Major != candidate.Major { + return false + } + if !isMovingRef(current) || !isMovingRef(candidate) { + return false + } + if current.Minor != candidate.Minor { + return current.Minor < candidate.Minor + } + return false +} + +func currentMajorCandidateForModel(major int, candidates []majorCandidates) majorCandidates { + for _, candidate := range candidates { + if candidate.Major == major { + return candidate + } + } + return majorCandidates{Major: major} +} + +func assertResolutionInvariants(resolution Resolution, candidates []majorCandidates, current actionspec.StableVersion) error { + latestMajor := latestPublishedMajorFromCandidates(candidates) + if resolution.LatestMajor != latestMajor { + return fmt.Errorf("latest major mismatch: got %d want %d", resolution.LatestMajor, latestMajor) + } + if resolution.HasUpgrade != (resolution.TargetRef != "") { + return fmt.Errorf("HasUpgrade/TargetRef mismatch: %+v", resolution) + } + if !resolution.HasUpgrade { + return nil + } + + target, err := actionspec.ParseStableVersion(resolution.TargetRef) + if err != nil { + return fmt.Errorf("target ref is not stable semver: %w", err) + } + if !candidateContainsEligibleRef(candidates, resolution.TargetRef) { + return fmt.Errorf("target ref %q is not an eligible published candidate", resolution.TargetRef) + } + if target.Major < current.Major { + return fmt.Errorf("resolver downgraded major from %d to %d", current.Major, target.Major) + } + if target.Major == current.Major && isMovingRef(current) && isMovingRef(target) && compareVersionDesc(current, target) <= 0 { + return fmt.Errorf("same-major moving upgrade is not newer: current=%s target=%s", current.Original, target.Original) + } + if target.Major == current.Major && !isMovingRef(current) && !isMovingRef(target) && compareNumericVersion(current, target) >= 0 { + return fmt.Errorf("same-major exact upgrade is not newer: current=%s target=%s", current.Original, target.Original) + } + return nil +} + +func candidateContainsEligibleRef(candidates []majorCandidates, ref string) bool { + for _, candidate := range candidates { + if candidate.HasMovingMajor && candidate.MovingMajorEligible && candidate.MovingMajor.Original == ref { + return true + } + if candidate.HasMovingMinor && candidate.MovingMinorEligible && candidate.MovingMinor.Original == ref { + return true + } + if candidate.HasExact && candidate.ExactEligible && candidate.Exact.Original == ref { + return true + } + } + return false +} + type githubTestData struct { tags []string refs map[string]gitRef @@ -283,6 +859,36 @@ type githubTestData struct { escapedHits map[string]int } +type policyScenario struct { + CurrentMajor uint8 + CurrentKind bool + CurrentState uint8 + NewerState uint8 + NewestState uint8 +} + +func (policyScenario) Generate(r *rand.Rand, _ int) reflect.Value { + scenario := policyScenario{ + CurrentMajor: uint8(r.Intn(4) + 1), + CurrentKind: r.Intn(2) == 1, + CurrentState: uint8(r.Intn(9)), + NewerState: uint8(r.Intn(9)), + NewestState: uint8(r.Intn(9)), + } + return reflect.ValueOf(scenario) +} + +func (s policyScenario) candidates() []majorCandidates { + return makePolicyCandidates(int(s.CurrentMajor), int(s.CurrentState), int(s.NewerState), int(s.NewestState)) +} + +func (s policyScenario) currentVersion() actionspec.StableVersion { + if s.CurrentKind { + return mustParseStableVersion(nil, fmt.Sprintf("v%d.1.0", s.CurrentMajor)) + } + return mustParseStableVersion(nil, fmt.Sprintf("v%d", s.CurrentMajor)) +} + type gitRef struct { Type string SHA string