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
3340func 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