Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
159 changes: 158 additions & 1 deletion pkg/cli/update_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"

Expand All @@ -28,6 +29,12 @@ func extractBaseRepo(actionPath string) string {
return actionPath
}

// isCoreAction returns true if the repo is a GitHub-maintained core action (actions/* org).
// Core actions are always updated to the latest major version without requiring --major.
func isCoreAction(repo string) bool {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice clean helper function! The HasPrefix check is simple and effective. One thought: if other "trusted" orgs need similar treatment in the future (e.g., github/), this could be extended to a slice of trusted prefixes. For now, actions/*-only is the right scope.

return strings.HasPrefix(repo, "actions/")
}

// 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 {
Expand Down Expand Up @@ -70,8 +77,11 @@ 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)

// Check for latest release
latestVersion, latestSHA, err := getLatestActionRelease(entry.Repo, entry.Version, allowMajor, verbose)
latestVersion, latestSHA, err := getLatestActionRelease(entry.Repo, entry.Version, effectiveAllowMajor, verbose)
if err != nil {
if verbose {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to check %s: %v", entry.Repo, err)))
Expand Down Expand Up @@ -456,3 +466,150 @@ func marshalActionsLockSorted(actionsLock *actionsLockFile) ([]byte, error) {
buf.WriteString(" }\n}")
return []byte(buf.String()), nil
}

// actionRefPattern matches "uses: actions/repo@SHA-or-tag" in workflow files.
// 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").

Copilot AI Feb 27, 2026

Copy link

Choose a reason for hiding this comment

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

The comment above actionRefPattern says it captures 4 groups, but the regex has 5 capturing groups (including the trailing whitespace group). Please update the comment to match the actual capture groups to avoid confusion/incorrect future edits.

Suggested change
// (4) optional version comment (e.g., "v6.0.2" from "# v6.0.2").
// (4) optional version comment (e.g., "v6.0.2" from "# v6.0.2"), (5) trailing whitespace.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 32b407a — comment now reads: (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*)$`)
Comment thread
github-actions[bot] marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The regex captures SHA hashes (40 hex chars) and version tags correctly. The [^\s#\n]+? lazy match for version tags looks good. Worth noting: the $ anchor with a multiline regex could be tricky — make sure the regexp.MustCompile is used with line-by-line processing (which it is, based on the scanning logic in UpdateActionsInWorkflowFiles).


// UpdateActionsInWorkflowFiles scans all workflow .md files in workflowsDir
// 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 {
if workflowsDir == "" {
workflowsDir = getWorkflowsDir()
}

updateLog.Printf("Updating action references in workflow files: dir=%s", workflowsDir)

entries, err := os.ReadDir(workflowsDir)
if err != nil {
return fmt.Errorf("failed to read workflows directory: %w", err)
}

var updatedFiles []string

for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
continue
}

Copilot AI Feb 27, 2026

Copy link

Choose a reason for hiding this comment

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

UpdateActionsInWorkflowFiles only iterates over os.ReadDir(workflowsDir) and skips subdirectories. In this repo there are workflow source files under nested paths (e.g. .github/workflows/shared/mcp-debug.md still references actions/checkout@v5), so these won’t be updated. Consider walking workflowsDir recursively (filepath.WalkDir) and applying the same update+recompile logic to all *.md files.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 32b407aUpdateActionsInWorkflowFiles now uses filepath.WalkDir to recurse into all subdirectories (including .github/workflows/shared/), applying the same update+recompile logic to all *.md files.

filePath := filepath.Join(workflowsDir, entry.Name())
content, err := os.ReadFile(filePath)
if err != nil {
if verbose {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to read %s: %v", filePath, err)))
}
continue
}

updated, newContent, err := updateActionRefsInContent(string(content), verbose)
if err != nil {
if verbose {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to update action refs in %s: %v", filePath, err)))
}
continue
}

if !updated {
continue
}

if err := os.WriteFile(filePath, []byte(newContent), 0644); err != nil {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nit: os.WriteFile with hardcoded 0644 will change the file's permissions if they were different (e.g., 0664 or 0600).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@copilot apply comment and leave a note as a comment in the go code

return fmt.Errorf("failed to write updated workflow %s: %w", filePath, err)
}

fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Updated action references in "+entry.Name()))
updatedFiles = append(updatedFiles, filePath)

// Recompile the updated workflow
if err := compileWorkflowWithRefresh(filePath, verbose, false, engineOverride, false); err != nil {
if verbose {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to recompile %s: %v", filePath, err)))
}
}
}

if len(updatedFiles) == 0 && verbose {
fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No action references needed updating in workflow files"))
}

return nil
}

// updateActionRefsInContent replaces outdated "uses: actions/*@version" references
// in content with the latest major version and SHA. Returns (changed, newContent, error).
func updateActionRefsInContent(content string, verbose bool) (bool, string, error) {
changed := false
lines := strings.Split(content, "\n")

for i, line := range lines {
match := actionRefPattern.FindStringSubmatchIndex(line)
if match == nil {
continue
}

// Extract matched groups
prefix := line[match[2]:match[3]] // "uses: "
repo := line[match[4]:match[5]] // e.g. "actions/checkout"
ref := line[match[6]:match[7]] // SHA or version tag
comment := ""
if match[8] >= 0 {
comment = line[match[8]:match[9]] // e.g. " # v6.0.2"
}
trailing := ""
if match[10] >= 0 {
trailing = line[match[10]:match[11]]
}

// Determine the "current version" to pass to getLatestActionRelease
isSHA := IsCommitSHA(ref)
currentVersion := ref
if isSHA {
// Extract version from comment (e.g., " # v6.0.2" -> "v6.0.2")
if comment != "" {
commentVersion := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(comment), "#"))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

When a .md file has a bare SHA with no version comment (e.g., uses: actions/checkout@11bd719 like in hourly-ci-cleaner.md), currentVersion is set to "". Does getLatestActionRelease handle an empty version string gracefully? If it needs a version to determine the current major, passing "" might cause it to skip the update or error. Might be worth falling back to resolving the SHA to a tag via the API, or just defaulting to "v0" so it always picks up the latest.

if commentVersion != "" {
currentVersion = commentVersion
} else {
currentVersion = ""
}
} else {
currentVersion = ""
}
}

// Get the latest version for this core action (always allow major)
latestVersion, latestSHA, err := getLatestActionRelease(repo, currentVersion, true, verbose)
if err != nil {
updateLog.Printf("Failed to get latest release for %s: %v", repo, err)
continue
}

Copilot AI Feb 27, 2026

Copy link

Choose a reason for hiding this comment

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

updateActionRefsInContent calls getLatestActionRelease (GitHub API / git ls-remote) for every matching line. With many workflows and repeated actions, this can result in a large number of redundant network calls and slow/flake the update command. Cache results per repo (and possibly per currentVersion/allowMajor) within a single run, and reuse the latestVersion/latestSHA across lines/files.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 32b407a — added a map[string]latestReleaseResult cache (keyed by repo|currentVersion) passed through updateActionRefsInContent. Each unique repo/version is resolved only once per UpdateActionsInWorkflowFiles call, regardless of how many files or lines reference it.


if isSHA {
if latestSHA == ref {
continue // SHA unchanged
}
} else {
if latestVersion == ref {
continue // Version tag unchanged
}
}

// Build the new uses line
var newRef string
if isSHA {
// SHA-pinned references stay SHA-pinned, updated to latest SHA + version comment
newRef = fmt.Sprintf("%s%s%s@%s # %s%s", line[:match[2]], prefix, repo, latestSHA, latestVersion, trailing)
} else {
// Version tag references just get the new version tag
newRef = fmt.Sprintf("%s%s%s@%s%s%s", line[:match[2]], prefix, repo, latestVersion, comment, trailing)
}

updateLog.Printf("Updating %s from %s to %s in line %d", repo, ref, latestVersion, i+1)
lines[i] = newRef
changed = true
}

return changed, strings.Join(lines, "\n"), nil
}
59 changes: 59 additions & 0 deletions pkg/cli/update_actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -249,3 +249,62 @@ func TestMajorVersionPreference(t *testing.T) {
})
}
}

func TestIsCoreAction(t *testing.T) {
tests := []struct {
name string
repo string
want bool
}{
{"actions/checkout is core", "actions/checkout", true},
{"actions/setup-go is core", "actions/setup-go", true},
{"actions/cache/restore is core", "actions/cache/restore", true},
{"github/codeql-action is not core", "github/codeql-action", false},
{"docker/login-action is not core", "docker/login-action", false},
{"super-linter/super-linter is not core", "super-linter/super-linter", false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isCoreAction(tt.repo)
if got != tt.want {
t.Errorf("isCoreAction(%q) = %v, want %v", tt.repo, got, tt.want)
}
})
}
}

func TestUpdateActionRefsInContent_NonCoreActionsUnchanged(t *testing.T) {
// Non-actions/* org references should not be modified by updateActionRefsInContent
// since it only processes "uses: actions/" prefixed references.
input := `steps:
- uses: docker/login-action@v3
- uses: github/codeql-action/upload-sarif@v3
- run: echo hello`

changed, newContent, err := updateActionRefsInContent(input, false)
if err != nil {
t.Fatalf("updateActionRefsInContent() error = %v", err)
}
if changed {
t.Errorf("updateActionRefsInContent() changed = true, want false for non-actions/* refs")
}
if newContent != input {
t.Errorf("updateActionRefsInContent() modified content for non-actions/* refs\nGot: %s\nWant: %s", newContent, input)
}
}

func TestUpdateActionRefsInContent_NoActionRefs(t *testing.T) {
input := `description: Test workflow
steps:
- run: echo hello
- run: echo world`

changed, _, err := updateActionRefsInContent(input, false)
if err != nil {
t.Fatalf("updateActionRefsInContent() error = %v", err)
}
if changed {
t.Errorf("updateActionRefsInContent() changed = true, want false for content with no action refs")
}
}
Comment on lines +277 to +312

Copilot AI Feb 27, 2026

Copy link

Choose a reason for hiding this comment

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

The new workflow-reference updater (updateActionRefsInContent / UpdateActionsInWorkflowFiles) has no test that asserts actual replacements for version-tag and SHA-pinned forms. Since getLatestActionRelease hits external systems, consider injecting it behind an interface/func var so unit tests can stub it and verify that tags, SHAs, and generated version comments are updated as intended (including nested-workflow paths once recursion is added).

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 32b407a — added getLatestActionReleaseFn as a package-level func var (defaulting to getLatestActionRelease) so tests can inject a stub. Three new tests added:

  • TestUpdateActionRefsInContent_VersionTagReplacement — verifies @v4@v6 updates
  • TestUpdateActionRefsInContent_SHAPinnedReplacement — verifies SHA+comment updates
  • TestUpdateActionRefsInContent_CacheReusedAcrossLines — verifies the stub is called only once for duplicate refs

22 changes: 20 additions & 2 deletions pkg/cli/update_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package cli

import (
"fmt"
"os"

"github.com/github/gh-aw/pkg/console"
"github.com/github/gh-aw/pkg/constants"
"github.com/github/gh-aw/pkg/logger"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -81,9 +83,25 @@ Examples:
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)

var firstErr error

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

// 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 {
// 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 {
// 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)))
}

return nil
return firstErr
}