Skip to content

Commit 65af980

Browse files
authored
feat: update command always updates core actions (actions/*) to latest major version (#18692)
1 parent 521da06 commit 65af980

10 files changed

Lines changed: 375 additions & 14 deletions

.changeset/patch-update-actions-core-workflows.md

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.github/workflows/bot-detection.lock.yml

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.github/workflows/bot-detection.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ jobs:
3131
steps:
3232
- name: Precompute deterministic findings
3333
id: precompute
34-
uses: actions/github-script@v7
34+
uses: actions/github-script@v8
3535
with:
3636
github-token: ${{ secrets.GH_AW_BOT_DETECTION_TOKEN || secrets.GITHUB_TOKEN }}
3737
script: |

.github/workflows/daily-syntax-error-quality.lock.yml

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.github/workflows/daily-syntax-error-quality.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ timeout-minutes: 20
3333
strict: true
3434
steps:
3535
- name: Setup Go
36-
uses: actions/setup-go@v5
36+
uses: actions/setup-go@v6
3737
with:
3838
go-version-file: go.mod
3939
cache: true

.github/workflows/release.lock.yml

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.github/workflows/release.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ jobs:
5151

5252
- name: Compute release configuration
5353
id: compute_config
54-
uses: actions/github-script@v7
54+
uses: actions/github-script@v8
5555
with:
5656
script: |
5757
const releaseType = context.payload.inputs.release_type;
@@ -172,7 +172,7 @@ jobs:
172172
echo "✓ Tag created: $RELEASE_TAG"
173173
174174
- name: Setup Go
175-
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0
175+
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
176176
with:
177177
go-version-file: go.mod
178178
cache: false # Disabled for release security - prevent cache poisoning attacks

pkg/cli/update_actions.go

Lines changed: 183 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"os"
99
"os/exec"
1010
"path/filepath"
11+
"regexp"
1112
"sort"
1213
"strings"
1314

@@ -28,6 +29,12 @@ func extractBaseRepo(actionPath string) string {
2829
return actionPath
2930
}
3031

32+
// isCoreAction returns true if the repo is a GitHub-maintained core action (actions/* org).
33+
// Core actions are always updated to the latest major version without requiring --major.
34+
func isCoreAction(repo string) bool {
35+
return strings.HasPrefix(repo, "actions/")
36+
}
37+
3138
// UpdateActions updates GitHub Actions versions in .github/aw/actions-lock.json
3239
// It checks each action for newer releases and updates the SHA if a newer version is found
3340
func UpdateActions(allowMajor, verbose bool) error {
@@ -70,8 +77,11 @@ func UpdateActions(allowMajor, verbose bool) error {
7077
for key, entry := range actionsLock.Entries {
7178
updateLog.Printf("Checking action: %s@%s", entry.Repo, entry.Version)
7279

80+
// Core actions (actions/*) always update to the latest major version
81+
effectiveAllowMajor := allowMajor || isCoreAction(entry.Repo)
82+
7383
// Check for latest release
74-
latestVersion, latestSHA, err := getLatestActionRelease(entry.Repo, entry.Version, allowMajor, verbose)
84+
latestVersion, latestSHA, err := getLatestActionRelease(entry.Repo, entry.Version, effectiveAllowMajor, verbose)
7585
if err != nil {
7686
if verbose {
7787
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to check %s: %v", entry.Repo, err)))
@@ -456,3 +466,175 @@ func marshalActionsLockSorted(actionsLock *actionsLockFile) ([]byte, error) {
456466
buf.WriteString(" }\n}")
457467
return []byte(buf.String()), nil
458468
}
469+
470+
// actionRefPattern matches "uses: actions/repo@SHA-or-tag" in workflow files.
471+
// Captures: (1) indentation+uses prefix, (2) repo path, (3) SHA or version tag,
472+
// (4) optional version comment (e.g., "v6.0.2" from "# v6.0.2"), (5) trailing whitespace.
473+
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*)$`)
474+
475+
// getLatestActionReleaseFn is the function used to fetch the latest release for an action.
476+
// It can be replaced in tests to avoid network calls.
477+
var getLatestActionReleaseFn = getLatestActionRelease
478+
479+
// latestReleaseResult caches a resolved version/SHA pair.
480+
type latestReleaseResult struct {
481+
version string
482+
sha string
483+
}
484+
485+
// UpdateActionsInWorkflowFiles scans all workflow .md files under workflowsDir
486+
// (recursively) and updates any "uses: actions/*@version" references to the latest
487+
// major version. Updated files are recompiled. Core actions (actions/*) always update
488+
// to latest major.
489+
func UpdateActionsInWorkflowFiles(workflowsDir, engineOverride string, verbose bool) error {
490+
if workflowsDir == "" {
491+
workflowsDir = getWorkflowsDir()
492+
}
493+
494+
updateLog.Printf("Updating action references in workflow files: dir=%s", workflowsDir)
495+
496+
// Per-invocation cache: key = "repo@currentVersion", avoids repeated API calls
497+
cache := make(map[string]latestReleaseResult)
498+
499+
var updatedFiles []string
500+
501+
err := filepath.WalkDir(workflowsDir, func(path string, d os.DirEntry, walkErr error) error {
502+
if walkErr != nil {
503+
return walkErr
504+
}
505+
if d.IsDir() || !strings.HasSuffix(d.Name(), ".md") {
506+
return nil
507+
}
508+
509+
content, err := os.ReadFile(path)
510+
if err != nil {
511+
if verbose {
512+
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to read %s: %v", path, err)))
513+
}
514+
return nil
515+
}
516+
517+
updated, newContent, err := updateActionRefsInContent(string(content), cache, verbose)
518+
if err != nil {
519+
if verbose {
520+
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to update action refs in %s: %v", path, err)))
521+
}
522+
return nil
523+
}
524+
525+
if !updated {
526+
return nil
527+
}
528+
529+
if err := os.WriteFile(path, []byte(newContent), 0644); err != nil {
530+
return fmt.Errorf("failed to write updated workflow %s: %w", path, err)
531+
}
532+
533+
fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Updated action references in "+d.Name()))
534+
updatedFiles = append(updatedFiles, path)
535+
536+
// Recompile the updated workflow
537+
if err := compileWorkflowWithRefresh(path, verbose, false, engineOverride, false); err != nil {
538+
if verbose {
539+
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to recompile %s: %v", path, err)))
540+
}
541+
}
542+
return nil
543+
})
544+
if err != nil {
545+
return fmt.Errorf("failed to walk workflows directory: %w", err)
546+
}
547+
548+
if len(updatedFiles) == 0 && verbose {
549+
fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No action references needed updating in workflow files"))
550+
}
551+
552+
return nil
553+
}
554+
555+
// updateActionRefsInContent replaces outdated "uses: actions/*@version" references
556+
// in content with the latest major version and SHA. Returns (changed, newContent, error).
557+
// cache is keyed by "repo@currentVersion" and avoids redundant API calls across lines/files.
558+
func updateActionRefsInContent(content string, cache map[string]latestReleaseResult, verbose bool) (bool, string, error) {
559+
changed := false
560+
lines := strings.Split(content, "\n")
561+
562+
for i, line := range lines {
563+
match := actionRefPattern.FindStringSubmatchIndex(line)
564+
if match == nil {
565+
continue
566+
}
567+
568+
// Extract matched groups
569+
prefix := line[match[2]:match[3]] // "uses: "
570+
repo := line[match[4]:match[5]] // e.g. "actions/checkout"
571+
ref := line[match[6]:match[7]] // SHA or version tag
572+
comment := ""
573+
if match[8] >= 0 {
574+
comment = line[match[8]:match[9]] // e.g. " # v6.0.2"
575+
}
576+
trailing := ""
577+
if match[10] >= 0 {
578+
trailing = line[match[10]:match[11]]
579+
}
580+
581+
// Determine the "current version" to pass to getLatestActionReleaseFn
582+
isSHA := IsCommitSHA(ref)
583+
currentVersion := ref
584+
if isSHA {
585+
// Extract version from comment (e.g., " # v6.0.2" -> "v6.0.2")
586+
if comment != "" {
587+
commentVersion := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(comment), "#"))
588+
if commentVersion != "" {
589+
currentVersion = commentVersion
590+
} else {
591+
currentVersion = ""
592+
}
593+
} else {
594+
currentVersion = ""
595+
}
596+
}
597+
598+
// Resolve latest version/SHA, using the cache to avoid redundant API calls.
599+
// Use "|" as separator since GitHub repo names cannot contain "|".
600+
cacheKey := repo + "|" + currentVersion
601+
result, cached := cache[cacheKey]
602+
if !cached {
603+
latestVersion, latestSHA, err := getLatestActionReleaseFn(repo, currentVersion, true, verbose)
604+
if err != nil {
605+
updateLog.Printf("Failed to get latest release for %s: %v", repo, err)
606+
continue
607+
}
608+
result = latestReleaseResult{version: latestVersion, sha: latestSHA}
609+
cache[cacheKey] = result
610+
}
611+
latestVersion := result.version
612+
latestSHA := result.sha
613+
614+
if isSHA {
615+
if latestSHA == ref {
616+
continue // SHA unchanged
617+
}
618+
} else {
619+
if latestVersion == ref {
620+
continue // Version tag unchanged
621+
}
622+
}
623+
624+
// Build the new uses line
625+
var newRef string
626+
if isSHA {
627+
// SHA-pinned references stay SHA-pinned, updated to latest SHA + version comment
628+
newRef = fmt.Sprintf("%s%s%s@%s # %s%s", line[:match[2]], prefix, repo, latestSHA, latestVersion, trailing)
629+
} else {
630+
// Version tag references just get the new version tag
631+
newRef = fmt.Sprintf("%s%s%s@%s%s%s", line[:match[2]], prefix, repo, latestVersion, comment, trailing)
632+
}
633+
634+
updateLog.Printf("Updating %s from %s to %s in line %d", repo, ref, latestVersion, i+1)
635+
lines[i] = newRef
636+
changed = true
637+
}
638+
639+
return changed, strings.Join(lines, "\n"), nil
640+
}

0 commit comments

Comments
 (0)