diff --git a/pkg/cli/update_actions.go b/pkg/cli/update_actions.go index 7f0097d16e..04ec324cce 100644 --- a/pkg/cli/update_actions.go +++ b/pkg/cli/update_actions.go @@ -36,8 +36,10 @@ func isCoreAction(repo string) bool { } // UpdateActions updates GitHub Actions versions in .github/aw/actions-lock.json -// It checks each action for newer releases and updates the SHA if a newer version is found -func UpdateActions(allowMajor, verbose bool) error { +// It checks each action for newer releases and updates the SHA if a newer version is found. +// 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. +func UpdateActions(allowMajor, verbose, disableReleaseBump bool) error { updateLog.Print("Starting action updates") if verbose { @@ -77,8 +79,9 @@ func UpdateActions(allowMajor, verbose bool) error { for key, entry := range actionsLock.Entries { updateLog.Printf("Checking action: %s@%s", entry.Repo, entry.Version) - // Core actions (actions/*) always update to the latest major version - effectiveAllowMajor := allowMajor || isCoreAction(entry.Repo) + // By default all actions are force-updated to the latest major version. + // When disableReleaseBump is set, only core actions (actions/*) bypass the --major flag. + effectiveAllowMajor := !disableReleaseBump || allowMajor || isCoreAction(entry.Repo) // Check for latest release latestVersion, latestSHA, err := getLatestActionRelease(entry.Repo, entry.Version, effectiveAllowMajor, verbose) @@ -467,10 +470,13 @@ func marshalActionsLockSorted(actionsLock *actionsLockFile) ([]byte, error) { return []byte(buf.String()), nil } -// actionRefPattern matches "uses: actions/repo@SHA-or-tag" in workflow files. +// actionRefPattern matches "uses: org/repo@SHA-or-tag" in workflow files for any org. +// Requires the org to start with an alphanumeric character and contain only alphanumeric, +// hyphens, or underscores (no dots, matching GitHub's org naming rules) to exclude local +// paths (e.g. "./..."). Repository names may additionally contain dots. // Captures: (1) indentation+uses prefix, (2) repo path, (3) SHA or version tag, // (4) optional version comment (e.g., "v6.0.2" from "# v6.0.2"), (5) trailing whitespace. -var actionRefPattern = regexp.MustCompile(`(uses:\s+)(actions/[a-zA-Z0-9_.-]+(?:/[a-zA-Z0-9_.-]+)*)@([a-fA-F0-9]{40}|[^\s#\n]+?)(\s*#\s*\S+)?(\s*)$`) +var actionRefPattern = regexp.MustCompile(`(uses:\s+)([a-zA-Z0-9][a-zA-Z0-9_-]*/[a-zA-Z0-9_.-]+(?:/[a-zA-Z0-9_.-]+)*)@([a-fA-F0-9]{40}|[^\s#\n]+?)(\s*#\s*\S+)?(\s*)$`) // getLatestActionReleaseFn is the function used to fetch the latest release for an action. // It can be replaced in tests to avoid network calls. @@ -483,10 +489,11 @@ type latestReleaseResult struct { } // UpdateActionsInWorkflowFiles scans all workflow .md files under workflowsDir -// (recursively) and updates any "uses: actions/*@version" references to the latest -// major version. Updated files are recompiled. Core actions (actions/*) always update -// to latest major. -func UpdateActionsInWorkflowFiles(workflowsDir, engineOverride string, verbose bool) error { +// (recursively) and updates any "uses: org/repo@version" references to the latest +// 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(workflowsDir, engineOverride string, verbose, disableReleaseBump bool) error { if workflowsDir == "" { workflowsDir = getWorkflowsDir() } @@ -514,7 +521,7 @@ func UpdateActionsInWorkflowFiles(workflowsDir, engineOverride string, verbose b return nil } - updated, newContent, err := updateActionRefsInContent(string(content), cache, verbose) + updated, newContent, err := updateActionRefsInContent(string(content), cache, !disableReleaseBump, verbose) if err != nil { if verbose { fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to update action refs in %s: %v", path, err))) @@ -552,10 +559,13 @@ func UpdateActionsInWorkflowFiles(workflowsDir, engineOverride string, verbose b return nil } -// updateActionRefsInContent replaces outdated "uses: actions/*@version" references +// 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. -func updateActionRefsInContent(content string, cache map[string]latestReleaseResult, verbose bool) (bool, string, error) { +// 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(content string, cache map[string]latestReleaseResult, allowMajor, verbose bool) (bool, string, error) { changed := false lines := strings.Split(content, "\n") @@ -578,6 +588,12 @@ func updateActionRefsInContent(content string, cache map[string]latestReleaseRes trailing = line[match[10]:match[11]] } + // When release bumps are disabled, skip non-core (non actions/*) action refs. + effectiveAllowMajor := allowMajor || isCoreAction(repo) + if !effectiveAllowMajor { + continue + } + // Determine the "current version" to pass to getLatestActionReleaseFn isSHA := IsCommitSHA(ref) currentVersion := ref @@ -600,7 +616,7 @@ func updateActionRefsInContent(content string, cache map[string]latestReleaseRes cacheKey := repo + "|" + currentVersion result, cached := cache[cacheKey] if !cached { - latestVersion, latestSHA, err := getLatestActionReleaseFn(repo, currentVersion, true, verbose) + latestVersion, latestSHA, err := getLatestActionReleaseFn(repo, currentVersion, effectiveAllowMajor, verbose) if err != nil { updateLog.Printf("Failed to get latest release for %s: %v", repo, err) continue diff --git a/pkg/cli/update_actions_test.go b/pkg/cli/update_actions_test.go index b0da5e8146..21edb94c63 100644 --- a/pkg/cli/update_actions_test.go +++ b/pkg/cli/update_actions_test.go @@ -275,20 +275,20 @@ func TestIsCoreAction(t *testing.T) { } func TestUpdateActionRefsInContent_NonCoreActionsUnchanged(t *testing.T) { - // Non-actions/* org references should not be modified by updateActionRefsInContent - // since it only processes "uses: actions/" prefixed references. + // When allowMajor=false (--disable-release-bump), non-actions/* org references + // should not be modified because they are not core actions. input := `steps: - uses: docker/login-action@v3 - uses: github/codeql-action/upload-sarif@v3 - run: echo hello` cache := make(map[string]latestReleaseResult) - changed, newContent, err := updateActionRefsInContent(input, cache, false) + changed, newContent, err := updateActionRefsInContent(input, cache, false, false) if err != nil { t.Fatalf("updateActionRefsInContent() error = %v", err) } if changed { - t.Errorf("updateActionRefsInContent() changed = true, want false for non-actions/* refs") + t.Errorf("updateActionRefsInContent() changed = true, want false for non-actions/* refs with allowMajor=false") } if newContent != input { t.Errorf("updateActionRefsInContent() modified content for non-actions/* refs\nGot: %s\nWant: %s", newContent, input) @@ -302,7 +302,7 @@ steps: - run: echo world` cache := make(map[string]latestReleaseResult) - changed, _, err := updateActionRefsInContent(input, cache, false) + changed, _, err := updateActionRefsInContent(input, cache, true, false) if err != nil { t.Fatalf("updateActionRefsInContent() error = %v", err) } @@ -338,7 +338,7 @@ func TestUpdateActionRefsInContent_VersionTagReplacement(t *testing.T) { - run: echo hello` cache := make(map[string]latestReleaseResult) - changed, got, err := updateActionRefsInContent(input, cache, false) + changed, got, err := updateActionRefsInContent(input, cache, true, false) if err != nil { t.Fatalf("updateActionRefsInContent() error = %v", err) } @@ -365,7 +365,7 @@ func TestUpdateActionRefsInContent_SHAPinnedReplacement(t *testing.T) { want := " uses: actions/checkout@" + newSHA + " # v6.0.2" cache := make(map[string]latestReleaseResult) - changed, got, err := updateActionRefsInContent(input, cache, false) + changed, got, err := updateActionRefsInContent(input, cache, true, false) if err != nil { t.Fatalf("updateActionRefsInContent() error = %v", err) } @@ -394,7 +394,7 @@ func TestUpdateActionRefsInContent_CacheReusedAcrossLines(t *testing.T) { - uses: actions/github-script@v7` cache := make(map[string]latestReleaseResult) - changed, _, err := updateActionRefsInContent(input, cache, false) + changed, _, err := updateActionRefsInContent(input, cache, true, false) if err != nil { t.Fatalf("updateActionRefsInContent() error = %v", err) } @@ -405,3 +405,41 @@ func TestUpdateActionRefsInContent_CacheReusedAcrossLines(t *testing.T) { t.Errorf("getLatestActionReleaseFn called %d times, want 1 (cache should prevent second call)", callCount) } } + +func TestUpdateActionRefsInContent_AllOrgsUpdatedWhenAllowMajor(t *testing.T) { + // With allowMajor=true (default behaviour), non-actions/* org references should + // also be updated to the latest major version. + orig := getLatestActionReleaseFn + defer func() { getLatestActionReleaseFn = orig }() + + getLatestActionReleaseFn = func(repo, currentVersion string, allowMajor, verbose bool) (string, string, error) { + switch repo { + case "docker/login-action": + return "v4", "newsha11234567890123456789012345678901234", nil + case "github/codeql-action": + return "v4", "newsha21234567890123456789012345678901234", nil + default: + return currentVersion, "", nil + } + } + + input := `steps: + - uses: docker/login-action@v3 + - uses: github/codeql-action@v3` + + want := `steps: + - uses: docker/login-action@v4 + - uses: github/codeql-action@v4` + + cache := make(map[string]latestReleaseResult) + changed, got, err := updateActionRefsInContent(input, cache, true, false) + if err != nil { + t.Fatalf("updateActionRefsInContent() error = %v", err) + } + if !changed { + t.Error("updateActionRefsInContent() changed = false, want true") + } + if got != want { + t.Errorf("updateActionRefsInContent() output mismatch\nGot:\n%s\nWant:\n%s", got, want) + } +} diff --git a/pkg/cli/update_command.go b/pkg/cli/update_command.go index b6c5a0fe35..e14edae860 100644 --- a/pkg/cli/update_command.go +++ b/pkg/cli/update_command.go @@ -43,6 +43,7 @@ Examples: ` + string(constants.CLIExtensionPrefix) + ` update --no-merge # Override local changes with upstream ` + string(constants.CLIExtensionPrefix) + ` update repo-assist --major # Allow major version updates ` + string(constants.CLIExtensionPrefix) + ` update --force # Force update even if no changes + ` + string(constants.CLIExtensionPrefix) + ` update --disable-release-bump # Update without force-bumping all action versions ` + string(constants.CLIExtensionPrefix) + ` update --dir custom/workflows # Update workflows in custom directory`, RunE: func(cmd *cobra.Command, args []string) error { majorFlag, _ := cmd.Flags().GetBool("major") @@ -53,12 +54,13 @@ Examples: noStopAfter, _ := cmd.Flags().GetBool("no-stop-after") stopAfter, _ := cmd.Flags().GetString("stop-after") noMergeFlag, _ := cmd.Flags().GetBool("no-merge") + disableReleaseBump, _ := cmd.Flags().GetBool("disable-release-bump") if err := validateEngine(engineOverride); err != nil { return err } - return RunUpdateWorkflows(args, majorFlag, forceFlag, verbose, engineOverride, workflowDir, noStopAfter, stopAfter, noMergeFlag) + return RunUpdateWorkflows(args, majorFlag, forceFlag, verbose, engineOverride, workflowDir, noStopAfter, stopAfter, noMergeFlag, disableReleaseBump) }, } @@ -69,6 +71,7 @@ Examples: cmd.Flags().Bool("no-stop-after", false, "Remove any stop-after field from the workflow") cmd.Flags().String("stop-after", "", "Override stop-after value in the workflow (e.g., '+48h', '2025-12-31 23:59:59')") cmd.Flags().Bool("no-merge", false, "Override local changes with upstream version instead of merging") + cmd.Flags().Bool("disable-release-bump", false, "Disable automatic major version bumps for all actions (only core actions/* are force-updated)") // Register completions for update command cmd.ValidArgsFunction = CompleteWorkflowNames @@ -80,8 +83,8 @@ Examples: // RunUpdateWorkflows updates workflows from their source repositories. // Each workflow is compiled immediately after update. -func RunUpdateWorkflows(workflowNames []string, allowMajor, force, verbose bool, engineOverride string, workflowsDir string, noStopAfter bool, stopAfter string, noMerge bool) error { - updateLog.Printf("Starting update process: workflows=%v, allowMajor=%v, force=%v, noMerge=%v", workflowNames, allowMajor, force, noMerge) +func RunUpdateWorkflows(workflowNames []string, allowMajor, force, verbose bool, engineOverride string, workflowsDir string, noStopAfter bool, stopAfter string, noMerge bool, disableReleaseBump bool) error { + updateLog.Printf("Starting update process: workflows=%v, allowMajor=%v, force=%v, noMerge=%v, disableReleaseBump=%v", workflowNames, allowMajor, force, noMerge, disableReleaseBump) var firstErr error @@ -90,15 +93,16 @@ func RunUpdateWorkflows(workflowNames []string, allowMajor, force, verbose bool, } // Update GitHub Actions versions in actions-lock.json. - // Core actions (actions/*) are always updated to the latest major version. - if err := UpdateActions(allowMajor, verbose); err != nil { + // 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. + if err := UpdateActions(allowMajor, verbose, disableReleaseBump); 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))) } // Update action references in user-provided steps within workflow .md files. - // This covers both generated and hand-written steps that reference actions/*. - if err := UpdateActionsInWorkflowFiles(workflowsDir, engineOverride, verbose); err != nil { + // By default all org/repo@version references are updated to the latest major version. + if err := UpdateActionsInWorkflowFiles(workflowsDir, engineOverride, verbose, disableReleaseBump); 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))) } diff --git a/pkg/cli/update_command_test.go b/pkg/cli/update_command_test.go index b1486a3ace..03414c4eb3 100644 --- a/pkg/cli/update_command_test.go +++ b/pkg/cli/update_command_test.go @@ -813,7 +813,7 @@ func TestUpdateActions_NoFile(t *testing.T) { os.Chdir(tmpDir) // Should not error when file doesn't exist - err := UpdateActions(false, false) + err := UpdateActions(false, false, false) if err != nil { t.Errorf("Expected no error when actions-lock.json doesn't exist, got: %v", err) } @@ -844,7 +844,7 @@ func TestUpdateActions_EmptyFile(t *testing.T) { os.Chdir(tmpDir) // Should not error with empty file - err := UpdateActions(false, false) + err := UpdateActions(false, false, false) if err != nil { t.Errorf("Expected no error with empty actions-lock.json, got: %v", err) } @@ -873,7 +873,7 @@ func TestUpdateActions_InvalidJSON(t *testing.T) { os.Chdir(tmpDir) // Should error with invalid JSON - err := UpdateActions(false, false) + err := UpdateActions(false, false, false) if err == nil { t.Error("Expected error with invalid JSON, got nil") } @@ -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(nil, false, false, false, "", "", false, "", false) + err := RunUpdateWorkflows(nil, false, false, false, "", "", false, "", false, false) assert.NoError(t, err, "Should not error when no workflows with source field exist") } @@ -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([]string{"nonexistent"}, false, false, false, "", "", false, "", false) + err := RunUpdateWorkflows([]string{"nonexistent"}, false, false, false, "", "", false, "", false, false) require.Error(t, err, "Should error when specified workflow not found") assert.Contains(t, err.Error(), "no workflows found matching the specified names") } diff --git a/pkg/cli/upgrade_command.go b/pkg/cli/upgrade_command.go index 41d15bf763..3bb9982da4 100644 --- a/pkg/cli/upgrade_command.go +++ b/pkg/cli/upgrade_command.go @@ -189,7 +189,7 @@ func runUpgradeCommand(verbose bool, workflowDir string, noFix bool, noCompile b fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Updating GitHub Actions versions...")) upgradeLog.Print("Updating GitHub Actions versions") - if err := UpdateActions(false, verbose); err != nil { + if err := UpdateActions(false, verbose, false); err != nil { upgradeLog.Printf("Failed to update actions: %v", err) // Don't fail the upgrade if action updates fail - this is non-critical fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Warning: Failed to update actions: %v", err)))