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
44 changes: 30 additions & 14 deletions pkg/cli/update_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Comment on lines +474 to 478
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actionRefPattern’s doc comment doesn’t match what the regex actually captures/permits. In particular, capture group (1) is only uses:\s+ (indentation is not included), and the org pattern currently allows underscores even though the comment states it follows GitHub org naming rules (and explicitly calls out allowed characters). Please either adjust the comment to reflect the actual regex, or tighten the regex to match the documented constraints so future changes don’t rely on incorrect assumptions.

Suggested change
// 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.
// Requires the org segment to start with an alphanumeric character and contain only
// alphanumeric characters, hyphens, or underscores (no dots) to exclude local paths
// (e.g. "./..."). This is slightly looser than GitHub's org naming rules, which disallow
// underscores. Repository and subpath segments may additionally contain dots.
// Captures: (1) the "uses:" prefix plus following whitespace (no leading indentation),
// (2) the repo path (org/repo[/subpath...]), (3) the SHA or version tag,
// (4) an optional version comment (e.g., "v6.0.2" from "# v6.0.2"), (5) trailing whitespace.

Copilot uses AI. Check for mistakes.
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.
Expand All @@ -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()
}
Expand Down Expand Up @@ -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)))
Expand Down Expand Up @@ -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")

Expand All @@ -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
Expand All @@ -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 {
Comment on lines 597 to 620
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updateActionRefsInContent will treat any non-SHA ref as a “version tag” and attempt to resolve it via releases. That means refs like uses: owner/repo@main (or other non-semver branch names) can be rewritten to the latest semver release tag, because getLatestActionRelease() treats an unparsable currentVersion as “pick the highest semver release” regardless of allowMajor. This becomes much more likely now that the pattern matches all orgs. Consider explicitly skipping non-semver, non-SHA refs (e.g., only update when ref looks like a semver tag), or changing getLatestActionRelease() to not jump to the highest semver when allowMajor is false and the current ref isn’t semver.

Copilot uses AI. Check for mistakes.
updateLog.Printf("Failed to get latest release for %s: %v", repo, err)
continue
Expand Down
54 changes: 46 additions & 8 deletions pkg/cli/update_actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
}
}
18 changes: 11 additions & 7 deletions pkg/cli/update_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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)
},
}

Expand All @@ -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
Expand All @@ -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

Expand All @@ -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)))
}
Expand Down
10 changes: 5 additions & 5 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(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)
}
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(false, false)
err := UpdateActions(false, false, false)
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(false, false)
err := UpdateActions(false, false, false)
if err == nil {
t.Error("Expected error with invalid JSON, got nil")
}
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(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")
}

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([]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")
}
2 changes: 1 addition & 1 deletion pkg/cli/upgrade_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down
Loading