Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pkg/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ All diagnostic output MUST go to `stderr` using `console` formatting helpers. St
| `AddMCPTool` | `func(string, string, ...) error` | Adds an MCP server to a workflow file |
| `InspectWorkflowMCP` | `func(string, ...) error` | Inspects MCP server configurations |
| `ListWorkflowMCP` | `func(string, bool) error` | Lists MCP server info for a workflow |
| `UpdateActions` | `func(bool, bool, bool) error` | Bulk-updates GitHub Action versions in workflows |
| `UpdateActions` | `func(bool, bool, bool, time.Duration) error` | Bulk-updates GitHub Action versions in workflows |
| `ActionsBuildCommand` | `func() error` | Builds all custom actions in `actions/` |
| `ActionsValidateCommand` | `func() error` | Validates all `action.yml` files under `actions/` |
| `ActionsCleanCommand` | `func() error` | Removes generated action build artifacts |
Expand Down
53 changes: 49 additions & 4 deletions pkg/cli/update_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"regexp"
"sort"
"strings"
"time"

"github.com/github/gh-aw/pkg/console"
"github.com/github/gh-aw/pkg/gitutil"
Expand All @@ -36,10 +37,13 @@ func isGhAwNativeAction(repo string) bool {
// By default all actions are updated to the latest major version; pass disableReleaseBump=true
// to revert to the old behaviour where only core (actions/*) actions bypass the --major flag.
//
// coolDown specifies the minimum age a release must have before it is applied. Repos under the
// "actions/" and "github/" namespaces are always exempt from the cooldown.
//
// The ActionCache helpers from pkg/workflow are used so that cached inputs and descriptions
// for safe-outputs.actions entries are preserved when their SHA is unchanged, and cleared
// when the SHA changes (prompting a re-fetch on the next compile).
func UpdateActions(ctx context.Context, allowMajor, verbose, disableReleaseBump bool) error {
func UpdateActions(ctx context.Context, allowMajor, verbose, disableReleaseBump bool, coolDown time.Duration) error {
updateLog.Print("Starting action updates")

if verbose {
Expand Down Expand Up @@ -137,6 +141,27 @@ func UpdateActions(ctx context.Context, allowMajor, verbose, disableReleaseBump
continue
}

// Apply cooldown: if the repo is not exempt and the release is too recent, skip.
if !isExemptFromCoolDown(entry.Repo) {
var coolDownResult coolDownCheckResult
if cachedDate, ok := actionCache.GetReleasedAt(entry.Repo, latestVersion); ok {
// Use cached release date to avoid an extra API call.
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)
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)))
skippedActions = append(skippedActions, entry.Repo)
continue
}
}

