From 2982dcb828dd935fa100e59f5d050981290c738d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Jul 2026 06:13:51 +0000 Subject: [PATCH 1/9] feat: cooldown fallback to older releases + empty-SHA guard in ActionCache.Set Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/update_actions.go | 112 +++++++++++++++- pkg/cli/update_actions_test.go | 206 ++++++++++++++++++++++++++++++ pkg/cli/update_workflows.go | 77 +++++++---- pkg/cli/update_workflows_test.go | 47 +++++++ pkg/workflow/action_cache.go | 4 + pkg/workflow/action_cache_test.go | 28 ++++ 6 files changed, 446 insertions(+), 28 deletions(-) diff --git a/pkg/cli/update_actions.go b/pkg/cli/update_actions.go index ba50cedfe99..a2d4bdc0528 100644 --- a/pkg/cli/update_actions.go +++ b/pkg/cli/update_actions.go @@ -41,12 +41,14 @@ type actionUpdateDeps struct { getLatestReleaseViaGit func(ctx context.Context, repo, currentVersion string, allowMajor, verbose bool) (string, string, error) runGHReleasesAPI func(ctx context.Context, baseRepo string) ([]byte, error) getActionSHAForTag func(ctx context.Context, repo, tag string) (string, error) + checkCoolDown func(ctx context.Context, repo, tag string, coolDown time.Duration) coolDownCheckResult } func defaultActionUpdateDeps() actionUpdateDeps { return actionUpdateDeps{ getLatestRelease: getLatestActionRelease, getLatestReleaseViaGit: getLatestActionReleaseViaGit, + checkCoolDown: checkReleaseCoolDown, runGHReleasesAPI: func(ctx context.Context, baseRepo string) ([]byte, error) { return workflow.RunGHCombinedContext(ctx, "Fetching releases...", "api", fmt.Sprintf("/repos/%s/releases", baseRepo), "--jq", ".[].tag_name") }, @@ -198,8 +200,16 @@ func updateActions(ctx context.Context, deps actionUpdateDeps, allowMajor, verbo if coolDownResult.InCoolDown { cooldownLog.Printf("Action %s: %s", entry.Repo, coolDownResult.Message) fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Skipping update for %s: %s", entry.Repo, coolDownResult.Message))) - skippedActions = append(skippedActions, entry.Repo) - continue + + // Try to find an older release that has passed the cooldown period. + olderVersion, olderSHA, findErr := findCooledDownActionVersion(ctx, deps, entry.Repo, entry.Version, effectiveAllowMajor, verbose, coolDown) + if findErr != nil || olderVersion == "" || olderSHA == "" { + skippedActions = append(skippedActions, entry.Repo) + continue + } + // Use the older, cooled-down release instead. + latestVersion = olderVersion + latestSHA = olderSHA } } @@ -537,6 +547,88 @@ func getLatestActionReleaseViaGit(ctx context.Context, repo, currentVersion stri return latestCompatible, sha, nil } +// findCooledDownActionVersion searches for the newest release that is strictly +// newer than currentVersion but has passed the cooldown period. It is used as +// a fallback when the highest candidate is still in cooldown: rather than +// skipping the update entirely, we walk down the release list toward older +// (but still upgrading) versions until one has cooled down. +// +// Returns ("", "", nil) when no suitable release is found (fail-open). +func findCooledDownActionVersion( + ctx context.Context, + deps actionUpdateDeps, + repo, currentVersion string, + allowMajor, verbose bool, + coolDown time.Duration, +) (string, string, error) { + baseRepo := gitutil.ExtractBaseRepo(repo) + + output, err := deps.runGHReleasesAPI(ctx, baseRepo) + if err != nil { + updateLog.Printf("findCooledDownActionVersion: failed to fetch releases for %s: %v", repo, err) + return "", "", nil // fail-open + } + + releases := strings.Split(strings.TrimSpace(string(output)), "\n") + + currentVer := parseVersion(currentVersion) + + type releaseCandidate struct { + tag string + version *semverutil.SemanticVersion + } + var candidates []releaseCandidate + for _, release := range releases { + releaseVer := parseVersion(release) + if releaseVer == nil || releaseVer.Pre != "" { + continue + } + if !allowMajor && currentVer != nil && releaseVer.Major != currentVer.Major { + continue + } + // Only include releases that are upgrades relative to the current version. + if currentVer != nil && !releaseVer.IsNewer(currentVer) { + continue + } + candidates = append(candidates, releaseCandidate{tag: release, version: releaseVer}) + } + + // Sort descending (newest first). + slices.SortFunc(candidates, func(a, b releaseCandidate) int { + switch { + case a.version.IsNewer(b.version): + return -1 + case b.version.IsNewer(a.version): + return 1 + default: + return 0 + } + }) + + for _, c := range candidates { + result := deps.checkCoolDown(ctx, repo, c.tag, coolDown) + if result.InCoolDown { + cooldownLog.Printf("Action fallback %s@%s: %s", repo, c.tag, result.Message) + if verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Skipping update for %s: %s", repo, result.Message))) + } + continue + } + sha, err := deps.getActionSHAForTag(ctx, baseRepo, c.tag) + if err != nil { + updateLog.Printf("findCooledDownActionVersion: failed to get SHA for %s@%s: %v", repo, c.tag, err) + continue // try next candidate + } + if sha == "" { + updateLog.Printf("findCooledDownActionVersion: empty SHA returned for %s@%s; skipping", repo, c.tag) + continue // skip; never store an entry without a SHA + } + return c.tag, sha, nil + } + + return "", "", nil +} + // getActionSHAForTag gets the commit SHA for a given tag in an action repository. // For annotated tags (and chained tag objects), it iteratively peels until it // reaches the underlying non-tag object SHA, matching what tools like Renovate expect. @@ -894,7 +986,9 @@ func updateActionRefsInContentWithDeps(ctx context.Context, deps actionUpdateDep } } - // Apply cooldown: if the repo is not exempt and the release is too recent, skip. + // Apply cooldown: if the repo is not exempt and the release is too recent, try + // progressively older releases (still newer than current) until finding one that + // has passed the cooldown period. if !isExemptFromCoolDown(repo) { coolDownKey := repo + "@" + latestVersion coolDownResult, coolDownCached := coolDownCache[coolDownKey] @@ -907,7 +1001,17 @@ func updateActionRefsInContentWithDeps(ctx context.Context, deps actionUpdateDep if verbose { fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Skipping update for %s: %s", repo, coolDownResult.Message))) } - continue + + // Try to find an older release that has passed the cooldown period. + olderVersion, olderSHA, findErr := findCooledDownActionVersion(ctx, deps, repo, currentVersion, effectiveAllowMajor, verbose, coolDown) + if findErr != nil || olderVersion == "" || olderSHA == "" { + continue + } + // Use the older, cooled-down release and update the per-invocation cache. + result = latestReleaseResult{version: olderVersion, sha: olderSHA} + cache[cacheKey] = result + latestVersion = olderVersion + latestSHA = olderSHA } } diff --git a/pkg/cli/update_actions_test.go b/pkg/cli/update_actions_test.go index 1fabe96b763..99f6d3f5d48 100644 --- a/pkg/cli/update_actions_test.go +++ b/pkg/cli/update_actions_test.go @@ -894,3 +894,209 @@ func savedEntryKeys(cache *workflow.ActionCache) []string { } return keys } + +// TestFindCooledDownActionVersion_SelectsOlderCooledRelease verifies that when the +// newest release is in cooldown, findCooledDownActionVersion falls back to the next +// older release that has passed the cooldown period. +func TestFindCooledDownActionVersion_SelectsOlderCooledRelease(t *testing.T) { + deps := newTestActionUpdateDeps() + + // v1.321.0 is the newest (in cooldown); v1.320.0 has cooled down. + deps.runGHReleasesAPI = func(_ context.Context, _ string) ([]byte, error) { + return []byte("v1.321.0\nv1.320.0\nv1.300.0"), nil + } + deps.checkCoolDown = func(_ context.Context, repo, tag string, cd time.Duration) coolDownCheckResult { + switch tag { + case "v1.321.0": + return checkReleaseCoolDownWithDate(repo, tag, time.Now().Add(-1*24*time.Hour), cd) + default: + return coolDownCheckResult{} + } + } + const wantSHA = "cooled1234567890123456789012345678901234" + deps.getActionSHAForTag = func(_ context.Context, _, tag string) (string, error) { + if tag == "v1.320.0" { + return wantSHA, nil + } + return "", errors.New("unexpected tag: " + tag) + } + + version, sha, err := findCooledDownActionVersion(context.Background(), deps, "ruby/setup-ruby", "v1.300.0", true, false, 7*24*time.Hour) + if err != nil { + t.Fatalf("findCooledDownActionVersion() error = %v", err) + } + if version != "v1.320.0" { + t.Errorf("version = %q, want %q", version, "v1.320.0") + } + if sha != wantSHA { + t.Errorf("sha = %q, want %q", sha, wantSHA) + } +} + +// TestFindCooledDownActionVersion_AllInCooldownReturnsEmpty verifies that +// findCooledDownActionVersion returns empty strings when all candidate releases +// are still within the cooldown window. +func TestFindCooledDownActionVersion_AllInCooldownReturnsEmpty(t *testing.T) { + deps := newTestActionUpdateDeps() + deps.runGHReleasesAPI = func(_ context.Context, _ string) ([]byte, error) { + return []byte("v1.321.0\nv1.320.0"), nil + } + // All releases too new. + deps.checkCoolDown = func(_ context.Context, repo, tag string, cd time.Duration) coolDownCheckResult { + return checkReleaseCoolDownWithDate(repo, tag, time.Now().Add(-1*24*time.Hour), cd) + } + deps.getActionSHAForTag = func(_ context.Context, _, _ string) (string, error) { + return "somesha1234567890123456789012345678901234", nil + } + + version, sha, err := findCooledDownActionVersion(context.Background(), deps, "ruby/setup-ruby", "v1.319.0", true, false, 7*24*time.Hour) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if version != "" || sha != "" { + t.Errorf("expected empty result when all releases in cooldown, got version=%q sha=%q", version, sha) + } +} + +// TestFindCooledDownActionVersion_EmptySHASkipped verifies that a release whose SHA +// resolves to an empty string is silently skipped (never returned as a valid candidate). +func TestFindCooledDownActionVersion_EmptySHASkipped(t *testing.T) { + deps := newTestActionUpdateDeps() + deps.runGHReleasesAPI = func(_ context.Context, _ string) ([]byte, error) { + return []byte("v1.321.0\nv1.320.0"), nil + } + deps.checkCoolDown = func(_ context.Context, _ string, _ string, _ time.Duration) coolDownCheckResult { + return coolDownCheckResult{} // not in cooldown + } + // SHA API returns empty for all tags. + deps.getActionSHAForTag = func(_ context.Context, _, _ string) (string, error) { + return "", nil // empty SHA + } + + version, sha, err := findCooledDownActionVersion(context.Background(), deps, "docker/login-action", "v4.4.0", true, false, 7*24*time.Hour) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if version != "" || sha != "" { + t.Errorf("expected empty result when SHA is empty, got version=%q sha=%q", version, sha) + } +} + +// TestUpdateActions_CooldownFallbackToOlderRelease verifies that updateActions +// falls back to an older cooled-down release instead of skipping the update +// entirely when the newest candidate is still in cooldown. +func TestUpdateActions_CooldownFallbackToOlderRelease(t *testing.T) { + deps := newTestActionUpdateDeps() + + // Latest version for ruby/setup-ruby is v1.321.0 (in cooldown). + deps.getLatestRelease = func(_ context.Context, repo, _ string, _, _ bool) (string, string, error) { + if repo == "ruby/setup-ruby" { + return "v1.321.0", "sha321_12345678901234567890123456789012", nil + } + return "v1.0.0", "default_1234567890123456789012345678901234", nil + } + + // v1.321.0 is in cooldown; v1.320.0 has cooled down. + deps.runGHReleasesAPI = func(_ context.Context, _ string) ([]byte, error) { + return []byte("v1.321.0\nv1.320.0\nv1.300.0"), nil + } + deps.checkCoolDown = func(_ context.Context, repo, tag string, cd time.Duration) coolDownCheckResult { + switch tag { + case "v1.321.0": + return checkReleaseCoolDownWithDate(repo, tag, time.Now().Add(-1*24*time.Hour), cd) + default: + return coolDownCheckResult{} + } + } + const fallbackSHA = "sha320_12345678901234567890123456789012" + deps.getActionSHAForTag = func(_ context.Context, _, tag string) (string, error) { + if tag == "v1.320.0" { + return fallbackSHA, nil + } + return "sha321_12345678901234567890123456789012", nil + } + + tmpDir := testutil.TempDir(t, "test-*") + cache := workflow.NewActionCache(tmpDir) + cache.Set("ruby/setup-ruby", "v1.300.0", "sha300_12345678901234567890123456789012") + if err := cache.Save(); err != nil { + t.Fatalf("failed to save initial cache: %v", err) + } + + wd, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(wd) }) + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to chdir: %v", err) + } + + if err := updateActions(context.Background(), deps, true, false, false, 7*24*time.Hour); err != nil { + t.Fatalf("updateActions() error = %v", err) + } + + saved := workflow.NewActionCache(tmpDir) + if err := saved.Load(); err != nil { + t.Fatalf("failed to reload cache: %v", err) + } + + // Should be updated to v1.320.0 (cooled-down fallback), not v1.321.0. + entry, ok := saved.Entries["ruby/setup-ruby@v1.320.0"] + if !ok { + t.Errorf("expected ruby/setup-ruby@v1.320.0 in cache after cooldown fallback; got entries: %v", savedEntryKeys(saved)) + } else if entry.SHA != fallbackSHA { + t.Errorf("SHA = %q, want %q", entry.SHA, fallbackSHA) + } +} + +// TestUpdateActionRefsInContent_CooldownFallback verifies that +// updateActionRefsInContentWithDeps falls back to an older cooled-down release +// when the newest candidate is still within the cooldown window. +func TestUpdateActionRefsInContent_CooldownFallback(t *testing.T) { + deps := newTestActionUpdateDeps() + + // Latest version is v1.321.0 (in cooldown); fallback is v1.320.0. + deps.getLatestRelease = func(_ context.Context, repo, _ string, _, _ bool) (string, string, error) { + if repo == "ruby/setup-ruby" { + return "v1.321.0", "sha321_12345678901234567890123456789012", nil + } + return "v1.0.0", "default_1234567890123456789012345678901234", nil + } + deps.runGHReleasesAPI = func(_ context.Context, _ string) ([]byte, error) { + return []byte("v1.321.0\nv1.320.0\nv1.300.0"), nil + } + deps.checkCoolDown = func(_ context.Context, repo, tag string, cd time.Duration) coolDownCheckResult { + switch tag { + case "v1.321.0": + return checkReleaseCoolDownWithDate(repo, tag, time.Now().Add(-1*24*time.Hour), cd) + default: + return coolDownCheckResult{} + } + } + const fallbackSHA = "sha320_12345678901234567890123456789012" + deps.getActionSHAForTag = func(_ context.Context, _, tag string) (string, error) { + if tag == "v1.320.0" { + return fallbackSHA, nil + } + return "sha321_12345678901234567890123456789012", nil + } + + input := "steps:\n - uses: ruby/setup-ruby@v1.300.0\n" + + changed, got, err := updateActionRefsInContentWithDeps( + context.Background(), deps, input, + make(map[string]latestReleaseResult), + make(map[string]coolDownCheckResult), + true, false, 7*24*time.Hour, + ) + if err != nil { + t.Fatalf("updateActionRefsInContentWithDeps() error = %v", err) + } + if !changed { + t.Fatal("expected content to be updated, but changed = false") + } + if want := "steps:\n - uses: ruby/setup-ruby@v1.320.0\n"; got != want { + t.Errorf("got:\n%s\nwant:\n%s", got, want) + } +} diff --git a/pkg/cli/update_workflows.go b/pkg/cli/update_workflows.go index d1a2abc2bdb..ad64fb870e3 100644 --- a/pkg/cli/update_workflows.go +++ b/pkg/cli/update_workflows.go @@ -10,6 +10,7 @@ import ( "net/url" "os" "path/filepath" + "slices" "strings" "sync" "time" @@ -456,10 +457,12 @@ func getLatestBranchCommitSHA(ctx context.Context, repo, branch string) (string, type workflowUpdateDeps struct { runReleasesAPI func(ctx context.Context, repo string) ([]byte, error) + checkCoolDown func(ctx context.Context, repo, tag string, coolDown time.Duration) coolDownCheckResult } func defaultWorkflowUpdateDeps() workflowUpdateDeps { return workflowUpdateDeps{ + checkCoolDown: checkReleaseCoolDown, runReleasesAPI: func(ctx context.Context, repo string) ([]byte, error) { endpoint := fmt.Sprintf("/repos/%s/releases", repo) output, err := workflow.RunGHContext(ctx, "Fetching releases...", "api", endpoint, "--jq", ".[].tag_name") @@ -541,50 +544,76 @@ func resolveLatestReleaseWithDeps(ctx context.Context, deps workflowUpdateDeps, return latestStable, nil } - // Find the latest compatible non-prerelease release. + // Find all compatible non-prerelease releases. // Per semver rules, v1.1.0-beta.1 > v1.0.0, so without this filter a prerelease // of a higher base version could be incorrectly selected as the upgrade target. - var latestCompatible string - var latestCompatibleVersion *semverutil.SemanticVersion - + type releaseCandidate struct { + tag string + version *semverutil.SemanticVersion + } + var compatibleReleases []releaseCandidate for _, release := range releases { releaseVer := parseVersion(release) if releaseVer == nil || releaseVer.Pre != "" { continue } - - // Check if compatible based on major version if !allowMajor && releaseVer.Major != currentVer.Major { continue } - - // Check if this is newer than what we have - if latestCompatibleVersion == nil || releaseVer.IsNewer(latestCompatibleVersion) { - latestCompatible = release - latestCompatibleVersion = releaseVer - } + compatibleReleases = append(compatibleReleases, releaseCandidate{tag: release, version: releaseVer}) } - if latestCompatible == "" { + if len(compatibleReleases) == 0 { return "", errors.New("no compatible release found") } - // Apply cooldown: if the latest release is newer than the current and the repo is not - // exempt from cooldown, check whether the release is recent enough to be held back. - if latestCompatible != currentRef && !isExemptFromCoolDown(repo) { - if result := checkReleaseCoolDown(ctx, repo, latestCompatible, coolDown); result.InCoolDown { - cooldownLog.Printf("Workflow source %s: %s", repo, result.Message) - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Skipping update for %s: %s", repo, result.Message))) - // Return the current ref — no update until cooldown expires. - return currentRef, nil + // Sort descending so the newest compatible release is first. + slices.SortFunc(compatibleReleases, func(a, b releaseCandidate) int { + switch { + case a.version.IsNewer(b.version): + return -1 + case b.version.IsNewer(a.version): + return 1 + default: + return 0 + } + }) + + // Collect upgrade candidates: releases strictly newer than current. + var upgradeCandidates []releaseCandidate + for _, c := range compatibleReleases { + if c.version.IsNewer(currentVer) { + upgradeCandidates = append(upgradeCandidates, c) } } - if verbose && latestCompatible != currentRef { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Found newer release: "+latestCompatible)) + // No upgrade available – already at the latest compatible release. + if len(upgradeCandidates) == 0 { + return currentRef, nil + } + + // If the repo is exempt from cooldown, return the best (newest) upgrade candidate. + if isExemptFromCoolDown(repo) { + return upgradeCandidates[0].tag, nil + } + + // Iterate from newest to oldest upgrade candidate, stopping at the first release + // that has passed the cooldown period. This lets users receive the latest cooled + // release rather than being blocked on a single too-recent version. + for _, c := range upgradeCandidates { + result := deps.checkCoolDown(ctx, repo, c.tag, coolDown) + if !result.InCoolDown { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Found newer release: "+c.tag)) + } + return c.tag, nil + } + cooldownLog.Printf("Workflow source %s: %s", repo, result.Message) + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Skipping update for %s: %s", repo, result.Message))) } - return latestCompatible, nil + // All upgrade candidates are still within the cooldown window. + return currentRef, nil } // updateWorkflow updates a single workflow from its source diff --git a/pkg/cli/update_workflows_test.go b/pkg/cli/update_workflows_test.go index 5e53b41eb58..583da159f0e 100644 --- a/pkg/cli/update_workflows_test.go +++ b/pkg/cli/update_workflows_test.go @@ -5,6 +5,7 @@ package cli import ( "context" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -90,3 +91,49 @@ func TestResolveLatestRelease_MixedPrereleaseAndStable(t *testing.T) { require.NoError(t, err, "should not error when stable v1.x releases exist") assert.Equal(t, "v1.3.0", result, "should select latest stable v1.x release, skipping prereleases") } + +// TestResolveLatestRelease_CooldownFallback verifies that when the newest upgrade +// candidate is within the cooldown window the resolver falls back to the next +// older release that has already passed the cooldown period. +func TestResolveLatestRelease_CooldownFallback(t *testing.T) { + t.Parallel() + + deps := mockWorkflowUpdateDeps(func(_ context.Context, _ string) ([]byte, error) { + // v1.3.0 is the newest; v1.2.0 is one step older and has cooled down. + return []byte("v1.3.0\nv1.2.0\nv1.0.0"), nil + }) + deps.checkCoolDown = func(_ context.Context, repo, tag string, cd time.Duration) coolDownCheckResult { + switch tag { + case "v1.3.0": + // Too new: published 2 days ago. + return checkReleaseCoolDownWithDate(repo, tag, time.Now().Add(-2*24*time.Hour), cd) + case "v1.2.0": + // Cooled down: published 10 days ago. + return checkReleaseCoolDownWithDate(repo, tag, time.Now().Add(-10*24*time.Hour), cd) + default: + return coolDownCheckResult{} + } + } + + result, err := resolveLatestReleaseWithDeps(context.Background(), deps, "owner/repo", "v1.0.0", false, false, 7*24*time.Hour) + require.NoError(t, err) + assert.Equal(t, "v1.2.0", result, "should fall back to v1.2.0 when v1.3.0 is in cooldown") +} + +// TestResolveLatestRelease_CooldownAllInWindow returns currentRef when every +// upgrade candidate is still within the cooldown window. +func TestResolveLatestRelease_CooldownAllInWindow(t *testing.T) { + t.Parallel() + + deps := mockWorkflowUpdateDeps(func(_ context.Context, _ string) ([]byte, error) { + return []byte("v1.2.0\nv1.1.0\nv1.0.0"), nil + }) + // All upgrade candidates are too new. + deps.checkCoolDown = func(_ context.Context, repo, tag string, cd time.Duration) coolDownCheckResult { + return checkReleaseCoolDownWithDate(repo, tag, time.Now().Add(-1*24*time.Hour), cd) + } + + result, err := resolveLatestReleaseWithDeps(context.Background(), deps, "owner/repo", "v1.0.0", false, false, 7*24*time.Hour) + require.NoError(t, err) + assert.Equal(t, "v1.0.0", result, "should return currentRef when all upgrade candidates are in cooldown") +} diff --git a/pkg/workflow/action_cache.go b/pkg/workflow/action_cache.go index 56aa0264ee5..2f9d84b961f 100644 --- a/pkg/workflow/action_cache.go +++ b/pkg/workflow/action_cache.go @@ -430,6 +430,10 @@ func (c *ActionCache) FindAnyEntryForRepo(repo string) (string, ActionCacheEntry // is unchanged. If the SHA changes (e.g. a moving tag points to a new commit), // cached inputs are cleared to stay consistent with the newly-pinned commit. func (c *ActionCache) Set(repo, version, sha string) { + if sha == "" { + actionCacheLog.Printf("refusing to store action pin entry with empty SHA for %s@%s; entry skipped", repo, version) + return + } key := formatActionCacheKey(repo, version) // Check if there are existing entries with the same repo+SHA but different version diff --git a/pkg/workflow/action_cache_test.go b/pkg/workflow/action_cache_test.go index 87e7863a369..a39b52e0297 100644 --- a/pkg/workflow/action_cache_test.go +++ b/pkg/workflow/action_cache_test.go @@ -1045,3 +1045,31 @@ func TestPruneOrphanedEntries_PreservesCompilerGenerated(t *testing.T) { t.Error("Expected runtime-managed ruby/setup-ruby@v1 to be preserved") } } + +// TestActionCache_Set_EmptySHARejected verifies that calling Set with an empty SHA +// is a no-op: the entry must not be stored in the cache, preventing an invalid +// (SHA-less) pin from being persisted to actions-lock.json. +func TestActionCache_Set_EmptySHARejected(t *testing.T) { + cache := NewActionCache(t.TempDir()) + + // Calling Set with an empty SHA must not create any entry. + cache.Set("docker/login-action", "v3.1.0", "") + + if _, exists := cache.Entries["docker/login-action@v3.1.0"]; exists { + t.Error("Set with empty SHA must not create a cache entry") + } + if len(cache.Entries) != 0 { + t.Errorf("Expected 0 entries after Set with empty SHA, got %d", len(cache.Entries)) + } + + // A subsequent Set with a real SHA must succeed normally. + const sha = "abc1234567890123456789012345678901234567" + cache.Set("docker/login-action", "v3.1.0", sha) + entry, exists := cache.Entries["docker/login-action@v3.1.0"] + if !exists { + t.Fatal("Set with valid SHA did not create a cache entry") + } + if entry.SHA != sha { + t.Errorf("entry SHA = %q, want %q", entry.SHA, sha) + } +} From 1cb029a790b058fe10bbeae4d64b599f5d7fb207 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 06:26:21 +0000 Subject: [PATCH 2/9] docs(adr): add draft ADR-47730 for cooldown fallback to older release Co-Authored-By: Claude Sonnet 4.6 --- ...7730-cooldown-fallback-to-older-release.md | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 docs/adr/47730-cooldown-fallback-to-older-release.md diff --git a/docs/adr/47730-cooldown-fallback-to-older-release.md b/docs/adr/47730-cooldown-fallback-to-older-release.md new file mode 100644 index 00000000000..c1b03f3a280 --- /dev/null +++ b/docs/adr/47730-cooldown-fallback-to-older-release.md @@ -0,0 +1,71 @@ +# ADR-47730: Cooldown Fallback to Older Release + +**Date**: 2026-07-24 +**Status**: Draft +**Deciders**: Unknown + +--- + +### Context + +The `gh aw update` command applies a cooldown policy to avoid pinning actions and +workflow sources to releases that are too recent (e.g. within 7 days of publication), +providing a safety buffer against newly-discovered bugs. Before this change, when the +newest available release was still within the cooldown window the update was silently +skipped entirely, leaving users on the current (potentially outdated) version even when +an earlier—but still upgrading—release had already cooled down. This affected both the +`updateActions` path (lock-file pins) and the `updateActionRefsInContentWithDeps` path +(inline workflow refs), as well as the `resolveLatestReleaseWithDeps` helper used for +workflow sources. + +### Decision + +We will change the cooldown handling in all three update paths so that, when the newest +upgrade candidate is still in cooldown, the system fetches the full release list, sorts +upgrade candidates newest-first, and walks them until it finds the first release that has +passed the cooldown period. That cooled-down release is used instead of skipping the +update. If every candidate is still hot, the behaviour degrades gracefully to the previous +no-op (return `currentRef` / skip). We also guard `ActionCache.Set` against empty SHAs to +prevent an unresolvable pin from being written to `actions-lock.json`. + +### Alternatives Considered + +#### Alternative 1: Keep the original skip-on-cooldown behaviour + +The prior approach—skip the entire update when the newest release is in cooldown—was +simple: one cooldown check per repo, no additional API calls, predictable behaviour. It +was not chosen because it left users on older versions when a perfectly valid, cooled-down +intermediate release existed, defeating the point of running the update command. + +#### Alternative 2: Disable or make cooldown opt-in + +Removing the cooldown constraint entirely would eliminate the fallback complexity and +guarantee every update reaches the absolute latest release. This was not chosen because +the cooldown policy exists specifically to avoid pinning to too-fresh releases that may +carry undiscovered bugs. Removing it would undermine that safety goal. + +#### Alternative 3: Surface a warning and require manual override + +Instead of automatically selecting an older release, the tool could report that the latest +is in cooldown and require the user to pass `--ignore-cooldown` or similar to proceed. +This was not chosen because it places operational burden on the user for a decision (use +the latest cooled-down release) that is both safe and predictable. + +### Consequences + +#### Positive +- Users automatically receive the latest upgrade that has passed the cooldown period rather than being silently held back. +- `checkCoolDown` is now an injectable dependency on both `actionUpdateDeps` and `workflowUpdateDeps`, enabling deterministic unit testing of cooldown scenarios without time manipulation. +- The `ActionCache.Set` empty-SHA guard prevents `actions-lock.json` from being polluted with unresolvable pin entries. + +#### Negative +- Each cooldown fallback triggers an additional GitHub Releases API call (`/repos/{owner}/{repo}/releases`) to enumerate candidates; this increases latency and API usage for repos whose latest release is in cooldown. +- The release-selection logic is more complex (collect, sort descending, iterate with per-candidate cooldown checks) compared to the previous single-check approach, increasing maintenance surface. + +#### Neutral +- The fallback is fail-open: if the releases API call fails or returns no suitable candidates, the code falls back to the original skip behaviour, so existing semantics are preserved on error. +- All three update paths (`updateActions`, `updateActionRefsInContentWithDeps`, `resolveLatestReleaseWithDeps`) now share the same logical pattern, improving consistency at the cost of the change touching multiple callsites. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* From 71000af19ef1d43c808b9a9533f2a41788aa86cf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Jul 2026 07:35:55 +0000 Subject: [PATCH 3/9] fix: address cooldown fallback review feedback Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- pkg/cli/update_actions.go | 30 +++++++++---- pkg/cli/update_actions_test.go | 82 ++++++++++++++++++++++++++++++++-- pkg/cli/update_workflows.go | 47 ++++++++++++------- 3 files changed, 133 insertions(+), 26 deletions(-) diff --git a/pkg/cli/update_actions.go b/pkg/cli/update_actions.go index a2d4bdc0528..9c98de6c0d5 100644 --- a/pkg/cli/update_actions.go +++ b/pkg/cli/update_actions.go @@ -50,7 +50,7 @@ func defaultActionUpdateDeps() actionUpdateDeps { getLatestReleaseViaGit: getLatestActionReleaseViaGit, checkCoolDown: checkReleaseCoolDown, runGHReleasesAPI: func(ctx context.Context, baseRepo string) ([]byte, error) { - return workflow.RunGHCombinedContext(ctx, "Fetching releases...", "api", fmt.Sprintf("/repos/%s/releases", baseRepo), "--jq", ".[].tag_name") + return workflow.RunGHCombinedContext(ctx, "Fetching releases...", "api", "--paginate", fmt.Sprintf("/repos/%s/releases", baseRepo), "--jq", ".[].tag_name") }, getActionSHAForTag: getActionSHAForTag, } @@ -192,17 +192,17 @@ func updateActions(ctx context.Context, deps actionUpdateDeps, allowMajor, verbo coolDownResult = checkReleaseCoolDownWithDate(entry.Repo, latestVersion, cachedDate, coolDown) } else { // Fetch from API and cache the date for future runs. - coolDownResult = checkReleaseCoolDown(ctx, entry.Repo, latestVersion, coolDown) + coolDownResult = deps.checkCoolDown(ctx, entry.Repo, latestVersion, coolDown) if !coolDownResult.PublishedAt.IsZero() { actionCache.SetReleasedAt(entry.Repo, latestVersion, coolDownResult.PublishedAt) } } if coolDownResult.InCoolDown { cooldownLog.Printf("Action %s: %s", entry.Repo, coolDownResult.Message) - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Skipping update for %s: %s", entry.Repo, coolDownResult.Message))) + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Skipping release candidate %s@%s: %s", entry.Repo, latestVersion, coolDownResult.Message))) // Try to find an older release that has passed the cooldown period. - olderVersion, olderSHA, findErr := findCooledDownActionVersion(ctx, deps, entry.Repo, entry.Version, effectiveAllowMajor, verbose, coolDown) + olderVersion, olderSHA, findErr := findCooledDownActionVersion(ctx, deps, entry.Repo, entry.Version, effectiveAllowMajor, verbose, coolDown, latestVersion) if findErr != nil || olderVersion == "" || olderSHA == "" { skippedActions = append(skippedActions, entry.Repo) continue @@ -212,6 +212,16 @@ func updateActions(ctx context.Context, deps actionUpdateDeps, allowMajor, verbo latestSHA = olderSHA } } + if latestSHA == "" { + skipErr := "could not resolve SHA for " + latestVersion + updateLog.Printf("Skipping update for %s: %s", entry.Repo, skipErr) + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Skipping %s: %s", entry.Repo, skipErr))) + failedActions = append(failedActions, actionUpdateFailure{ + name: entry.Repo, + err: skipErr, + }) + continue + } // Update the entry using ActionCache.Set which: // - Preserves cached inputs/descriptions when the SHA is unchanged (moving tag) @@ -560,6 +570,7 @@ func findCooledDownActionVersion( repo, currentVersion string, allowMajor, verbose bool, coolDown time.Duration, + skipTag string, ) (string, string, error) { baseRepo := gitutil.ExtractBaseRepo(repo) @@ -606,11 +617,14 @@ func findCooledDownActionVersion( }) for _, c := range candidates { + if skipTag != "" && c.tag == skipTag { + continue + } result := deps.checkCoolDown(ctx, repo, c.tag, coolDown) if result.InCoolDown { cooldownLog.Printf("Action fallback %s@%s: %s", repo, c.tag, result.Message) if verbose { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Skipping update for %s: %s", repo, result.Message))) + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Skipping release candidate %s@%s: %s", repo, c.tag, result.Message))) } continue } @@ -993,17 +1007,17 @@ func updateActionRefsInContentWithDeps(ctx context.Context, deps actionUpdateDep coolDownKey := repo + "@" + latestVersion coolDownResult, coolDownCached := coolDownCache[coolDownKey] if !coolDownCached { - coolDownResult = checkReleaseCoolDown(ctx, repo, latestVersion, coolDown) + coolDownResult = deps.checkCoolDown(ctx, repo, latestVersion, coolDown) coolDownCache[coolDownKey] = coolDownResult } if coolDownResult.InCoolDown { cooldownLog.Printf("Action ref %s in workflow: %s", repo, coolDownResult.Message) if verbose { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Skipping update for %s: %s", repo, coolDownResult.Message))) + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Skipping release candidate %s@%s: %s", repo, latestVersion, coolDownResult.Message))) } // Try to find an older release that has passed the cooldown period. - olderVersion, olderSHA, findErr := findCooledDownActionVersion(ctx, deps, repo, currentVersion, effectiveAllowMajor, verbose, coolDown) + olderVersion, olderSHA, findErr := findCooledDownActionVersion(ctx, deps, repo, currentVersion, effectiveAllowMajor, verbose, coolDown, latestVersion) if findErr != nil || olderVersion == "" || olderSHA == "" { continue } diff --git a/pkg/cli/update_actions_test.go b/pkg/cli/update_actions_test.go index 99f6d3f5d48..682ea8debd9 100644 --- a/pkg/cli/update_actions_test.go +++ b/pkg/cli/update_actions_test.go @@ -921,7 +921,7 @@ func TestFindCooledDownActionVersion_SelectsOlderCooledRelease(t *testing.T) { return "", errors.New("unexpected tag: " + tag) } - version, sha, err := findCooledDownActionVersion(context.Background(), deps, "ruby/setup-ruby", "v1.300.0", true, false, 7*24*time.Hour) + version, sha, err := findCooledDownActionVersion(context.Background(), deps, "ruby/setup-ruby", "v1.300.0", true, false, 7*24*time.Hour, "") if err != nil { t.Fatalf("findCooledDownActionVersion() error = %v", err) } @@ -949,7 +949,7 @@ func TestFindCooledDownActionVersion_AllInCooldownReturnsEmpty(t *testing.T) { return "somesha1234567890123456789012345678901234", nil } - version, sha, err := findCooledDownActionVersion(context.Background(), deps, "ruby/setup-ruby", "v1.319.0", true, false, 7*24*time.Hour) + version, sha, err := findCooledDownActionVersion(context.Background(), deps, "ruby/setup-ruby", "v1.319.0", true, false, 7*24*time.Hour, "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -973,7 +973,7 @@ func TestFindCooledDownActionVersion_EmptySHASkipped(t *testing.T) { return "", nil // empty SHA } - version, sha, err := findCooledDownActionVersion(context.Background(), deps, "docker/login-action", "v4.4.0", true, false, 7*24*time.Hour) + version, sha, err := findCooledDownActionVersion(context.Background(), deps, "docker/login-action", "v4.4.0", true, false, 7*24*time.Hour, "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1100,3 +1100,79 @@ func TestUpdateActionRefsInContent_CooldownFallback(t *testing.T) { t.Errorf("got:\n%s\nwant:\n%s", got, want) } } + +func TestFindCooledDownActionVersion_SHAErrorFallsToNext(t *testing.T) { + deps := newTestActionUpdateDeps() + deps.runGHReleasesAPI = func(_ context.Context, _ string) ([]byte, error) { + return []byte("v1.322.0\nv1.321.0\nv1.320.0"), nil + } + deps.checkCoolDown = func(_ context.Context, _, _ string, _ time.Duration) coolDownCheckResult { + return coolDownCheckResult{} // all candidates cooled down + } + deps.getActionSHAForTag = func(_ context.Context, _, tag string) (string, error) { + if tag == "v1.322.0" { + return "", errors.New("not found") + } + if tag == "v1.321.0" { + return "sha321_12345678901234567890123456789012", nil + } + return "", errors.New("unexpected tag: " + tag) + } + + version, sha, err := findCooledDownActionVersion(context.Background(), deps, "ruby/setup-ruby", "v1.320.0", true, false, 7*24*time.Hour, "") + if err != nil { + t.Fatalf("findCooledDownActionVersion() error = %v", err) + } + if version != "v1.321.0" { + t.Errorf("version = %q, want %q", version, "v1.321.0") + } + if sha != "sha321_12345678901234567890123456789012" { + t.Errorf("sha = %q, want %q", sha, "sha321_12345678901234567890123456789012") + } +} + +func TestUpdateActions_EmptyResolvedSHARetainsExistingEntry(t *testing.T) { + deps := newTestActionUpdateDeps() + deps.getLatestRelease = func(_ context.Context, repo, currentVersion string, _, _ bool) (string, string, error) { + if repo == "ruby/setup-ruby" { + return "v1.321.0", "", nil + } + return currentVersion, "", nil + } + + tmpDir := testutil.TempDir(t, "test-*") + cache := workflow.NewActionCache(tmpDir) + cache.Set("ruby/setup-ruby", "v1.300.0", "sha300_12345678901234567890123456789012") + if err := cache.Save(); err != nil { + t.Fatalf("failed to save initial cache: %v", err) + } + + wd, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(wd) }) + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to chdir: %v", err) + } + + if err := updateActions(context.Background(), deps, true, false, false, 0); err != nil { + t.Fatalf("updateActions() error = %v", err) + } + + saved := workflow.NewActionCache(tmpDir) + if err := saved.Load(); err != nil { + t.Fatalf("failed to reload cache: %v", err) + } + + existing, ok := saved.Entries["ruby/setup-ruby@v1.300.0"] + if !ok { + t.Fatalf("expected existing entry to be retained when resolved SHA is empty; got entries: %v", savedEntryKeys(saved)) + } + if existing.SHA != "sha300_12345678901234567890123456789012" { + t.Fatalf("expected retained entry SHA to stay unchanged, got %q", existing.SHA) + } + if _, ok := saved.Entries["ruby/setup-ruby@v1.321.0"]; ok { + t.Fatalf("did not expect new empty-SHA version entry to be created; got entries: %v", savedEntryKeys(saved)) + } +} diff --git a/pkg/cli/update_workflows.go b/pkg/cli/update_workflows.go index ad64fb870e3..27bb62eacb7 100644 --- a/pkg/cli/update_workflows.go +++ b/pkg/cli/update_workflows.go @@ -387,6 +387,35 @@ func fetchPublicGitHubAPI(ctx context.Context, endpoint string) ([]byte, error) return body, nil } +func fetchPublicReleaseTagsPaginated(ctx context.Context, repo string) ([]string, error) { + var tags []string + for page := 1; ; page++ { + endpoint := fmt.Sprintf("/repos/%s/releases?per_page=100&page=%d", repo, page) + body, err := fetchPublicGitHubAPI(ctx, endpoint) + if err != nil { + return nil, err + } + var releases []struct { + TagName string `json:"tag_name"` + } + if err := json.Unmarshal(body, &releases); err != nil { + return nil, fmt.Errorf("failed to parse releases response: %w", err) + } + if len(releases) == 0 { + break + } + for _, r := range releases { + if r.TagName != "" { + tags = append(tags, r.TagName) + } + } + if len(releases) < 100 { + break + } + } + return tags, nil +} + // getRepoDefaultBranch fetches the default branch name for a repository. func getRepoDefaultBranch(ctx context.Context, repo string) (string, error) { output, err := workflow.RunGHContext(ctx, "Fetching repo info...", "api", "/repos/"+repo, "--jq", ".default_branch") @@ -465,25 +494,13 @@ func defaultWorkflowUpdateDeps() workflowUpdateDeps { checkCoolDown: checkReleaseCoolDown, runReleasesAPI: func(ctx context.Context, repo string) ([]byte, error) { endpoint := fmt.Sprintf("/repos/%s/releases", repo) - output, err := workflow.RunGHContext(ctx, "Fetching releases...", "api", endpoint, "--jq", ".[].tag_name") + output, err := workflow.RunGHContext(ctx, "Fetching releases...", "api", "--paginate", endpoint, "--jq", ".[].tag_name") if err != nil && gitutil.IsAuthError(err.Error()) { updateLog.Printf("GitHub API auth failed for releases of %s, retrying without token", repo) - body, fallbackErr := fetchPublicGitHubAPI(ctx, endpoint) + tags, fallbackErr := fetchPublicReleaseTagsPaginated(ctx, repo) if fallbackErr != nil { return nil, fmt.Errorf("failed (with token: %w; without token: %w)", err, fallbackErr) } - var releases []struct { - TagName string `json:"tag_name"` - } - if fallbackErr = json.Unmarshal(body, &releases); fallbackErr != nil { - return nil, fmt.Errorf("failed to parse releases response: %w", fallbackErr) - } - var tags []string - for _, r := range releases { - if r.TagName != "" { - tags = append(tags, r.TagName) - } - } return []byte(strings.Join(tags, "\n")), nil } return output, err @@ -609,7 +626,7 @@ func resolveLatestReleaseWithDeps(ctx context.Context, deps workflowUpdateDeps, return c.tag, nil } cooldownLog.Printf("Workflow source %s: %s", repo, result.Message) - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Skipping update for %s: %s", repo, result.Message))) + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Skipping release candidate %s@%s: %s", repo, c.tag, result.Message))) } // All upgrade candidates are still within the cooldown window. From cfd776eaa211022e2130ae26c2b9be211405f1db Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Jul 2026 08:14:47 +0000 Subject: [PATCH 4/9] fix: preserve existing action pin when cache set is rejected Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- pkg/cli/update_actions.go | 14 ++++++++++++-- pkg/workflow/action_cache.go | 5 +++-- pkg/workflow/action_cache_test.go | 8 ++++++-- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/pkg/cli/update_actions.go b/pkg/cli/update_actions.go index 9c98de6c0d5..781ebb641ed 100644 --- a/pkg/cli/update_actions.go +++ b/pkg/cli/update_actions.go @@ -238,13 +238,23 @@ func updateActions(ctx context.Context, deps actionUpdateDeps, allowMajor, verbo updateLog.Printf("Updating %s from %s (%s) to %s (%s)", entry.Repo, entry.Version, oldSHAStr, latestVersion, newSHAStr) fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Updated %s from %s to %s", entry.Repo, entry.Version, latestVersion))) + // Set the new entry first; ActionCache.Set handles inputs/description preservation. + // If the write is rejected (e.g. empty SHA), keep the old entry untouched. + if !actionCache.Set(entry.Repo, latestVersion, latestSHA) { + skipErr := "failed to write action cache entry for " + entry.Repo + "@" + latestVersion + updateLog.Printf("Skipping update for %s: %s", entry.Repo, skipErr) + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Skipping %s: %s", entry.Repo, skipErr))) + failedActions = append(failedActions, actionUpdateFailure{ + name: entry.Repo, + err: skipErr, + }) + continue + } // Remove the old key when the version changes, using the original map key from // the snapshot to handle any key/version mismatches in the stored cache file. if latestVersion != entry.Version { actionCache.DeleteByKey(s.key) } - // Set the new entry; ActionCache.Set handles inputs/description preservation. - actionCache.Set(entry.Repo, latestVersion, latestSHA) updatedActions = append(updatedActions, entry.Repo) } diff --git a/pkg/workflow/action_cache.go b/pkg/workflow/action_cache.go index 2f9d84b961f..5ad8371408e 100644 --- a/pkg/workflow/action_cache.go +++ b/pkg/workflow/action_cache.go @@ -429,10 +429,10 @@ func (c *ActionCache) FindAnyEntryForRepo(repo string) (string, ActionCacheEntry // Set stores a new cache entry, preserving any already-cached inputs when the SHA // is unchanged. If the SHA changes (e.g. a moving tag points to a new commit), // cached inputs are cleared to stay consistent with the newly-pinned commit. -func (c *ActionCache) Set(repo, version, sha string) { +func (c *ActionCache) Set(repo, version, sha string) bool { if sha == "" { actionCacheLog.Printf("refusing to store action pin entry with empty SHA for %s@%s; entry skipped", repo, version) - return + return false } key := formatActionCacheKey(repo, version) @@ -477,6 +477,7 @@ func (c *ActionCache) Set(repo, version, sha string) { ActionDescription: description, } c.dirty = true // Mark cache as modified + return true } // GetInputs retrieves the cached action inputs for the given repo and version. diff --git a/pkg/workflow/action_cache_test.go b/pkg/workflow/action_cache_test.go index a39b52e0297..6fb12638780 100644 --- a/pkg/workflow/action_cache_test.go +++ b/pkg/workflow/action_cache_test.go @@ -1053,7 +1053,9 @@ func TestActionCache_Set_EmptySHARejected(t *testing.T) { cache := NewActionCache(t.TempDir()) // Calling Set with an empty SHA must not create any entry. - cache.Set("docker/login-action", "v3.1.0", "") + if ok := cache.Set("docker/login-action", "v3.1.0", ""); ok { + t.Error("Set with empty SHA must report failure") + } if _, exists := cache.Entries["docker/login-action@v3.1.0"]; exists { t.Error("Set with empty SHA must not create a cache entry") @@ -1064,7 +1066,9 @@ func TestActionCache_Set_EmptySHARejected(t *testing.T) { // A subsequent Set with a real SHA must succeed normally. const sha = "abc1234567890123456789012345678901234567" - cache.Set("docker/login-action", "v3.1.0", sha) + if ok := cache.Set("docker/login-action", "v3.1.0", sha); !ok { + t.Fatal("Set with valid SHA must report success") + } entry, exists := cache.Entries["docker/login-action@v3.1.0"] if !exists { t.Fatal("Set with valid SHA did not create a cache entry") From 22bd799fecb9458a936bf74680a3c9b8a86b84d9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Jul 2026 08:18:56 +0000 Subject: [PATCH 5/9] chore: refine cache write skip diagnostics Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- pkg/cli/update_actions.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cli/update_actions.go b/pkg/cli/update_actions.go index 781ebb641ed..cb2f3105b24 100644 --- a/pkg/cli/update_actions.go +++ b/pkg/cli/update_actions.go @@ -241,7 +241,7 @@ func updateActions(ctx context.Context, deps actionUpdateDeps, allowMajor, verbo // Set the new entry first; ActionCache.Set handles inputs/description preservation. // If the write is rejected (e.g. empty SHA), keep the old entry untouched. if !actionCache.Set(entry.Repo, latestVersion, latestSHA) { - skipErr := "failed to write action cache entry for " + entry.Repo + "@" + latestVersion + skipErr := fmt.Sprintf("failed to write action cache entry for %s@%s (resolved SHA may be empty)", entry.Repo, latestVersion) updateLog.Printf("Skipping update for %s: %s", entry.Repo, skipErr) fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Skipping %s: %s", entry.Repo, skipErr))) failedActions = append(failedActions, actionUpdateFailure{ From f649ce521f5aa714bb39e013c10ac3c79321fde2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:28:45 +0000 Subject: [PATCH 6/9] fix: finish cooldown fallback review follow-ups Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- pkg/cli/update_actions.go | 14 ++++++++++---- pkg/cli/update_actions_test.go | 2 +- pkg/cli/update_workflows.go | 14 +++++++++++++- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/pkg/cli/update_actions.go b/pkg/cli/update_actions.go index cb2f3105b24..3e0f13b814e 100644 --- a/pkg/cli/update_actions.go +++ b/pkg/cli/update_actions.go @@ -199,14 +199,17 @@ func updateActions(ctx context.Context, deps actionUpdateDeps, allowMajor, verbo } if coolDownResult.InCoolDown { cooldownLog.Printf("Action %s: %s", entry.Repo, coolDownResult.Message) - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Skipping release candidate %s@%s: %s", entry.Repo, latestVersion, coolDownResult.Message))) // Try to find an older release that has passed the cooldown period. olderVersion, olderSHA, findErr := findCooledDownActionVersion(ctx, deps, entry.Repo, entry.Version, effectiveAllowMajor, verbose, coolDown, latestVersion) if findErr != nil || olderVersion == "" || olderSHA == "" { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Skipping release candidate %s@%s: %s", entry.Repo, latestVersion, coolDownResult.Message))) skippedActions = append(skippedActions, entry.Repo) continue } + if verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Falling back to %s for %s (latest release candidate is still in cooldown)", olderVersion, entry.Repo))) + } // Use the older, cooled-down release instead. latestVersion = olderVersion latestSHA = olderSHA @@ -1022,15 +1025,18 @@ func updateActionRefsInContentWithDeps(ctx context.Context, deps actionUpdateDep } if coolDownResult.InCoolDown { cooldownLog.Printf("Action ref %s in workflow: %s", repo, coolDownResult.Message) - if verbose { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Skipping release candidate %s@%s: %s", repo, latestVersion, coolDownResult.Message))) - } // Try to find an older release that has passed the cooldown period. olderVersion, olderSHA, findErr := findCooledDownActionVersion(ctx, deps, repo, currentVersion, effectiveAllowMajor, verbose, coolDown, latestVersion) if findErr != nil || olderVersion == "" || olderSHA == "" { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Skipping release candidate %s@%s: %s", repo, latestVersion, coolDownResult.Message))) + } continue } + if verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Falling back to %s for %s (latest release candidate is still in cooldown)", olderVersion, repo))) + } // Use the older, cooled-down release and update the per-invocation cache. result = latestReleaseResult{version: olderVersion, sha: olderSHA} cache[cacheKey] = result diff --git a/pkg/cli/update_actions_test.go b/pkg/cli/update_actions_test.go index 682ea8debd9..6a3a38229f2 100644 --- a/pkg/cli/update_actions_test.go +++ b/pkg/cli/update_actions_test.go @@ -973,7 +973,7 @@ func TestFindCooledDownActionVersion_EmptySHASkipped(t *testing.T) { return "", nil // empty SHA } - version, sha, err := findCooledDownActionVersion(context.Background(), deps, "docker/login-action", "v4.4.0", true, false, 7*24*time.Hour, "") + version, sha, err := findCooledDownActionVersion(context.Background(), deps, "docker/login-action", "v1.319.0", true, false, 7*24*time.Hour, "") if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/pkg/cli/update_workflows.go b/pkg/cli/update_workflows.go index 27bb62eacb7..a834eec281b 100644 --- a/pkg/cli/update_workflows.go +++ b/pkg/cli/update_workflows.go @@ -611,12 +611,17 @@ func resolveLatestReleaseWithDeps(ctx context.Context, deps workflowUpdateDeps, // If the repo is exempt from cooldown, return the best (newest) upgrade candidate. if isExemptFromCoolDown(repo) { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Found newer release: "+upgradeCandidates[0].tag)) + } return upgradeCandidates[0].tag, nil } // Iterate from newest to oldest upgrade candidate, stopping at the first release // that has passed the cooldown period. This lets users receive the latest cooled // release rather than being blocked on a single too-recent version. + loggedCandidateSkip := false + cooldownSkippedCount := 0 for _, c := range upgradeCandidates { result := deps.checkCoolDown(ctx, repo, c.tag, coolDown) if !result.InCoolDown { @@ -625,8 +630,15 @@ func resolveLatestReleaseWithDeps(ctx context.Context, deps workflowUpdateDeps, } return c.tag, nil } + cooldownSkippedCount++ cooldownLog.Printf("Workflow source %s: %s", repo, result.Message) - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Skipping release candidate %s@%s: %s", repo, c.tag, result.Message))) + if !loggedCandidateSkip { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Skipping release candidate %s@%s: %s", repo, c.tag, result.Message))) + loggedCandidateSkip = true + } + } + if cooldownSkippedCount > 1 { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Evaluated %d release candidates for %s; all are still in cooldown", cooldownSkippedCount, repo))) } // All upgrade candidates are still within the cooldown window. From eac8516fdd85f23a98a9306b0ac0100074c27988 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:19:20 +0000 Subject: [PATCH 7/9] refactor: share release candidate filtering for cooldown fallback Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- pkg/cli/release_candidates.go | 53 +++++++++++++++++++++++++++++++++++ pkg/cli/update_actions.go | 32 +-------------------- pkg/cli/update_workflows.go | 36 ++---------------------- 3 files changed, 56 insertions(+), 65 deletions(-) create mode 100644 pkg/cli/release_candidates.go diff --git a/pkg/cli/release_candidates.go b/pkg/cli/release_candidates.go new file mode 100644 index 00000000000..24a7f03d804 --- /dev/null +++ b/pkg/cli/release_candidates.go @@ -0,0 +1,53 @@ +package cli + +import ( + "slices" + + "github.com/github/gh-aw/pkg/semverutil" +) + +type releaseCandidate struct { + tag string + version *semverutil.SemanticVersion +} + +func sortedCompatibleReleaseCandidates(releases []string, currentVer *semverutil.SemanticVersion, allowMajor bool) []releaseCandidate { + var compatibleReleases []releaseCandidate + for _, release := range releases { + releaseVer := parseVersion(release) + if releaseVer == nil || releaseVer.Pre != "" { + continue + } + if !allowMajor && currentVer != nil && releaseVer.Major != currentVer.Major { + continue + } + compatibleReleases = append(compatibleReleases, releaseCandidate{tag: release, version: releaseVer}) + } + + slices.SortFunc(compatibleReleases, func(a, b releaseCandidate) int { + switch { + case a.version.IsNewer(b.version): + return -1 + case b.version.IsNewer(a.version): + return 1 + default: + return 0 + } + }) + + return compatibleReleases +} + +func newerReleaseCandidates(candidates []releaseCandidate, currentVer *semverutil.SemanticVersion) []releaseCandidate { + if currentVer == nil { + return candidates + } + + var newer []releaseCandidate + for _, c := range candidates { + if c.version.IsNewer(currentVer) { + newer = append(newer, c) + } + } + return newer +} diff --git a/pkg/cli/update_actions.go b/pkg/cli/update_actions.go index 3e0f13b814e..bd5e4f8c0f7 100644 --- a/pkg/cli/update_actions.go +++ b/pkg/cli/update_actions.go @@ -597,37 +597,7 @@ func findCooledDownActionVersion( currentVer := parseVersion(currentVersion) - type releaseCandidate struct { - tag string - version *semverutil.SemanticVersion - } - var candidates []releaseCandidate - for _, release := range releases { - releaseVer := parseVersion(release) - if releaseVer == nil || releaseVer.Pre != "" { - continue - } - if !allowMajor && currentVer != nil && releaseVer.Major != currentVer.Major { - continue - } - // Only include releases that are upgrades relative to the current version. - if currentVer != nil && !releaseVer.IsNewer(currentVer) { - continue - } - candidates = append(candidates, releaseCandidate{tag: release, version: releaseVer}) - } - - // Sort descending (newest first). - slices.SortFunc(candidates, func(a, b releaseCandidate) int { - switch { - case a.version.IsNewer(b.version): - return -1 - case b.version.IsNewer(a.version): - return 1 - default: - return 0 - } - }) + candidates := newerReleaseCandidates(sortedCompatibleReleaseCandidates(releases, currentVer, allowMajor), currentVer) for _, c := range candidates { if skipTag != "" && c.tag == skipTag { diff --git a/pkg/cli/update_workflows.go b/pkg/cli/update_workflows.go index a834eec281b..e8fca780aff 100644 --- a/pkg/cli/update_workflows.go +++ b/pkg/cli/update_workflows.go @@ -10,7 +10,6 @@ import ( "net/url" "os" "path/filepath" - "slices" "strings" "sync" "time" @@ -564,45 +563,14 @@ func resolveLatestReleaseWithDeps(ctx context.Context, deps workflowUpdateDeps, // Find all compatible non-prerelease releases. // Per semver rules, v1.1.0-beta.1 > v1.0.0, so without this filter a prerelease // of a higher base version could be incorrectly selected as the upgrade target. - type releaseCandidate struct { - tag string - version *semverutil.SemanticVersion - } - var compatibleReleases []releaseCandidate - for _, release := range releases { - releaseVer := parseVersion(release) - if releaseVer == nil || releaseVer.Pre != "" { - continue - } - if !allowMajor && releaseVer.Major != currentVer.Major { - continue - } - compatibleReleases = append(compatibleReleases, releaseCandidate{tag: release, version: releaseVer}) - } + compatibleReleases := sortedCompatibleReleaseCandidates(releases, currentVer, allowMajor) if len(compatibleReleases) == 0 { return "", errors.New("no compatible release found") } - // Sort descending so the newest compatible release is first. - slices.SortFunc(compatibleReleases, func(a, b releaseCandidate) int { - switch { - case a.version.IsNewer(b.version): - return -1 - case b.version.IsNewer(a.version): - return 1 - default: - return 0 - } - }) - // Collect upgrade candidates: releases strictly newer than current. - var upgradeCandidates []releaseCandidate - for _, c := range compatibleReleases { - if c.version.IsNewer(currentVer) { - upgradeCandidates = append(upgradeCandidates, c) - } - } + upgradeCandidates := newerReleaseCandidates(compatibleReleases, currentVer) // No upgrade available – already at the latest compatible release. if len(upgradeCandidates) == 0 { From 6d06210d090c6ad20a123c50b7b0df368cd5258b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:22:48 +0000 Subject: [PATCH 8/9] docs: clarify shared release candidate helper behavior Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- pkg/cli/release_candidates.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/cli/release_candidates.go b/pkg/cli/release_candidates.go index 24a7f03d804..35564f6e42f 100644 --- a/pkg/cli/release_candidates.go +++ b/pkg/cli/release_candidates.go @@ -11,6 +11,9 @@ type releaseCandidate struct { version *semverutil.SemanticVersion } +// sortedCompatibleReleaseCandidates filters tags to stable semantic versions, +// applies the major-version compatibility rule, and returns candidates sorted +// newest-first. func sortedCompatibleReleaseCandidates(releases []string, currentVer *semverutil.SemanticVersion, allowMajor bool) []releaseCandidate { var compatibleReleases []releaseCandidate for _, release := range releases { @@ -38,6 +41,8 @@ func sortedCompatibleReleaseCandidates(releases []string, currentVer *semverutil return compatibleReleases } +// newerReleaseCandidates keeps only candidates newer than currentVer. When +// currentVer is nil, all candidates are returned unchanged. func newerReleaseCandidates(candidates []releaseCandidate, currentVer *semverutil.SemanticVersion) []releaseCandidate { if currentVer == nil { return candidates From ee0097f9176ebe4a4992a9174faa30d8f887fee6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:25:57 +0000 Subject: [PATCH 9/9] refactor: split candidate selection into clearer intermediate steps Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- pkg/cli/update_actions.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/cli/update_actions.go b/pkg/cli/update_actions.go index bd5e4f8c0f7..830658be1b0 100644 --- a/pkg/cli/update_actions.go +++ b/pkg/cli/update_actions.go @@ -597,7 +597,8 @@ func findCooledDownActionVersion( currentVer := parseVersion(currentVersion) - candidates := newerReleaseCandidates(sortedCompatibleReleaseCandidates(releases, currentVer, allowMajor), currentVer) + compatibleReleases := sortedCompatibleReleaseCandidates(releases, currentVer, allowMajor) + candidates := newerReleaseCandidates(compatibleReleases, currentVer) for _, c := range candidates { if skipTag != "" && c.tag == skipTag {