diff --git a/README.md b/README.md index 9a9752b..6241d18 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 @@ -10,9 +10,9 @@ Run `actupdate` inside a git repo and it will: 1. Scan `.github/workflows/*.yml` and `.github/workflows/*.yaml` 2. Find remote `uses:` references such as `actions/checkout@v4` 3. Query GitHub for the action repository's tags -4. Prefer a moving major tag such as `v6` when it exists +4. Prefer moving tags such as `v6` or `v6.2` when the action publishes them 5. Fall back to the latest verified stable exact tag such as `v6.2.1` when the - repo does not publish moving major tags + repo does not publish an eligible moving tag 6. Show the planned updates with colorized terminal output when supported 7. Prompt once before rewriting files; pressing Enter accepts the default and applies the changes @@ -76,10 +76,15 @@ 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 +- Moving major tags such as `v6` are preferred first; moving minor tags such as + `v6.2` are preferred next; exact tags such as `v6.2.1` are the fallback +- Moving refs keep their precision within the same major, so `v3` is not + rewritten to `v3.4` and `v3.4` is not rewritten to `v3.4.1` +- Exact refs can upgrade within the same major, but representation-only changes + such as `v3.0` to `v3.0.0` are ignored - 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..7e96faa 100644 --- a/cmd/actupdate/main.go +++ b/cmd/actupdate/main.go @@ -46,6 +46,10 @@ func main() { func run(args []string, in io.Reader, out, errOut io.Writer, httpClient *http.Client, githubBaseURL string) int { opts, err := parseArgs(args) if err != nil { + if errors.Is(err, flag.ErrHelp) { + printUsage(out) + return exitOK + } fmt.Fprintln(errOut, err) return exitInvalidInput } @@ -140,13 +144,7 @@ func parseArgs(args []string) (*cliOptions, error) { return nil, nil } - fs := flag.NewFlagSet("actupdate", flag.ContinueOnError) - fs.SetOutput(io.Discard) - opts := &cliOptions{} - fs.StringVar(&opts.Repo, "repo", "", "path to repository root") - fs.BoolVar(&opts.Yes, "yes", false, "apply without prompting") - fs.StringVar(&opts.GitHubToken, "github-token", "", "GitHub token override") - fs.IntVar(&opts.CooldownDays, "cooldown-days", 0, "minimum tag age in days before upgrading") + fs, opts := newFlagSet(io.Discard) if err := fs.Parse(args); err != nil { return nil, err } @@ -162,6 +160,36 @@ func parseArgs(args []string) (*cliOptions, error) { return opts, nil } +func newFlagSet(out io.Writer) (*flag.FlagSet, *cliOptions) { + fs := flag.NewFlagSet("actupdate", flag.ContinueOnError) + fs.SetOutput(out) + fs.Usage = func() { + fmt.Fprint(out, `Usage of actupdate: + +Updates GitHub Action references in .github/workflows/*.yml and *.yaml files +to the latest eligible stable version. + +Commands: + actupdate [flags] + actupdate version + +Flags: +`) + fs.PrintDefaults() + } + opts := &cliOptions{} + fs.StringVar(&opts.Repo, "repo", "", "path to repository root") + fs.BoolVar(&opts.Yes, "yes", false, "apply without prompting") + fs.StringVar(&opts.GitHubToken, "github-token", "", "GitHub token override") + fs.IntVar(&opts.CooldownDays, "cooldown-days", 0, "minimum tag age in days before upgrading") + return fs, opts +} + +func printUsage(out io.Writer) { + fs, _ := newFlagSet(out) + fs.Usage() +} + func cooldownDuration(days int) (time.Duration, error) { if days < 0 { return 0, fmt.Errorf("--cooldown-days must be non-negative") @@ -210,7 +238,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 +281,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 +289,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..795e6d5 100644 --- a/cmd/actupdate/main_test.go +++ b/cmd/actupdate/main_test.go @@ -46,6 +46,36 @@ func TestRunVersion(t *testing.T) { } } +func TestRunHelpWithOtherFlags(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode := run([]string{"--help", "--repo", "."}, strings.NewReader(""), &stdout, &stderr, http.DefaultClient, "") + if exitCode != exitOK { + t.Fatalf("expected exit 0, got %d stderr=%s", exitCode, stderr.String()) + } + if stderr.Len() != 0 { + t.Fatalf("expected empty stderr, got %q", stderr.String()) + } + if got := stdout.String(); !strings.Contains(got, "Usage of actupdate:") { + t.Fatalf("expected usage output, got %q", got) + } +} + +func TestRunInvalidFlagUsesStderrOnly(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode := run([]string{"--unknown"}, strings.NewReader(""), &stdout, &stderr, http.DefaultClient, "") + if exitCode != exitInvalidInput { + t.Fatalf("expected invalid input exit, got %d", exitCode) + } + if stdout.Len() != 0 { + t.Fatalf("expected empty stdout, got %q", stdout.String()) + } + if got := stderr.String(); !strings.Contains(got, "flag provided but not defined") { + t.Fatalf("expected flag error on stderr, got %q", got) + } +} + func TestPromptConfirmDefaultsToYes(t *testing.T) { var stdout bytes.Buffer confirmed, err := promptConfirm(strings.NewReader("\n"), &stdout) @@ -205,6 +235,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..2b0f7d7 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -31,6 +31,63 @@ type Resolution struct { LatestMajor int } +type Precision int + +const ( + PrecisionMovingMajor Precision = iota + PrecisionMovingMinor + PrecisionExact +) + +type Version struct { + Original string + Major int + Minor int + Patch int + Precision Precision +} + +type Eligibility int + +const ( + EligibilityUnknown Eligibility = iota + EligibilityEligible + EligibilityBlocked +) + +type Candidate struct { + Version Version + Eligibility Eligibility +} + +type majorCandidates struct { + Major int + MovingMajor *Candidate + MovingMinor *Candidate + Exact *Candidate +} + +type Decision struct { + Target Version + HasUpgrade bool + NoUpdateReason NoUpdateReason +} + +type NoUpdateReason string + +const ( + reasonAlreadyLatestMajor NoUpdateReason = "already on latest stable major" + reasonAlreadyLatestStable NoUpdateReason = "already on latest stable version" + reasonNewerMajorCooldown NoUpdateReason = "newer major tags are still within cooldown" + reasonCurrentMovingCooldown NoUpdateReason = "newer moving tags in current major are still within cooldown" + reasonCurrentStableCooldown NoUpdateReason = "newer stable tags in current major are still within cooldown" + reasonMovingMajorTag NoUpdateReason = "moving major tag" + reasonMovingMinorTag NoUpdateReason = "moving minor tag" + reasonExactFallback NoUpdateReason = "exact tag fallback" + reasonCurrentMajorMoving NoUpdateReason = "newer moving version in current major" + reasonCurrentMajorStable NoUpdateReason = "newer stable version in current major" +) + type tagResponse struct { Name string `json:"name"` } @@ -83,64 +140,36 @@ 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 - } + current := Version{Major: currentMajor, Precision: PrecisionMovingMajor} + return c.resolve(ctx, repo, current, cooldown, true, tags) +} + +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 latestMajor <= currentMajor { - return Resolution{ - HasUpgrade: false, - LatestMajor: latestMajor, - Reason: "already on latest stable major", - }, nil + if len(tags) == 0 { + return Resolution{}, fmt.Errorf("%s: no stable semver tags found", repo) } + return c.resolve(ctx, repo, versionFromStable(current), cooldown, false, tags) +} + +func (c *Client) resolve(ctx context.Context, repo string, current Version, cooldown time.Duration, majorOnly bool, tags []actionspec.StableVersion) (Resolution, error) { + candidates := collectMajorCandidates(tags) 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 { - return Resolution{}, err - } - if eligible { - return Resolution{ - TargetRef: best.Original, - HasUpgrade: true, - Reason: "exact tag fallback", - LatestMajor: major, - }, nil - } + eligible := func(candidate *Candidate) (bool, error) { + return c.ensureEligible(ctx, repo, candidate, cutoff) } - - return Resolution{ - HasUpgrade: false, - LatestMajor: latestMajor, - Reason: "newer major tags are still within cooldown", - }, nil + decision, err := resolvePolicy(current, candidates, majorOnly, eligible) + if err != nil { + return Resolution{}, err + } + return resolutionFromDecision(decision, latestPublishedMajorFromCandidates(candidates)), nil } func (c *Client) stableTags(ctx context.Context, repo string) ([]actionspec.StableVersion, error) { @@ -180,7 +209,7 @@ func (c *Client) stableTags(ctx context.Context, repo string) ([]actionspec.Stab } } - slices.SortFunc(versions, compareVersionDesc) + slices.SortFunc(versions, compareStableVersionDesc) c.cache[repo] = versions return versions, nil } @@ -259,15 +288,27 @@ func (c *Client) commitTime(ctx context.Context, repo, sha string) (time.Time, e return parseGitHubTime(repo, commit.Author.Date, "author") } -func (c *Client) tagEligible(ctx context.Context, repo, tag string, cutoff time.Time) (bool, error) { +func (c *Client) ensureEligible(ctx context.Context, repo string, candidate *Candidate, cutoff time.Time) (bool, error) { + if candidate == nil { + return false, nil + } + if candidate.Eligibility != EligibilityUnknown { + return candidate.Eligibility == EligibilityEligible, nil + } if cutoff.IsZero() { + candidate.Eligibility = EligibilityEligible return true, nil } - publishedAt, err := c.tagPublishedAt(ctx, repo, tag) + publishedAt, err := c.tagPublishedAt(ctx, repo, candidate.Version.Original) if err != nil { return false, err } - return !publishedAt.After(cutoff), nil + if publishedAt.After(cutoff) { + candidate.Eligibility = EligibilityBlocked + return false, nil + } + candidate.Eligibility = EligibilityEligible + return true, nil } func (c *Client) endpointURL(repo string, segments ...string) (*url.URL, error) { @@ -290,22 +331,6 @@ func (c *Client) endpointURL(repo string, segments ...string) (*url.URL, error) return url.Parse(path.String()) } -func newerMajors(tags []actionspec.StableVersion, currentMajor int) []int { - seen := map[int]struct{}{} - majors := make([]int, 0) - for _, tag := range tags { - if tag.Major <= currentMajor { - continue - } - if _, ok := seen[tag.Major]; ok { - continue - } - seen[tag.Major] = struct{}{} - majors = append(majors, tag.Major) - } - return majors -} - func (c *Client) getJSON(req *http.Request, repo, notFoundMessage string, out any) error { req.Header.Set("Accept", "application/vnd.github+json") req.Header.Set("User-Agent", "actupdate") @@ -334,6 +359,143 @@ func (c *Client) getJSON(req *http.Request, repo, notFoundMessage string, out an return nil } +func resolvePolicy(current Version, candidates []majorCandidates, majorOnly bool, eligible func(*Candidate) (bool, error)) (Decision, error) { + if latestPublishedMajorFromCandidates(candidates) <= current.Major { + if majorOnly { + return noUpdate(reasonAlreadyLatestMajor), nil + } + return resolveCurrentMajor(current, candidates, eligible) + } + + for i := range candidates { + candidate := &candidates[i] + if candidate.Major <= current.Major { + continue + } + if decision, decided, err := firstEligibleUpgrade([]*Candidate{candidate.MovingMajor, candidate.MovingMinor, candidate.Exact}, eligible); err != nil || decided { + return decision, err + } + } + + if majorOnly { + return noUpdate(reasonNewerMajorCooldown), nil + } + currentDecision, err := resolveCurrentMajor(current, candidates, eligible) + if err != nil { + return Decision{}, err + } + if currentDecision.HasUpgrade { + return currentDecision, nil + } + return noUpdate(reasonNewerMajorCooldown), nil +} + +func resolveCurrentMajor(current Version, candidates []majorCandidates, eligible func(*Candidate) (bool, error)) (Decision, error) { + candidate, ok := findMajorCandidates(candidates, current.Major) + if !ok { + return noUpdate(reasonAlreadyLatestStable), nil + } + + switch current.Precision { + case PrecisionMovingMajor: + if isSameMajorMovingUpgrade(current, candidate.MovingMajor) { + return decisionForCandidate(candidate.MovingMajor, reasonCurrentMajorMoving, reasonCurrentMovingCooldown, eligible) + } + case PrecisionMovingMinor: + if isSameMajorMovingUpgrade(current, candidate.MovingMinor) { + return decisionForCandidate(candidate.MovingMinor, reasonCurrentMajorMoving, reasonCurrentMovingCooldown, eligible) + } + case PrecisionExact: + return firstEligibleCurrentMajorExactUpgrade(current, candidate, eligible) + } + return noUpdate(reasonAlreadyLatestStable), nil +} + +func firstEligibleCurrentMajorExactUpgrade(current Version, candidate majorCandidates, eligible func(*Candidate) (bool, error)) (Decision, error) { + foundBlocked := false + for _, target := range []*Candidate{candidate.MovingMajor, candidate.MovingMinor, candidate.Exact} { + if !isSameMajorExactCurrentUpgrade(current, target) { + continue + } + isEligible, err := eligible(target) + if err != nil { + return Decision{}, err + } + if isEligible { + if target.Version.Precision == PrecisionExact { + return upgrade(target.Version, reasonCurrentMajorStable), nil + } + return upgrade(target.Version, reasonCurrentMajorMoving), nil + } + foundBlocked = true + } + if foundBlocked { + return noUpdate(reasonCurrentStableCooldown), nil + } + return noUpdate(reasonAlreadyLatestStable), nil +} + +func firstEligibleUpgrade(ordered []*Candidate, eligible func(*Candidate) (bool, error)) (Decision, bool, error) { + for _, candidate := range ordered { + if candidate == nil { + continue + } + isEligible, err := eligible(candidate) + if err != nil { + return Decision{}, false, err + } + if isEligible { + return upgrade(candidate.Version, upgradeReason(candidate.Version.Precision)), true, nil + } + } + return Decision{}, false, nil +} + +func decisionForCandidate(candidate *Candidate, upgradeReason, blockedReason NoUpdateReason, eligible func(*Candidate) (bool, error)) (Decision, error) { + if candidate == nil { + return noUpdate(reasonAlreadyLatestStable), nil + } + isEligible, err := eligible(candidate) + if err != nil { + return Decision{}, err + } + if isEligible { + return upgrade(candidate.Version, upgradeReason), nil + } + return noUpdate(blockedReason), nil +} + +func resolutionFromDecision(decision Decision, latestMajor int) Resolution { + resolution := Resolution{ + HasUpgrade: decision.HasUpgrade, + LatestMajor: latestMajor, + Reason: string(decision.NoUpdateReason), + } + if decision.HasUpgrade { + resolution.TargetRef = decision.Target.Original + } + return resolution +} + +func noUpdate(reason NoUpdateReason) Decision { + return Decision{NoUpdateReason: reason} +} + +func upgrade(target Version, reason NoUpdateReason) Decision { + return Decision{Target: target, HasUpgrade: true, NoUpdateReason: reason} +} + +func upgradeReason(precision Precision) NoUpdateReason { + switch precision { + case PrecisionMovingMajor: + return reasonMovingMajorTag + case PrecisionMovingMinor: + return reasonMovingMinorTag + default: + return reasonExactFallback + } +} + func parseGitHubTime(repo, value, field string) (time.Time, error) { if value == "" { return time.Time{}, fmt.Errorf("%s: missing %s timestamp in GitHub response", repo, field) @@ -345,7 +507,92 @@ func parseGitHubTime(repo, value, field string) (time.Time, error) { return parsed, nil } -func compareVersionDesc(a, b actionspec.StableVersion) int { +func collectMajorCandidates(tags []actionspec.StableVersion) []majorCandidates { + byMajor := make([]majorCandidates, 0) + indexByMajor := map[int]int{} + for _, tag := range tags { + version := versionFromStable(tag) + index, ok := indexByMajor[version.Major] + if !ok { + index = len(byMajor) + indexByMajor[version.Major] = index + byMajor = append(byMajor, majorCandidates{Major: version.Major}) + } + + slot := &byMajor[index] + candidate := &Candidate{Version: version} + switch version.Precision { + case PrecisionMovingMajor: + if slot.MovingMajor == nil { + slot.MovingMajor = candidate + } + case PrecisionMovingMinor: + if slot.MovingMinor == nil { + slot.MovingMinor = candidate + } + case PrecisionExact: + if slot.Exact == nil { + slot.Exact = candidate + } + } + } + return byMajor +} + +func findMajorCandidates(candidates []majorCandidates, major int) (majorCandidates, bool) { + for _, candidate := range candidates { + if candidate.Major == major { + return candidate, true + } + } + return majorCandidates{}, false +} + +func versionFromStable(version actionspec.StableVersion) Version { + return Version{ + Original: version.Original, + Major: version.Major, + Minor: version.Minor, + Patch: version.Patch, + Precision: precisionFromStable(version), + } +} + +func precisionFromStable(version actionspec.StableVersion) Precision { + if !version.HasMinor { + return PrecisionMovingMajor + } + if !version.HasPatch { + return PrecisionMovingMinor + } + return PrecisionExact +} + +func isSameMajorExactCurrentUpgrade(current Version, candidate *Candidate) bool { + if candidate == nil || current.Major != candidate.Version.Major { + return false + } + if current.Precision != PrecisionExact { + return false + } + return compareNumericVersion(current, candidate.Version) < 0 +} + +func isSameMajorMovingUpgrade(current Version, candidate *Candidate) bool { + if candidate == nil || current.Major != candidate.Version.Major { + return false + } + if current.Precision != candidate.Version.Precision { + return false + } + return compareNumericVersion(current, candidate.Version) < 0 +} + +func compareStableVersionDesc(a, b actionspec.StableVersion) int { + return compareVersionDesc(versionFromStable(a), versionFromStable(b)) +} + +func compareVersionDesc(a, b Version) int { if a.Major != b.Major { return b.Major - a.Major } @@ -355,11 +602,8 @@ 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) { - return -1 - } - return 1 + if a.Precision != b.Precision { + return int(b.Precision) - int(a.Precision) } if strings.HasPrefix(a.Original, "v") && !strings.HasPrefix(b.Original, "v") { return -1 @@ -370,24 +614,31 @@ func compareVersionDesc(a, b actionspec.StableVersion) int { return strings.Compare(a.Original, b.Original) } -func findMovingMajor(tags []actionspec.StableVersion, major int) (actionspec.StableVersion, bool) { - for _, tag := range tags { - if tag.Major == major && isMovingMajor(tag) { - return tag, true +func compareNumericVersion(a, b Version) int { + if a.Major != b.Major { + if a.Major < b.Major { + return -1 } + return 1 } - return actionspec.StableVersion{}, false -} - -func findHighestForMajor(tags []actionspec.StableVersion, major int) (actionspec.StableVersion, bool) { - for _, tag := range tags { - if tag.Major == major { - return tag, true + 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 actionspec.StableVersion{}, false + return 0 } -func isMovingMajor(tag actionspec.StableVersion) bool { - return !tag.HasMinor && !tag.HasPatch +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..266ed11 100644 --- a/internal/github/client_test.go +++ b/internal/github/client_test.go @@ -4,42 +4,88 @@ 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) { +func TestResolveLatestStableReportsPublishedLatestMajor(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, `[{"name":"v6"},{"name":"v6.2.1"},{"name":"v5.9.0"}]`) + fmt.Fprint(w, `[{"name":"v6.2.1"},{"name":"v6"},{"name":"v5.9"}]`) })) defer server.Close() client := NewClient(server.Client(), server.URL, "") - result, err := client.ResolveLatestMajor(context.Background(), "actions/checkout", 4, 0) + 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 || result.TargetRef != "v6" || result.Reason != "moving major tag" { - t.Fatalf("unexpected result: %+v", result) + 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 TestResolveLatestMajorFallsBackToExactTag(t *testing.T) { +func TestResolveLatestStableTreatsEquivalentExactTagsAsUnchanged(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, `[{"name":"v6.2.1"},{"name":"v6.1.0"},{"name":"v5.9.0"}]`) + 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.ResolveLatestMajor(context.Background(), "actions/checkout", 4, 0) + 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 || result.TargetRef != "v6.2.1" || result.Reason != "exact tag fallback" { + 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 TestResolveLatestStableUpgradesExactRefToSameMajorMovingMinor(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `[{"name":"v3.3"},{"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 || result.TargetRef != "v3.3" || result.Reason != "newer moving version in current major" { t.Fatalf("unexpected result: %+v", result) } } @@ -99,16 +145,75 @@ func TestResolveLatestMajorWithCooldownUsesOlderMovingTag(t *testing.T) { } } -func TestResolveLatestMajorWithCooldownFallsBackToOlderExactTag(t *testing.T) { +func TestResolveLatestMajorWithCooldownFallsBackFromMovingMajorToMovingMinor(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{"v6", "v6.2.1", "v4"}, + tags: []string{"v6", "v6.2", "v6.2.1", "v4"}, + refs: map[string]gitRef{ + "v6": {Type: "tag", SHA: "tag-v6"}, + "v6.2": {Type: "tag", SHA: "tag-v62"}, + }, + tagObjects: map[string]string{ + "tag-v6": now.Add(-2 * 24 * time.Hour).Format(time.RFC3339), + "tag-v62": now.Add(-9 * 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.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" || result.Reason != "moving minor tag" { + t.Fatalf("unexpected result: %+v", result) + } + if hits["/repos/actions/checkout/git/ref/tags/v6.2.1"] != 0 { + t.Fatalf("expected exact fallback not to be checked after moving minor is eligible, got %d", hits["/repos/actions/checkout/git/ref/tags/v6.2.1"]) + } +} + +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.2.1": {Type: "tag", SHA: "tag-v621"}, + "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), }, }) @@ -126,6 +231,121 @@ func TestResolveLatestMajorWithCooldownFallsBackToOlderExactTag(t *testing.T) { } } +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 < 3; currentKind++ { + for currentState := 0; currentState <= 12; currentState++ { + for newerState := 0; newerState <= 12; newerState++ { + for newestState := 0; newestState <= 12; newestState++ { + candidates := makePolicyCandidates(currentMajor, currentState, newerState, newestState) + current := makeCurrentVersion(currentMajor, currentKind) + + gotMajor, err := resolveForTest(Version{Major: currentMajor, Precision: PrecisionMovingMajor}, candidates, true) + if err != nil { + t.Fatalf("major policy error: %v", err) + } + 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, err := resolveForTest(current, candidates, false) + if err != nil { + t.Fatalf("stable policy error: %v", err) + } + 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() + current := Version{Major: int(s.CurrentMajor), Precision: PrecisionMovingMajor} + got, err := resolveForTest(current, candidates, true) + if err != nil { + t.Logf("major policy error scenario=%+v err=%v", s, err) + return false + } + 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, current); 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, err := resolveForTest(current, candidates, false) + if err != nil { + t.Logf("stable policy error scenario=%+v err=%v", s, err) + return false + } + 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 +462,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 +527,282 @@ 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 = testCandidate(fmt.Sprintf("v%d", major), EligibilityEligible) + case 2: + candidate.MovingMajor = testCandidate(fmt.Sprintf("v%d", major), EligibilityBlocked) + case 3: + candidate.MovingMinor = testCandidate(fmt.Sprintf("v%d.2", major), EligibilityEligible) + case 4: + candidate.MovingMinor = testCandidate(fmt.Sprintf("v%d.2", major), EligibilityBlocked) + case 5: + candidate.Exact = testCandidate(fmt.Sprintf("v%d.2.0", major), EligibilityEligible) + case 6: + candidate.Exact = testCandidate(fmt.Sprintf("v%d.2.0", major), EligibilityBlocked) + case 7: + candidate.MovingMajor = testCandidate(fmt.Sprintf("v%d", major), EligibilityEligible) + candidate.Exact = testCandidate(fmt.Sprintf("v%d.2.0", major), EligibilityEligible) + case 8: + candidate.MovingMajor = testCandidate(fmt.Sprintf("v%d", major), EligibilityBlocked) + candidate.MovingMinor = testCandidate(fmt.Sprintf("v%d.2", major), EligibilityEligible) + candidate.Exact = testCandidate(fmt.Sprintf("v%d.2.0", major), EligibilityEligible) + case 9: + candidate.MovingMajor = testCandidate(fmt.Sprintf("v%d", major), EligibilityBlocked) + candidate.MovingMinor = testCandidate(fmt.Sprintf("v%d.2", major), EligibilityBlocked) + candidate.Exact = testCandidate(fmt.Sprintf("v%d.2.0", major), EligibilityEligible) + case 10: + candidate.MovingMajor = testCandidate(fmt.Sprintf("v%d", major), EligibilityBlocked) + candidate.MovingMinor = testCandidate(fmt.Sprintf("v%d.2", major), EligibilityBlocked) + candidate.Exact = testCandidate(fmt.Sprintf("v%d.2.0", major), EligibilityBlocked) + case 11: + candidate.MovingMinor = testCandidate(fmt.Sprintf("v%d.2", major), EligibilityEligible) + candidate.Exact = testCandidate(fmt.Sprintf("v%d.2.0", major), EligibilityEligible) + case 12: + candidate.MovingMinor = testCandidate(fmt.Sprintf("v%d.2", major), EligibilityBlocked) + candidate.Exact = testCandidate(fmt.Sprintf("v%d.2.0", major), EligibilityEligible) + default: + panic(fmt.Sprintf("unknown state %d", state)) + } + return candidate, true +} + +func testCandidate(ref string, eligibility Eligibility) *Candidate { + return &Candidate{ + Version: versionFromStable(mustParseStableVersion(nil, ref)), + Eligibility: eligibility, + } +} + +func makeCurrentVersion(currentMajor, currentKind int) Version { + switch currentKind { + case 0: + return versionFromStable(mustParseStableVersion(nil, fmt.Sprintf("v%d", currentMajor))) + case 1: + return versionFromStable(mustParseStableVersion(nil, fmt.Sprintf("v%d.1", currentMajor))) + default: + return versionFromStable(mustParseStableVersion(nil, fmt.Sprintf("v%d.1.0", currentMajor))) + } +} + +func resolveForTest(current Version, candidates []majorCandidates, majorOnly bool) (Resolution, error) { + decision, err := resolvePolicy(current, candidates, majorOnly, func(candidate *Candidate) (bool, error) { + return candidate != nil && candidate.Eligibility == EligibilityEligible, nil + }) + if err != nil { + return Resolution{}, err + } + return resolutionFromDecision(decision, latestPublishedMajorFromCandidates(candidates)), nil +} + +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 + } + for _, target := range []*Candidate{candidate.MovingMajor, candidate.MovingMinor, candidate.Exact} { + if target == nil { + continue + } + if target.Eligibility == EligibilityEligible { + return Resolution{TargetRef: target.Version.Original, HasUpgrade: true, Reason: string(upgradeReason(target.Version.Precision)), 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 Version, candidates []majorCandidates) Resolution { + latestMajor := latestPublishedMajorFromCandidates(candidates) + blockedNewer := false + for _, candidate := range candidates { + if candidate.Major <= current.Major { + continue + } + for _, target := range []*Candidate{candidate.MovingMajor, candidate.MovingMinor, candidate.Exact} { + if target == nil { + continue + } + if target.Eligibility == EligibilityEligible { + return Resolution{TargetRef: target.Version.Original, HasUpgrade: true, Reason: string(upgradeReason(target.Version.Precision)), LatestMajor: latestMajor} + } + blockedNewer = true + } + } + + currentMajor := currentMajorCandidateForModel(current.Major, candidates) + switch current.Precision { + case PrecisionMovingMajor: + if isSameMajorMovingUpgradeModel(current, currentMajor.MovingMajor) { + if currentMajor.MovingMajor.Eligibility == EligibilityEligible { + return Resolution{TargetRef: currentMajor.MovingMajor.Version.Original, HasUpgrade: true, Reason: "newer moving version in current major", LatestMajor: latestMajor} + } + if blockedNewer { + return Resolution{LatestMajor: latestMajor, Reason: "newer major tags are still within cooldown"} + } + return Resolution{LatestMajor: latestMajor, Reason: "newer moving tags in current major are still within cooldown"} + } + case PrecisionMovingMinor: + if isSameMajorMovingUpgradeModel(current, currentMajor.MovingMinor) { + if currentMajor.MovingMinor.Eligibility == EligibilityEligible { + return Resolution{TargetRef: currentMajor.MovingMinor.Version.Original, HasUpgrade: true, Reason: "newer moving version in current major", LatestMajor: latestMajor} + } + if blockedNewer { + return Resolution{LatestMajor: latestMajor, Reason: "newer major tags are still within cooldown"} + } + return Resolution{LatestMajor: latestMajor, Reason: "newer moving tags in current major are still within cooldown"} + } + case PrecisionExact: + foundBlocked := false + for _, target := range []*Candidate{currentMajor.MovingMajor, currentMajor.MovingMinor, currentMajor.Exact} { + if !isSameMajorExactCurrentUpgradeModel(current, target) { + continue + } + if target.Eligibility == EligibilityEligible { + reason := "newer moving version in current major" + if target.Version.Precision == PrecisionExact { + reason = "newer stable version in current major" + } + return Resolution{TargetRef: target.Version.Original, HasUpgrade: true, Reason: reason, LatestMajor: latestMajor} + } + foundBlocked = true + } + if foundBlocked { + if blockedNewer { + return Resolution{LatestMajor: latestMajor, Reason: "newer major tags are still within cooldown"} + } + 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 isSameMajorExactCurrentUpgradeModel(current Version, candidate *Candidate) bool { + if candidate == nil || current.Major != candidate.Version.Major { + return false + } + if current.Precision != PrecisionExact { + return false + } + return compareNumericVersion(current, candidate.Version) < 0 +} + +func isSameMajorMovingUpgradeModel(current Version, candidate *Candidate) bool { + if candidate == nil || current.Major != candidate.Version.Major { + return false + } + if current.Precision != candidate.Version.Precision { + return false + } + return compareNumericVersion(current, candidate.Version) < 0 +} + +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 Version) 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 + } + + targetStable, err := actionspec.ParseStableVersion(resolution.TargetRef) + if err != nil { + return fmt.Errorf("target ref is not stable semver: %w", err) + } + target := versionFromStable(targetStable) + 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 && current.Precision != PrecisionExact && target.Precision != PrecisionExact && 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 && current.Precision == PrecisionExact && target.Precision == PrecisionExact && 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.MovingMajor != nil && candidate.MovingMajor.Eligibility == EligibilityEligible && candidate.MovingMajor.Version.Original == ref { + return true + } + if candidate.MovingMinor != nil && candidate.MovingMinor.Eligibility == EligibilityEligible && candidate.MovingMinor.Version.Original == ref { + return true + } + if candidate.Exact != nil && candidate.Exact.Eligibility == EligibilityEligible && candidate.Exact.Version.Original == ref { + return true + } + } + return false +} + type githubTestData struct { tags []string refs map[string]gitRef @@ -283,6 +812,33 @@ type githubTestData struct { escapedHits map[string]int } +type policyScenario struct { + CurrentMajor uint8 + CurrentKind uint8 + CurrentState uint8 + NewerState uint8 + NewestState uint8 +} + +func (policyScenario) Generate(r *rand.Rand, _ int) reflect.Value { + scenario := policyScenario{ + CurrentMajor: uint8(r.Intn(4) + 1), + CurrentKind: uint8(r.Intn(3)), + CurrentState: uint8(r.Intn(13)), + NewerState: uint8(r.Intn(13)), + NewestState: uint8(r.Intn(13)), + } + 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() Version { + return makeCurrentVersion(int(s.CurrentMajor), int(s.CurrentKind)) +} + type gitRef struct { Type string SHA string