// Update the entry using ActionCache.Set which:
// - Preserves cached inputs/descriptions when the SHA is unchanged (moving tag)
// - Clears cached inputs/descriptions when the SHA changes, prompting a re-fetch
Expand Down Expand Up @@ -540,7 +565,7 @@ type latestReleaseResult struct {
// major version. Updated files are recompiled. By default all actions are updated to
// the latest major version; pass disableReleaseBump=true to only update core
// (actions/*) references.
func UpdateActionsInWorkflowFiles(ctx context.Context, workflowsDir, engineOverride string, verbose, disableReleaseBump bool, noCompile bool) error {
func UpdateActionsInWorkflowFiles(ctx context.Context, workflowsDir, engineOverride string, verbose, disableReleaseBump bool, noCompile bool, coolDown time.Duration) error {
if workflowsDir == "" {
workflowsDir = getWorkflowsDir()
}
Expand All @@ -549,6 +574,8 @@ func UpdateActionsInWorkflowFiles(ctx context.Context, workflowsDir, engineOverr

// Per-invocation cache: key = "repo@currentVersion", avoids repeated API calls
cache := make(map[string]latestReleaseResult)
// Per-invocation cooldown cache: key = "repo@tag", avoids redundant date API calls
coolDownCache := make(map[string]coolDownCheckResult)

var updatedFiles []string

Expand All @@ -571,7 +598,7 @@ func UpdateActionsInWorkflowFiles(ctx context.Context, workflowsDir, engineOverr
return nil
}

updated, newContent, err := updateActionRefsInContent(ctx, string(content), cache, !disableReleaseBump, verbose)
updated, newContent, err := updateActionRefsInContent(ctx, string(content), cache, coolDownCache, !disableReleaseBump, verbose, coolDown)
if err != nil {
if verbose {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to update action refs in %s: %v", path, err)))
Expand Down Expand Up @@ -614,10 +641,11 @@ func UpdateActionsInWorkflowFiles(ctx context.Context, workflowsDir, engineOverr
// updateActionRefsInContent replaces outdated "uses: org/repo@version" references
// in content with the latest major version and SHA. Returns (changed, newContent, error).
// cache is keyed by "repo@currentVersion" and avoids redundant API calls across lines/files.
// coolDownCache is keyed by "repo@tag" and avoids redundant cooldown date API calls.
// When allowMajor is true (the default), all matched actions are updated to the latest
// major version. When allowMajor is false (--disable-release-bump), non-core (non
// actions/*) action refs are skipped; core actions are always updated.
func updateActionRefsInContent(ctx context.Context, content string, cache map[string]latestReleaseResult, allowMajor, verbose bool) (bool, string, error) {
func updateActionRefsInContent(ctx context.Context, content string, cache map[string]latestReleaseResult, coolDownCache map[string]coolDownCheckResult, allowMajor, verbose bool, coolDown time.Duration) (bool, string, error) {
changed := false
lines := strings.Split(content, "\n")

Expand Down Expand Up @@ -689,6 +717,23 @@ func updateActionRefsInContent(ctx context.Context, content string, cache map[st
}
}

// Apply cooldown: if the repo is not exempt and the release is too recent, skip.
if !isExemptFromCoolDown(repo) {
coolDownKey := repo + "@" + latestVersion
coolDownResult, coolDownCached := coolDownCache[coolDownKey]
if !coolDownCached {
coolDownResult = checkReleaseCoolDown(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)))
}
continue
}
}

// Build the new uses line
var newRef string
if isSHA {
Expand Down
16 changes: 8 additions & 8 deletions pkg/cli/update_actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ func TestUpdateActions_SafeOutputsInputsPreserved(t *testing.T) {
t.Fatalf("failed to chdir: %v", err)
}

if err := UpdateActions(context.Background(), false, false, false); err != nil {
if err := UpdateActions(context.Background(), false, false, false, 0); err != nil {
t.Fatalf("UpdateActions() error = %v", err)
}

Expand Down Expand Up @@ -353,7 +353,7 @@ func TestUpdateActionRefsInContent_NonCoreActionsUnchanged(t *testing.T) {
- run: echo hello`

cache := make(map[string]latestReleaseResult)
changed, newContent, err := updateActionRefsInContent(context.Background(), input, cache, false, false)
changed, newContent, err := updateActionRefsInContent(context.Background(), input, cache, make(map[string]coolDownCheckResult), false, false, 0)
if err != nil {
t.Fatalf("updateActionRefsInContent() error = %v", err)
}
Expand All @@ -372,7 +372,7 @@ steps:
- run: echo world`

cache := make(map[string]latestReleaseResult)
changed, _, err := updateActionRefsInContent(context.Background(), input, cache, true, false)
changed, _, err := updateActionRefsInContent(context.Background(), input, cache, make(map[string]coolDownCheckResult), true, false, 0)
if err != nil {
t.Fatalf("updateActionRefsInContent() error = %v", err)
}
Expand Down Expand Up @@ -408,7 +408,7 @@ func TestUpdateActionRefsInContent_VersionTagReplacement(t *testing.T) {
- run: echo hello`

cache := make(map[string]latestReleaseResult)
changed, got, err := updateActionRefsInContent(context.Background(), input, cache, true, false)
changed, got, err := updateActionRefsInContent(context.Background(), input, cache, make(map[string]coolDownCheckResult), true, false, 0)
if err != nil {
t.Fatalf("updateActionRefsInContent() error = %v", err)
}
Expand All @@ -435,7 +435,7 @@ func TestUpdateActionRefsInContent_SHAPinnedReplacement(t *testing.T) {
want := " uses: actions/checkout@" + newSHA + " # v6.0.2"

cache := make(map[string]latestReleaseResult)
changed, got, err := updateActionRefsInContent(context.Background(), input, cache, true, false)
changed, got, err := updateActionRefsInContent(context.Background(), input, cache, make(map[string]coolDownCheckResult), true, false, 0)
if err != nil {
t.Fatalf("updateActionRefsInContent() error = %v", err)
}
Expand Down Expand Up @@ -464,7 +464,7 @@ func TestUpdateActionRefsInContent_CacheReusedAcrossLines(t *testing.T) {
- uses: actions/github-script@v7`

cache := make(map[string]latestReleaseResult)
changed, _, err := updateActionRefsInContent(context.Background(), input, cache, true, false)
changed, _, err := updateActionRefsInContent(context.Background(), input, cache, make(map[string]coolDownCheckResult), true, false, 0)
if err != nil {
t.Fatalf("updateActionRefsInContent() error = %v", err)
}
Expand Down Expand Up @@ -502,7 +502,7 @@ func TestUpdateActionRefsInContent_AllOrgsUpdatedWhenAllowMajor(t *testing.T) {
- uses: github/codeql-action@v4`

cache := make(map[string]latestReleaseResult)
changed, got, err := updateActionRefsInContent(context.Background(), input, cache, true, false)
changed, got, err := updateActionRefsInContent(context.Background(), input, cache, make(map[string]coolDownCheckResult), true, false, 0)
if err != nil {
t.Fatalf("updateActionRefsInContent() error = %v", err)
}
Expand Down Expand Up @@ -665,7 +665,7 @@ func TestUpdateActions_GhAwNativeActionCappedAtCLIVersion(t *testing.T) {
t.Fatalf("failed to chdir: %v", err)
}

if err := UpdateActions(context.Background(), false, false, false); err != nil {
if err := UpdateActions(context.Background(), false, false, false, 0); err != nil {
t.Fatalf("UpdateActions() error = %v", err)
}

Expand Down
24 changes: 17 additions & 7 deletions pkg/cli/update_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"os"
"time"

"github.com/github/gh-aw/pkg/console"
"github.com/github/gh-aw/pkg/constants"
Expand Down Expand Up @@ -48,7 +49,9 @@ Examples:
` + string(constants.CLIExtensionPrefix) + ` update --no-compile # Update without regenerating lock files
` + string(constants.CLIExtensionPrefix) + ` update --no-redirect # Refuse workflows that use redirect frontmatter
` + string(constants.CLIExtensionPrefix) + ` update --dir custom/workflows # Update workflows in custom directory
` + string(constants.CLIExtensionPrefix) + ` update --create-pull-request # Update and open a pull request`,
` + string(constants.CLIExtensionPrefix) + ` update --create-pull-request # Update and open a pull request
` + string(constants.CLIExtensionPrefix) + ` update --cool-down 0 # Disable cooldown and apply all pending releases immediately
` + string(constants.CLIExtensionPrefix) + ` update --cool-down 3d # Apply a custom 3-day cooldown period`,
RunE: func(cmd *cobra.Command, args []string) error {
majorFlag, _ := cmd.Flags().GetBool("major")
forceFlag, _ := cmd.Flags().GetBool("force")
Expand All @@ -64,18 +67,24 @@ Examples:
createPRFlag, _ := cmd.Flags().GetBool("create-pull-request")
prFlagAlias, _ := cmd.Flags().GetBool("pr")
createPR := createPRFlag || prFlagAlias
coolDownStr, _ := cmd.Flags().GetString("cool-down")

if err := validateEngine(engineOverride); err != nil {
return err
}

coolDown, err := parseCoolDownFlag(coolDownStr)
if err != nil {
return fmt.Errorf("invalid --cool-down value: %w", err)
}

if createPR {
if err := PreflightCheckForCreatePR(verbose); err != nil {
return err
}
}

if err := RunUpdateWorkflows(cmd.Context(), args, majorFlag, forceFlag, verbose, engineOverride, workflowDir, noStopAfter, stopAfter, noMergeFlag, disableReleaseBump, noCompile, noRedirect); err != nil {
if err := RunUpdateWorkflows(cmd.Context(), args, majorFlag, forceFlag, verbose, engineOverride, workflowDir, noStopAfter, stopAfter, noMergeFlag, disableReleaseBump, noCompile, noRedirect, coolDown); err != nil {
return err
}

Expand All @@ -101,6 +110,7 @@ Examples:
cmd.Flags().Bool("no-redirect", false, "Refuse updates when redirect frontmatter is present")
cmd.Flags().Bool("create-pull-request", false, "Create a pull request with the update changes")
cmd.Flags().Bool("pr", false, "Alias for --create-pull-request")
cmd.Flags().String("cool-down", "7d", "Cooldown period before applying a new release (e.g. 7d, 24h, 0 to disable). Does not apply to actions/* or github/* repositories")
_ = cmd.Flags().MarkHidden("pr") // Hide the short alias from help output

// Register completions for update command
Expand All @@ -113,20 +123,20 @@ Examples:

// RunUpdateWorkflows updates workflows from their source repositories.
// Each workflow is compiled immediately after update.
func RunUpdateWorkflows(ctx context.Context, workflowNames []string, allowMajor, force, verbose bool, engineOverride string, workflowsDir string, noStopAfter bool, stopAfter string, noMerge bool, disableReleaseBump bool, noCompile bool, noRedirect bool) error {
updateLog.Printf("Starting update process: workflows=%v, allowMajor=%v, force=%v, noMerge=%v, disableReleaseBump=%v, noCompile=%v, noRedirect=%v", workflowNames, allowMajor, force, noMerge, disableReleaseBump, noCompile, noRedirect)
func RunUpdateWorkflows(ctx context.Context, workflowNames []string, allowMajor, force, verbose bool, engineOverride string, workflowsDir string, noStopAfter bool, stopAfter string, noMerge bool, disableReleaseBump bool, noCompile bool, noRedirect bool, coolDown time.Duration) error {
updateLog.Printf("Starting update process: workflows=%v, allowMajor=%v, force=%v, noMerge=%v, disableReleaseBump=%v, noCompile=%v, noRedirect=%v, coolDown=%v", workflowNames, allowMajor, force, noMerge, disableReleaseBump, noCompile, noRedirect, coolDown)

var firstErr error

if err := UpdateWorkflows(ctx, workflowNames, allowMajor, force, verbose, engineOverride, workflowsDir, noStopAfter, stopAfter, noMerge, noCompile, noRedirect); err != nil {
if err := UpdateWorkflows(ctx, workflowNames, allowMajor, force, verbose, engineOverride, workflowsDir, noStopAfter, stopAfter, noMerge, noCompile, noRedirect, coolDown); err != nil {
firstErr = fmt.Errorf("workflow update failed: %w", err)
}

// Update GitHub Actions versions in actions-lock.json.
// By default all actions are updated to the latest major version.
// Pass --disable-release-bump to revert to only forcing updates for core (actions/*) actions.
updateLog.Printf("Updating GitHub Actions versions in actions-lock.json: allowMajor=%v, disableReleaseBump=%v", allowMajor, disableReleaseBump)
if err := UpdateActions(ctx, allowMajor, verbose, disableReleaseBump); err != nil {
if err := UpdateActions(ctx, allowMajor, verbose, disableReleaseBump, coolDown); err != nil {
// Non-fatal: warn but don't fail the update
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Warning: Failed to update actions-lock.json: %v", err)))
}
Expand All @@ -141,7 +151,7 @@ func RunUpdateWorkflows(ctx context.Context, workflowNames []string, allowMajor,
// Update action references in user-provided steps within workflow .md files.
// By default all org/repo@version references are updated to the latest major version.
updateLog.Print("Updating action references in workflow .md files")
if err := UpdateActionsInWorkflowFiles(ctx, workflowsDir, engineOverride, verbose, disableReleaseBump, noCompile); err != nil {
if err := UpdateActionsInWorkflowFiles(ctx, workflowsDir, engineOverride, verbose, disableReleaseBump, noCompile, coolDown); err != nil {
// Non-fatal: warn but don't fail the update
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Warning: Failed to update action references in workflow files: %v", err)))
}
Expand Down
12 changes: 6 additions & 6 deletions pkg/cli/update_command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -813,7 +813,7 @@ func TestUpdateActions_NoFile(t *testing.T) {
os.Chdir(tmpDir)

// Should not error when file doesn't exist
err := UpdateActions(context.Background(), false, false, false)
err := UpdateActions(context.Background(), false, false, false, 0)
if err != nil {
t.Errorf("Expected no error when actions-lock.json doesn't exist, got: %v", err)
}
Expand Down Expand Up @@ -844,7 +844,7 @@ func TestUpdateActions_EmptyFile(t *testing.T) {
os.Chdir(tmpDir)

// Should not error with empty file
err := UpdateActions(context.Background(), false, false, false)
err := UpdateActions(context.Background(), false, false, false, 0)
if err != nil {
t.Errorf("Expected no error with empty actions-lock.json, got: %v", err)
}
Expand Down Expand Up @@ -873,7 +873,7 @@ func TestUpdateActions_InvalidJSON(t *testing.T) {
os.Chdir(tmpDir)

// Should error with invalid JSON
err := UpdateActions(context.Background(), false, false, false)
err := UpdateActions(context.Background(), false, false, false, 0)
if err == nil {
t.Error("Expected error with invalid JSON, got nil")
}
Expand All @@ -894,7 +894,7 @@ func TestResolveLatestRef_CommitSHA(t *testing.T) {
// in authenticated environments it will succeed. Either outcome is
// acceptable — the key invariant is that the SHA is correctly
// identified (tested above) and the function does not panic.
_, _ = resolveLatestRef(context.Background(), "test/repo", sha, false, false)
_, _ = resolveLatestRef(context.Background(), "test/repo", sha, false, false, 0)
}

// TestResolveLatestRef_NotCommitSHA tests that non-SHA refs are handled appropriately
Expand Down Expand Up @@ -980,7 +980,7 @@ func TestRunUpdateWorkflows_NoSourceWorkflows(t *testing.T) {
os.Chdir(tmpDir)

// Running update with no source workflows should succeed with an info message, not an error
err := RunUpdateWorkflows(context.Background(), nil, false, false, false, "", "", false, "", false, false, false, false)
err := RunUpdateWorkflows(context.Background(), nil, false, false, false, "", "", false, "", false, false, false, false, 0)
assert.NoError(t, err, "Should not error when no workflows with source field exist")
}

Expand All @@ -996,7 +996,7 @@ func TestRunUpdateWorkflows_SpecificWorkflowNotFound(t *testing.T) {
os.Chdir(tmpDir)

// Running update with a specific name that doesn't exist should fail
err := RunUpdateWorkflows(context.Background(), []string{"nonexistent"}, false, false, false, "", "", false, "", false, false, false, false)
err := RunUpdateWorkflows(context.Background(), []string{"nonexistent"}, false, false, false, "", "", false, "", false, false, false, false, 0)
require.Error(t, err, "Should error when specified workflow not found")
assert.Contains(t, err.Error(), "no workflows found matching the specified names")
}
Loading