diff --git a/docs/adr/52084-split-update-actions-into-focused-modules.md b/docs/adr/52084-split-update-actions-into-focused-modules.md new file mode 100644 index 00000000000..a1e14edec1c --- /dev/null +++ b/docs/adr/52084-split-update-actions-into-focused-modules.md @@ -0,0 +1,51 @@ +# ADR-52084: Split update-actions Implementation into Focused Modules + +**Date**: 2026-08-11 +**Status**: Draft +**Deciders**: Unknown + +--- + +### Context + +`pkg/cli/update_actions.go` grew to 1,144 lines, exceeding the repository's 800-line file-size threshold and becoming the largest non-test Go source file in the codebase. The file mixed four loosely related responsibilities in a single compilation unit: action classification (`isCoreAction`, `isGhAwNativeAction`), release version resolution via GitHub API and git fallback (`getLatestActionRelease`, `getLatestActionReleaseViaGit`, cooldown gating), lockfile orchestration (`UpdateActions`, `updateActions`), and workflow Markdown reference rewriting (`UpdateActionsInWorkflowFiles`, `updateSkillRefsInContent`, `updateActionRefsInContentWithDeps`). This coupling made it hard to locate, read, and test individual concerns without scrolling through the entire file. + +### Decision + +We will split `pkg/cli/update_actions.go` into four focused files within the same `cli` package, preserving all public API signatures (`UpdateActions`, `UpdateActionsInWorkflowFiles`) and all existing behaviour unchanged: + +1. **`update_actions_deps.go`** — dependency-injection struct (`actionUpdateDeps`), cache types, `newCachedActionUpdateDeps`, and `defaultActionUpdateDeps`. +2. **`update_actions_release.go`** — action classification helpers, GitHub Releases API resolution, git-tag fallback resolution, SHA peeling, and cooldown-window gating. +3. **`update_actions.go`** (reduced) — top-level `UpdateActions` entry point and `updateActions` lockfile orchestration. +4. **`update_actions_workflow_refs.go`** — `UpdateActionsInWorkflowFiles` entry point, workflow-file walking, `uses:` action-ref rewriting, and `source:` skill-ref rewriting. + +The split follows domain boundaries already visible in the file, requires no new interfaces or packages, and leaves callers in `update_command.go` and test files unmodified. + +### Alternatives Considered + +#### Alternative 1: Keep the monolith + +Leave `update_actions.go` as a single file. This requires zero refactoring effort and zero risk of regressions, but the file will continue to grow as new concerns are added. The four distinct domains will remain entangled, making code review and targeted testing progressively harder. The repository's file-size threshold would be permanently violated. + +#### Alternative 2: Extract to a dedicated package (`pkg/updateactions`) + +Move all action-update logic to a new top-level package instead of splitting within `pkg/cli`. This would create a cleaner architectural boundary and make the separation visible to package consumers. However, it requires updating import paths in multiple callers and test files, changing the exported symbol locations, and performing a larger structural change — all without any behaviour difference. Given that no external package currently imports these symbols directly (they are CLI-internal), the added complexity is not justified at this stage. + +### Consequences + +#### Positive +- Each file encapsulates a single concern; a developer working on cooldown logic only needs to open `update_actions_release.go` rather than scrolling a 1,144-line file. +- Test files can be named and organised to mirror the new source boundaries (`update_actions_release_test.go`, `update_actions_workflow_refs_test.go`), making the relationship between source and test explicit. +- The repository's file-size threshold is satisfied for all resulting files (each is under 500 lines). + +#### Negative +- Understanding the complete action-update flow now requires opening and cross-referencing multiple files instead of reading one. +- The refactor produces no user-visible functionality and adds commit churn that reviewers must verify as purely mechanical. + +#### Neutral +- The public API (`UpdateActions`, `UpdateActionsInWorkflowFiles`) and all function signatures remain unchanged, so no callers require modification. +- `actionUpdateDeps` continues to act as the shared dependency-injection seam between resolution and rewriting logic; no new interfaces are introduced. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* diff --git a/pkg/cli/update_actions.go b/pkg/cli/update_actions.go index 18741e0d097..3ae3487586f 100644 --- a/pkg/cli/update_actions.go +++ b/pkg/cli/update_actions.go @@ -2,157 +2,17 @@ package cli import ( "context" - "errors" "fmt" "os" - "os/exec" "path/filepath" - "regexp" - "slices" - "strings" - "sync" "time" - "github.com/github/gh-aw/pkg/constants" - "github.com/github/gh-aw/pkg/console" "github.com/github/gh-aw/pkg/gitutil" - "github.com/github/gh-aw/pkg/parser" "github.com/github/gh-aw/pkg/semverutil" "github.com/github/gh-aw/pkg/workflow" - "github.com/goccy/go-yaml" ) -// 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 { - return strings.HasPrefix(repo, "actions/") -} - -// isGhAwNativeAction returns true if the action repo is part of the gh-aw native ecosystem -// (i.e., maintained in the github/gh-aw or github/gh-aw-actions repository). These actions -// are versioned in lock-step with the CLI and must never be updated beyond the running CLI version. -func isGhAwNativeAction(repo string) bool { - base := gitutil.ExtractBaseRepo(repo) - return base == "github/gh-aw" || base == "github/gh-aw-actions" -} - -type actionUpdateDeps struct { - getLatestRelease func(ctx context.Context, repo, currentVersion string, allowMajor, verbose bool) (string, string, error) - getLatestReleaseViaGit func(ctx context.Context, repo, currentVersion string, allowMajor, verbose bool) (string, string, error) - runGHReleasesAPI func(ctx context.Context, baseRepo string) ([]byte, error) - getActionSHAForTag func(ctx context.Context, repo, tag string) (string, error) - checkCoolDown func(ctx context.Context, repo, tag string, coolDown time.Duration) coolDownCheckResult -} - -type cachedLatestRelease struct { - version string - sha string - err error -} - -type cachedSHA struct { - sha string - err error -} - -// newCachedActionUpdateDeps memoizes GitHub reads for the full update command. -// This cache is shared by actions-lock.json updates and Markdown action refs. -func newCachedActionUpdateDeps(base actionUpdateDeps) actionUpdateDeps { - var mu sync.Mutex - latestReleases := make(map[string]cachedLatestRelease) - releaseLists := make(map[string]struct { - output []byte - err error - }) - shas := make(map[string]cachedSHA) - cooldowns := make(map[string]coolDownCheckResult) - - cached := base - cached.getLatestRelease = func(ctx context.Context, repo, currentVersion string, allowMajor, verbose bool) (string, string, error) { - key := fmt.Sprintf("%s|%s|%t", repo, currentVersion, allowMajor) - mu.Lock() - result, ok := latestReleases[key] - mu.Unlock() - if ok { - return result.version, result.sha, result.err - } - version, sha, err := base.getLatestRelease(ctx, repo, currentVersion, allowMajor, verbose) - mu.Lock() - latestReleases[key] = cachedLatestRelease{version: version, sha: sha, err: err} - mu.Unlock() - return version, sha, err - } - cached.runGHReleasesAPI = func(ctx context.Context, repo string) ([]byte, error) { - mu.Lock() - result, ok := releaseLists[repo] - mu.Unlock() - if ok { - return result.output, result.err - } - output, err := base.runGHReleasesAPI(ctx, repo) - mu.Lock() - releaseLists[repo] = struct { - output []byte - err error - }{output: output, err: err} - mu.Unlock() - return output, err - } - cached.getActionSHAForTag = func(ctx context.Context, repo, tag string) (string, error) { - key := repo + "|" + tag - mu.Lock() - result, ok := shas[key] - mu.Unlock() - if ok { - return result.sha, result.err - } - sha, err := base.getActionSHAForTag(ctx, repo, tag) - mu.Lock() - shas[key] = cachedSHA{sha: sha, err: err} - mu.Unlock() - return sha, err - } - cached.checkCoolDown = func(ctx context.Context, repo, tag string, coolDown time.Duration) coolDownCheckResult { - key := fmt.Sprintf("%s|%s|%s", repo, tag, coolDown) - mu.Lock() - result, ok := cooldowns[key] - mu.Unlock() - if ok { - return result - } - result = base.checkCoolDown(ctx, repo, tag, coolDown) - mu.Lock() - cooldowns[key] = result - mu.Unlock() - return result - } - return cached -} - -func defaultActionUpdateDeps() actionUpdateDeps { - return actionUpdateDeps{ - getLatestRelease: getLatestActionRelease, - getLatestReleaseViaGit: getLatestActionReleaseViaGit, - checkCoolDown: checkReleaseCoolDown, - runGHReleasesAPI: func(ctx context.Context, baseRepo string) ([]byte, error) { - return workflow.RunGHCombinedContext(ctx, "Fetching releases...", "api", "--paginate", fmt.Sprintf("/repos/%s/releases", baseRepo), "--jq", ".[].tag_name") - }, - getActionSHAForTag: getActionSHAForTag, - } -} - -// 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. -// 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. -// -// coolDown specifies the minimum age a release must have before it is applied. Repos under the -// "actions/" and "github/" namespaces are always exempt from the cooldown. -// -// The ActionCache helpers from pkg/workflow are used so that cached inputs and descriptions -// for safe-outputs.actions entries are preserved when their SHA is unchanged, and cleared -// when the SHA changes (prompting a re-fetch on the next compile). func UpdateActions(ctx context.Context, allowMajor, verbose, disableReleaseBump bool, coolDown time.Duration) error { return updateActions(ctx, defaultActionUpdateDeps(), allowMajor, verbose, disableReleaseBump, coolDown) } @@ -397,748 +257,3 @@ func updateActions(ctx context.Context, deps actionUpdateDeps, allowMajor, verbo return nil } - -// getLatestActionRelease gets the latest release for an action repository -// It respects semantic versioning and the allowMajor flag -func getLatestActionRelease(ctx context.Context, repo, currentVersion string, allowMajor, verbose bool) (string, string, error) { - return getLatestActionReleaseWithDeps(ctx, defaultActionUpdateDeps(), repo, currentVersion, allowMajor, verbose) -} - -func getLatestActionReleaseWithDeps(ctx context.Context, deps actionUpdateDeps, repo, currentVersion string, allowMajor, verbose bool) (string, string, error) { - updateLog.Printf("Getting latest release for %s@%s (allowMajor=%v)", repo, currentVersion, allowMajor) - - // Extract base repository (e.g., "actions/cache/restore" -> "actions/cache") - baseRepo := gitutil.ExtractBaseRepo(repo) - updateLog.Printf("Using base repository: %s for action: %s", baseRepo, repo) - - // Use gh CLI to get releases - output, err := deps.runGHReleasesAPI(ctx, baseRepo) - if err != nil { - // Check if this is an authentication error - outputStr := string(output) - if gitutil.IsAuthError(outputStr) || gitutil.IsAuthError(err.Error()) { - updateLog.Printf("GitHub API authentication failed, attempting git ls-remote fallback for %s", repo) - // Try fallback using git ls-remote - latestRelease, latestSHA, gitErr := deps.getLatestReleaseViaGit(ctx, repo, currentVersion, allowMajor, verbose) - if gitErr != nil { - return "", "", fmt.Errorf("failed to fetch releases via GitHub API and git: API error: %w, Git Error: %w", err, gitErr) - } - return latestRelease, latestSHA, nil - } - // Include the gh output in the error for better diagnostics - if trimmed := strings.TrimSpace(outputStr); trimmed != "" { - return "", "", fmt.Errorf("failed to fetch releases: %w: %s", err, trimmed) - } - return "", "", fmt.Errorf("failed to fetch releases: %w", err) - } - - releases := strings.Split(strings.TrimSpace(string(output)), "\n") - if len(releases) == 0 || releases[0] == "" { - // No GitHub Releases found; fall back to tag scanning via git ls-remote. - // Some repositories publish tags without creating GitHub Releases — this is safe - // to use and the warning below is informational only. - updateLog.Printf("No releases found via GitHub API for %s, falling back to git ls-remote tag scan", baseRepo) - if verbose { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(baseRepo+": no GitHub Releases found, falling back to tag scanning (safe to ignore)")) - } - latestRelease, latestSHA, gitErr := deps.getLatestReleaseViaGit(ctx, repo, currentVersion, allowMajor, verbose) - if gitErr != nil { - return "", "", fmt.Errorf("no releases or tags found for %s: %w", baseRepo, gitErr) - } - return latestRelease, latestSHA, nil - } - - // Parse current version - currentVer := parseVersion(currentVersion) - - // Find all valid stable semantic version releases (skip prereleases such as v1.0.0-beta.1). - // Per semver rules, v1.1.0-beta.1 > v1.0.0, so without this filter a prerelease of a - // higher base version could be incorrectly selected as the upgrade target. - type releaseWithVersion struct { - tag string - version *semverutil.SemanticVersion - } - var validReleases []releaseWithVersion - for _, release := range releases { - releaseVer := parseVersion(release) - if releaseVer != nil && releaseVer.Pre == "" { - validReleases = append(validReleases, releaseWithVersion{ - tag: release, - version: releaseVer, - }) - } - } - - if len(validReleases) == 0 { - return "", "", errors.New("no valid semantic version releases found") - } - - // Sort releases by semver in descending order (highest first) - slices.SortFunc(validReleases, func(a, b releaseWithVersion) int { - switch { - case a.version.IsNewer(b.version): - return -1 - case b.version.IsNewer(a.version): - return 1 - default: - return 0 - } - }) - - // If current version is not valid, return the highest semver release - if currentVer == nil { - latestRelease := validReleases[0].tag - sha, err := deps.getActionSHAForTag(ctx, baseRepo, latestRelease) - if err != nil { - return "", "", fmt.Errorf("failed to get SHA for %s: %w", latestRelease, err) - } - return latestRelease, sha, nil - } - - // Find the highest compatible release (respecting major version if !allowMajor) - var latestCompatible string - var latestCompatibleVersion *semverutil.SemanticVersion - - for _, rel := range validReleases { - // Check if compatible based on major version - if !allowMajor && rel.version.Major != currentVer.Major { - continue - } - - // Since releases are sorted by semver descending, first match is highest - if latestCompatibleVersion == nil || rel.version.IsNewer(latestCompatibleVersion) { - latestCompatible = rel.tag - latestCompatibleVersion = rel.version - } else if !rel.version.IsNewer(latestCompatibleVersion) && - rel.version.Major == latestCompatibleVersion.Major && - rel.version.Minor == latestCompatibleVersion.Minor && - rel.version.Patch == latestCompatibleVersion.Patch { - // If versions are equal, prefer the less precise one (e.g., "v8" over "v8.0.0") - // This follows GitHub Actions convention of using major version tags - if !rel.version.IsPreciseVersion() && latestCompatibleVersion.IsPreciseVersion() { - latestCompatible = rel.tag - latestCompatibleVersion = rel.version - } - } - } - - if latestCompatible == "" { - return "", "", errors.New("no compatible release found") - } - - // Get the SHA for the latest compatible release - sha, err := deps.getActionSHAForTag(ctx, baseRepo, latestCompatible) - if err != nil { - return "", "", fmt.Errorf("failed to get SHA for %s: %w", latestCompatible, err) - } - - return latestCompatible, sha, nil -} - -// getLatestActionReleaseViaGit gets the latest release using git ls-remote (fallback) -func getLatestActionReleaseViaGit(ctx context.Context, repo, currentVersion string, allowMajor, verbose bool) (string, string, error) { - if verbose { - fmt.Fprintln(os.Stderr, console.FormatVerboseMessage(fmt.Sprintf("Fetching latest release for %s via git ls-remote (current: %s, allow major: %v)", repo, currentVersion, allowMajor))) - } - - // Extract base repository (e.g., "actions/cache/restore" -> "actions/cache") - baseRepo := gitutil.ExtractBaseRepo(repo) - updateLog.Printf("Using base repository: %s for action: %s (git fallback)", baseRepo, repo) - - githubHost := getGitHubHostForRepo(baseRepo) - repoURL := fmt.Sprintf("%s/%s.git", githubHost, baseRepo) - - // List all tags - // #nosec G204 -- repoURL is constructed from workflow configuration authored by the developer - cmd := exec.CommandContext(ctx, "git", "ls-remote", "--tags", repoURL) - output, err := cmd.Output() - if err != nil { - return "", "", fmt.Errorf("failed to fetch releases via git ls-remote: %w", err) - } - - releases, tagToSHA := parseActionTagRefs(string(output)) - - if len(releases) == 0 { - return "", "", errors.New("no releases found") - } - - // Parse current version - currentVer := parseVersion(currentVersion) - - // Find all valid stable semantic version releases (skip prereleases such as v1.0.0-beta.1). - // Per semver rules, v1.1.0-beta.1 > v1.0.0, so without this filter a prerelease of a - // higher base version could be incorrectly selected as the upgrade target. - // git ls-remote --tags returns every tag, so the prerelease check is especially important - // for this fallback path. - type releaseWithVersion struct { - tag string - version *semverutil.SemanticVersion - } - var validReleases []releaseWithVersion - for _, release := range releases { - releaseVer := parseVersion(release) - if releaseVer != nil && releaseVer.Pre == "" { - validReleases = append(validReleases, releaseWithVersion{ - tag: release, - version: releaseVer, - }) - } - } - - if len(validReleases) == 0 { - return "", "", errors.New("no valid semantic version releases found") - } - - // Sort releases by semver in descending order (highest first) - slices.SortFunc(validReleases, func(a, b releaseWithVersion) int { - switch { - case a.version.IsNewer(b.version): - return -1 - case b.version.IsNewer(a.version): - return 1 - default: - return 0 - } - }) - - // If current version is not valid, return the highest semver release - if currentVer == nil { - latestRelease := validReleases[0].tag - sha := tagToSHA[latestRelease] - if verbose { - fmt.Fprintln(os.Stderr, console.FormatVerboseMessage(fmt.Sprintf("Current version is not valid, using highest semver release: %s (via git)", latestRelease))) - } - return latestRelease, sha, nil - } - - // Find the highest compatible release (respecting major version if !allowMajor) - var latestCompatible string - var latestCompatibleVersion *semverutil.SemanticVersion - - for _, rel := range validReleases { - // Check if compatible based on major version - if !allowMajor && rel.version.Major != currentVer.Major { - continue - } - - // Since releases are sorted by semver descending, first match is highest - if latestCompatibleVersion == nil || rel.version.IsNewer(latestCompatibleVersion) { - latestCompatible = rel.tag - latestCompatibleVersion = rel.version - } else if !rel.version.IsNewer(latestCompatibleVersion) && - rel.version.Major == latestCompatibleVersion.Major && - rel.version.Minor == latestCompatibleVersion.Minor && - rel.version.Patch == latestCompatibleVersion.Patch { - // If versions are equal, prefer the less precise one (e.g., "v8" over "v8.0.0") - // This follows GitHub Actions convention of using major version tags - if !rel.version.IsPreciseVersion() && latestCompatibleVersion.IsPreciseVersion() { - latestCompatible = rel.tag - latestCompatibleVersion = rel.version - } - } - } - - if latestCompatible == "" { - return "", "", errors.New("no compatible release found") - } - - sha := tagToSHA[latestCompatible] - if verbose { - fmt.Fprintln(os.Stderr, console.FormatVerboseMessage(fmt.Sprintf("Latest compatible release: %s (via git)", latestCompatible))) - } - - return latestCompatible, sha, nil -} - -// parseActionTagRefs parses git ls-remote --tags output, preferring peeled commit -// SHAs over annotated tag-object SHAs while retaining lightweight tag SHAs. -func parseActionTagRefs(output string) ([]string, map[string]string) { - var releases []string - tagToSHA := make(map[string]string) - seenTags := make(map[string]struct{}) - - for line := range strings.SplitSeq(strings.TrimSpace(output), "\n") { - parts := strings.Fields(line) - if len(parts) < 2 || !strings.HasPrefix(parts[1], "refs/tags/") { - continue - } - - sha := parts[0] - tagRef := strings.TrimPrefix(parts[1], "refs/tags/") - peeled := strings.HasSuffix(tagRef, "^{}") - tag := strings.TrimSuffix(tagRef, "^{}") - - if _, seen := seenTags[tag]; !seen { - releases = append(releases, tag) - seenTags[tag] = struct{}{} - } - if peeled { - tagToSHA[tag] = sha - } else if _, exists := tagToSHA[tag]; !exists { - tagToSHA[tag] = sha - } - } - - return releases, tagToSHA -} - -// findCooledDownActionVersion searches for the newest release that is strictly -// newer than currentVersion but has passed the cooldown period. It is used as -// a fallback when the highest candidate is still in cooldown: rather than -// skipping the update entirely, we walk down the release list toward older -// (but still upgrading) versions until one has cooled down. -// -// Returns ("", "", nil) when no suitable release is found (fail-open). -func findCooledDownActionVersion( - ctx context.Context, - deps actionUpdateDeps, - repo, currentVersion string, - allowMajor, verbose bool, - coolDown time.Duration, - skipTag string, -) (string, string, error) { - baseRepo := gitutil.ExtractBaseRepo(repo) - - output, err := deps.runGHReleasesAPI(ctx, baseRepo) - if err != nil { - updateLog.Printf("findCooledDownActionVersion: failed to fetch releases for %s: %v", repo, err) - return "", "", nil // fail-open - } - - releases := strings.Split(strings.TrimSpace(string(output)), "\n") - - currentVer := parseVersion(currentVersion) - - compatibleReleases := sortedCompatibleReleaseCandidates(releases, currentVer, allowMajor) - candidates := newerReleaseCandidates(compatibleReleases, currentVer) - - for _, c := range candidates { - if skipTag != "" && c.tag == skipTag { - continue - } - result := deps.checkCoolDown(ctx, repo, c.tag, coolDown) - if result.InCoolDown { - cooldownLog.Printf("Action fallback %s@%s: %s", repo, c.tag, result.Message) - if verbose { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Skipping release candidate %s@%s: %s", repo, c.tag, result.Message))) - } - continue - } - sha, err := deps.getActionSHAForTag(ctx, baseRepo, c.tag) - if err != nil { - updateLog.Printf("findCooledDownActionVersion: failed to get SHA for %s@%s: %v", repo, c.tag, err) - continue // try next candidate - } - if sha == "" { - updateLog.Printf("findCooledDownActionVersion: empty SHA returned for %s@%s; skipping", repo, c.tag) - continue // skip; never store an entry without a SHA - } - return c.tag, sha, nil - } - - return "", "", nil -} - -// getActionSHAForTag gets the commit SHA for a given tag in an action repository. -// For annotated tags (and chained tag objects), it iteratively peels until it -// reaches the underlying non-tag object SHA, matching what tools like Renovate expect. -func getActionSHAForTag(ctx context.Context, repo, tag string) (string, error) { - updateLog.Printf("Getting SHA for %s@%s", repo, tag) - - // Fetch both SHA and object type to detect annotated tags. - // Annotated tags have type "tag" and their SHA points to the tag object, - // not the underlying commit. We must peel to get the commit SHA. - output, err := workflow.RunGHContext(ctx, "Fetching tag info...", "api", fmt.Sprintf("/repos/%s/git/ref/tags/%s", repo, tag), "--jq", "[.object.sha, .object.type] | @tsv") - if err != nil { - return "", fmt.Errorf("failed to resolve tag: %w", err) - } - - sha, objType, err := workflow.ParseTagRefTSV(string(output)) - if err != nil { - return "", fmt.Errorf("failed to parse API response for %s@%s: %w", repo, tag, err) - } - - // Annotated tags (and chained tag objects) point to a tag object rather than - // directly to a commit. Iteratively peel until we reach a non-tag object so - // that emitted action pins use the stable underlying commit SHA rather than a - // mutable tag object SHA (which changes when the tag is re-created). - const maxTagPeelDepth = 10 - for depth := 0; objType == "tag"; depth++ { - if depth >= maxTagPeelDepth { - return "", fmt.Errorf("failed to peel annotated tag: exceeded max depth %d for %s@%s", maxTagPeelDepth, repo, tag) - } - updateLog.Printf("Detected annotated tag for %s@%s (depth %d, tag object SHA: %s), peeling to underlying object", repo, tag, depth, sha) - output2, err := workflow.RunGHContext(ctx, "Peeling annotated tag...", "api", fmt.Sprintf("/repos/%s/git/tags/%s", repo, sha), "--jq", "[.object.sha, .object.type] | @tsv") - if err != nil { - return "", fmt.Errorf("failed to peel annotated tag: %w", err) - } - sha, objType, err = workflow.ParseTagRefTSV(string(output2)) - if err != nil { - return "", fmt.Errorf("failed to parse peeled tag API response for %s@%s: %w", repo, tag, err) - } - } - updateLog.Printf("Resolved %s@%s to %s SHA: %s", repo, tag, objType, sha) - - return sha, nil -} - -// 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+)([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*)$`) - -// latestReleaseResult caches a resolved version/SHA pair. -type latestReleaseResult struct { - version string - sha string -} - -// UpdateActionsInWorkflowFiles scans all workflow .md files under workflowsDir -// (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(ctx context.Context, workflowsDir, engineOverride string, verbose, disableReleaseBump bool, noCompile bool, coolDown time.Duration, approve bool) error { - return updateActionsInWorkflowFiles(ctx, defaultActionUpdateDeps(), updateActionsOptions{ - workflowsDir: workflowsDir, - engineOverride: engineOverride, - verbose: verbose, - disableReleaseBump: disableReleaseBump, - noCompile: noCompile, - coolDown: coolDown, - approve: approve, - }) -} - -// updateActionsOptions bundles the configuration parameters for updateActionsInWorkflowFiles, -// collapsing a long positional parameter list into a struct. -// engineOverride sets a non-default agentic engine for recompiled workflows. -// disableReleaseBump prevents upgrading action/skill references to newer releases. -// noCompile skips recompilation of updated workflow files. -// coolDown is the minimum age a release must have before it is considered for upgrade. -// approve auto-approves any interactive prompts during recompilation. -type updateActionsOptions struct { - workflowsDir string - engineOverride string - verbose bool - disableReleaseBump bool - noCompile bool - coolDown time.Duration - approve bool -} - -func updateActionsInWorkflowFiles(ctx context.Context, deps actionUpdateDeps, opts updateActionsOptions) error { - if opts.workflowsDir == "" { - opts.workflowsDir = getWorkflowsDir() - } - - updateLog.Printf("Updating action references in workflow files: dir=%s", opts.workflowsDir) - - // Per-invocation cache: key = "repo@currentVersion", avoids repeated API calls - cache := make(map[string]latestReleaseResult) - // Per-invocation cooldown cache: key = "repo@tag", avoids redundant date API calls - coolDownCache := make(map[string]coolDownCheckResult) - - var updatedFiles []string - - err := filepath.WalkDir(opts.workflowsDir, func(path string, d os.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - if ctx.Err() != nil { - return ctx.Err() - } - if d.IsDir() || !strings.HasSuffix(d.Name(), ".md") { - return nil - } - - content, err := os.ReadFile(path) - if err != nil { - if opts.verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to read %s: %v", path, err))) - } - return nil - } - - updatedActions, newContent, err := updateActionRefsInContentWithDeps(ctx, deps, string(content), cache, coolDownCache, !opts.disableReleaseBump, opts.verbose, opts.coolDown) - if err != nil { - if opts.verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to update action refs in %s: %v", path, err))) - } - return nil - } - updatedSkills, newContent, err := updateSkillRefsInContent(ctx, newContent, !opts.disableReleaseBump, opts.verbose, opts.coolDown) - if err != nil { - if opts.verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to update skill refs in %s: %v", path, err))) - } - return nil - } - - if !updatedActions && !updatedSkills { - return nil - } - - if err := os.WriteFile(path, []byte(newContent), constants.FilePermPublic); err != nil { - return fmt.Errorf("failed to write updated workflow %s: %w", path, err) - } - - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Updated action/skill references in "+d.Name())) - updatedFiles = append(updatedFiles, path) - return nil - }) - if err != nil { - return fmt.Errorf("failed to walk workflows directory: %w", err) - } - - if len(updatedFiles) > 0 && !opts.noCompile { - if err := compileWorkflowsForUpdate(ctx, updatedFiles, opts.workflowsDir, opts.engineOverride, opts.verbose, opts.approve); err != nil { - return fmt.Errorf("failed to compile workflows with updated action references: %w", err) - } - } - - if len(updatedFiles) == 0 && opts.verbose { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No action references needed updating in workflow files")) - } - - return nil -} - -type skillRefUpdateResolver func(ctx context.Context, repo, currentRef string, allowMajor, verbose bool, coolDown time.Duration) (string, error) - -func updateSkillRefsInContent(ctx context.Context, content string, allowMajor, verbose bool, coolDown time.Duration) (bool, string, error) { - return updateSkillRefsInContentWithResolver(ctx, content, allowMajor, verbose, coolDown, resolveLatestRef) -} - -func updateSkillRefsInContentWithResolver( - ctx context.Context, - content string, - allowMajor, verbose bool, - coolDown time.Duration, - resolver skillRefUpdateResolver, -) (bool, string, error) { - result, err := parser.ExtractFrontmatterFromContent(content) - if err != nil { - if verbose { - updateLog.Printf("Skipping skill update for content without parseable frontmatter: %v", err) - } - return false, content, nil - } - if result == nil || result.Frontmatter == nil { - return false, content, nil - } - - rawSkills, ok := result.Frontmatter["skills"].([]any) - if !ok || len(rawSkills) == 0 { - return false, content, nil - } - - changed := false - for i, rawSkill := range rawSkills { - switch typed := rawSkill.(type) { - case string: - updated, updatedRef, err := updateSkillRefValue(ctx, typed, allowMajor, verbose, coolDown, resolver) - if err != nil { - return false, content, err - } - if updated { - rawSkills[i] = updatedRef - changed = true - } - case map[string]any: - skillRef, ok := typed["skill"].(string) - if !ok { - continue - } - updated, updatedRef, err := updateSkillRefValue(ctx, skillRef, allowMajor, verbose, coolDown, resolver) - if err != nil { - return false, content, err - } - if updated { - typed["skill"] = updatedRef - changed = true - } - } - } - if !changed { - return false, content, nil - } - result.Frontmatter["skills"] = rawSkills - - updatedFrontmatter, err := yaml.Marshal(result.Frontmatter) - if err != nil { - return false, content, fmt.Errorf("failed to marshal updated frontmatter: %w", err) - } - updatedContent, err := parser.ReconstructWorkflowFile(parser.QuoteCronExpressions(string(updatedFrontmatter)), result.Markdown) - if err != nil { - return false, content, fmt.Errorf("failed to reconstruct workflow file: %w", err) - } - return true, updatedContent, nil -} - -func updateSkillRefValue( - ctx context.Context, - skillRef string, - allowMajor, verbose bool, - coolDown time.Duration, - resolver skillRefUpdateResolver, -) (bool, string, error) { - trimmedSkillRef := strings.TrimSpace(skillRef) - if trimmedSkillRef == "" || strings.Contains(trimmedSkillRef, "${{") { - return false, skillRef, nil - } - spec, currentRef, ok := strings.Cut(trimmedSkillRef, "@") - spec = strings.TrimSpace(spec) - currentRef = strings.TrimSpace(currentRef) - if !ok || spec == "" || currentRef == "" { - return false, skillRef, nil - } - - repo := gitutil.ExtractBaseRepo(spec) - if repo == "" { - return false, skillRef, nil - } - latestRef, err := resolver(ctx, repo, currentRef, allowMajor, verbose, coolDown) - if err != nil { - if verbose { - updateLog.Printf("Skipping skill update for %s@%s: %v", spec, currentRef, err) - } - return false, skillRef, nil - } - if latestRef == "" || latestRef == currentRef { - return false, skillRef, nil - } - return true, spec + "@" + latestRef, nil -} - -func updateActionRefsInContentWithDeps(ctx context.Context, deps actionUpdateDeps, content string, cache map[string]latestReleaseResult, coolDownCache map[string]coolDownCheckResult, allowMajor, verbose bool, coolDown time.Duration) (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]] - } - - // 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 the latest-release resolver. - 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), "#")) - if commentVersion != "" { - currentVersion = commentVersion - } else { - currentVersion = "" - } - } else { - currentVersion = "" - } - } - - // Resolve latest version/SHA, using the cache to avoid redundant API calls. - // Use "|" as separator since GitHub repo names cannot contain "|". - cacheKey := repo + "|" + currentVersion - result, cached := cache[cacheKey] - if !cached { - latestVersion, latestSHA, err := deps.getLatestRelease(ctx, repo, currentVersion, effectiveAllowMajor, verbose) - if err != nil { - updateLog.Printf("Failed to get latest release for %s: %v", repo, err) - continue - } - result = latestReleaseResult{version: latestVersion, sha: latestSHA} - cache[cacheKey] = result - } - latestVersion := result.version - latestSHA := result.sha - - if isSHA { - if latestSHA == ref { - continue // SHA unchanged - } - } else { - if latestVersion == ref { - continue // Version tag unchanged - } - // Prevent downgrades: if the proposed version is older than the current, skip. - currentVer := parseVersion(ref) - proposedVer := parseVersion(latestVersion) - if currentVer != nil && proposedVer != nil && currentVer.IsNewer(proposedVer) { - updateLog.Printf("Skipping %s in workflow file: proposed version %s is older than current %s (would be a downgrade)", repo, latestVersion, ref) - continue - } - } - - // Apply cooldown: if the repo is not exempt and the release is too recent, try - // progressively older releases (still newer than current) until finding one that - // has passed the cooldown period. - if !isExemptFromCoolDown(repo) { - coolDownKey := repo + "@" + latestVersion - coolDownResult, coolDownCached := coolDownCache[coolDownKey] - if !coolDownCached { - coolDownResult = deps.checkCoolDown(ctx, repo, latestVersion, coolDown) - coolDownCache[coolDownKey] = coolDownResult - } - if coolDownResult.InCoolDown { - cooldownLog.Printf("Action ref %s in workflow: %s", repo, coolDownResult.Message) - - // Try to find an older release that has passed the cooldown period. - olderVersion, olderSHA, findErr := findCooledDownActionVersion(ctx, deps, repo, currentVersion, effectiveAllowMajor, verbose, coolDown, latestVersion) - if findErr != nil || olderVersion == "" || olderSHA == "" { - if verbose { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Skipping release candidate %s@%s: %s", repo, latestVersion, coolDownResult.Message))) - } - continue - } - if verbose { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Falling back to %s for %s (latest release candidate is still in cooldown)", olderVersion, repo))) - } - // Use the older, cooled-down release and update the per-invocation cache. - result = latestReleaseResult{version: olderVersion, sha: olderSHA} - cache[cacheKey] = result - latestVersion = olderVersion - latestSHA = olderSHA - } - } - - // 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 -} diff --git a/pkg/cli/update_actions_deps.go b/pkg/cli/update_actions_deps.go new file mode 100644 index 00000000000..1ed54b126e9 --- /dev/null +++ b/pkg/cli/update_actions_deps.go @@ -0,0 +1,126 @@ +package cli + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/github/gh-aw/pkg/workflow" +) + +type actionUpdateDeps struct { + getLatestRelease func(ctx context.Context, repo, currentVersion string, allowMajor, verbose bool) (string, string, error) + getLatestReleaseViaGit func(ctx context.Context, repo, currentVersion string, allowMajor, verbose bool) (string, string, error) + runGHReleasesAPI func(ctx context.Context, baseRepo string) ([]byte, error) + getActionSHAForTag func(ctx context.Context, repo, tag string) (string, error) + checkCoolDown func(ctx context.Context, repo, tag string, coolDown time.Duration) coolDownCheckResult +} + +type cachedLatestRelease struct { + version string + sha string + err error +} + +type cachedSHA struct { + sha string + err error +} + +// newCachedActionUpdateDeps memoizes GitHub reads for the full update command. +// This cache is shared by actions-lock.json updates and Markdown action refs. +func newCachedActionUpdateDeps(base actionUpdateDeps) actionUpdateDeps { + var mu sync.Mutex + latestReleases := make(map[string]cachedLatestRelease) + releaseLists := make(map[string]struct { + output []byte + err error + }) + shas := make(map[string]cachedSHA) + cooldowns := make(map[string]coolDownCheckResult) + + cached := base + cached.getLatestRelease = func(ctx context.Context, repo, currentVersion string, allowMajor, verbose bool) (string, string, error) { + key := fmt.Sprintf("%s|%s|%t", repo, currentVersion, allowMajor) + mu.Lock() + result, ok := latestReleases[key] + mu.Unlock() + if ok { + return result.version, result.sha, result.err + } + version, sha, err := base.getLatestRelease(ctx, repo, currentVersion, allowMajor, verbose) + mu.Lock() + latestReleases[key] = cachedLatestRelease{version: version, sha: sha, err: err} + mu.Unlock() + return version, sha, err + } + cached.runGHReleasesAPI = func(ctx context.Context, repo string) ([]byte, error) { + mu.Lock() + result, ok := releaseLists[repo] + mu.Unlock() + if ok { + return result.output, result.err + } + output, err := base.runGHReleasesAPI(ctx, repo) + mu.Lock() + releaseLists[repo] = struct { + output []byte + err error + }{output: output, err: err} + mu.Unlock() + return output, err + } + cached.getActionSHAForTag = func(ctx context.Context, repo, tag string) (string, error) { + key := repo + "|" + tag + mu.Lock() + result, ok := shas[key] + mu.Unlock() + if ok { + return result.sha, result.err + } + sha, err := base.getActionSHAForTag(ctx, repo, tag) + mu.Lock() + shas[key] = cachedSHA{sha: sha, err: err} + mu.Unlock() + return sha, err + } + cached.checkCoolDown = func(ctx context.Context, repo, tag string, coolDown time.Duration) coolDownCheckResult { + key := fmt.Sprintf("%s|%s|%s", repo, tag, coolDown) + mu.Lock() + result, ok := cooldowns[key] + mu.Unlock() + if ok { + return result + } + result = base.checkCoolDown(ctx, repo, tag, coolDown) + mu.Lock() + cooldowns[key] = result + mu.Unlock() + return result + } + return cached +} + +func defaultActionUpdateDeps() actionUpdateDeps { + return actionUpdateDeps{ + getLatestRelease: getLatestActionRelease, + getLatestReleaseViaGit: getLatestActionReleaseViaGit, + checkCoolDown: checkReleaseCoolDown, + runGHReleasesAPI: func(ctx context.Context, baseRepo string) ([]byte, error) { + return workflow.RunGHCombinedContext(ctx, "Fetching releases...", "api", "--paginate", fmt.Sprintf("/repos/%s/releases", baseRepo), "--jq", ".[].tag_name") + }, + getActionSHAForTag: getActionSHAForTag, + } +} + +// 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. +// 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. +// +// coolDown specifies the minimum age a release must have before it is applied. Repos under the +// "actions/" and "github/" namespaces are always exempt from the cooldown. +// +// The ActionCache helpers from pkg/workflow are used so that cached inputs and descriptions +// for safe-outputs.actions entries are preserved when their SHA is unchanged, and cleared diff --git a/pkg/cli/update_actions_release.go b/pkg/cli/update_actions_release.go new file mode 100644 index 00000000000..4ac31a6b68f --- /dev/null +++ b/pkg/cli/update_actions_release.go @@ -0,0 +1,413 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "slices" + "strings" + "time" + + "github.com/github/gh-aw/pkg/console" + "github.com/github/gh-aw/pkg/gitutil" + "github.com/github/gh-aw/pkg/semverutil" + "github.com/github/gh-aw/pkg/workflow" +) + +// 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 { + return strings.HasPrefix(repo, "actions/") +} + +// isGhAwNativeAction returns true if the action repo is part of the gh-aw native ecosystem +// (i.e., maintained in the github/gh-aw or github/gh-aw-actions repository). These actions +// are versioned in lock-step with the CLI and must never be updated beyond the running CLI version. +func isGhAwNativeAction(repo string) bool { + base := gitutil.ExtractBaseRepo(repo) + return base == "github/gh-aw" || base == "github/gh-aw-actions" +} + +func getLatestActionRelease(ctx context.Context, repo, currentVersion string, allowMajor, verbose bool) (string, string, error) { + return getLatestActionReleaseWithDeps(ctx, defaultActionUpdateDeps(), repo, currentVersion, allowMajor, verbose) +} + +func getLatestActionReleaseWithDeps(ctx context.Context, deps actionUpdateDeps, repo, currentVersion string, allowMajor, verbose bool) (string, string, error) { + updateLog.Printf("Getting latest release for %s@%s (allowMajor=%v)", repo, currentVersion, allowMajor) + + // Extract base repository (e.g., "actions/cache/restore" -> "actions/cache") + baseRepo := gitutil.ExtractBaseRepo(repo) + updateLog.Printf("Using base repository: %s for action: %s", baseRepo, repo) + + // Use gh CLI to get releases + output, err := deps.runGHReleasesAPI(ctx, baseRepo) + if err != nil { + // Check if this is an authentication error + outputStr := string(output) + if gitutil.IsAuthError(outputStr) || gitutil.IsAuthError(err.Error()) { + updateLog.Printf("GitHub API authentication failed, attempting git ls-remote fallback for %s", repo) + // Try fallback using git ls-remote + latestRelease, latestSHA, gitErr := deps.getLatestReleaseViaGit(ctx, repo, currentVersion, allowMajor, verbose) + if gitErr != nil { + return "", "", fmt.Errorf("failed to fetch releases via GitHub API and git: API error: %w, Git Error: %w", err, gitErr) + } + return latestRelease, latestSHA, nil + } + // Include the gh output in the error for better diagnostics + if trimmed := strings.TrimSpace(outputStr); trimmed != "" { + return "", "", fmt.Errorf("failed to fetch releases: %w: %s", err, trimmed) + } + return "", "", fmt.Errorf("failed to fetch releases: %w", err) + } + + releases := strings.Split(strings.TrimSpace(string(output)), "\n") + if len(releases) == 0 || releases[0] == "" { + // No GitHub Releases found; fall back to tag scanning via git ls-remote. + // Some repositories publish tags without creating GitHub Releases — this is safe + // to use and the warning below is informational only. + updateLog.Printf("No releases found via GitHub API for %s, falling back to git ls-remote tag scan", baseRepo) + if verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(baseRepo+": no GitHub Releases found, falling back to tag scanning (safe to ignore)")) + } + latestRelease, latestSHA, gitErr := deps.getLatestReleaseViaGit(ctx, repo, currentVersion, allowMajor, verbose) + if gitErr != nil { + return "", "", fmt.Errorf("no releases or tags found for %s: %w", baseRepo, gitErr) + } + return latestRelease, latestSHA, nil + } + + // Parse current version + currentVer := parseVersion(currentVersion) + + // Find all valid stable semantic version releases (skip prereleases such as v1.0.0-beta.1). + // Per semver rules, v1.1.0-beta.1 > v1.0.0, so without this filter a prerelease of a + // higher base version could be incorrectly selected as the upgrade target. + type releaseWithVersion struct { + tag string + version *semverutil.SemanticVersion + } + var validReleases []releaseWithVersion + for _, release := range releases { + releaseVer := parseVersion(release) + if releaseVer != nil && releaseVer.Pre == "" { + validReleases = append(validReleases, releaseWithVersion{ + tag: release, + version: releaseVer, + }) + } + } + + if len(validReleases) == 0 { + return "", "", errors.New("no valid semantic version releases found") + } + + // Sort releases by semver in descending order (highest first) + slices.SortFunc(validReleases, func(a, b releaseWithVersion) int { + switch { + case a.version.IsNewer(b.version): + return -1 + case b.version.IsNewer(a.version): + return 1 + default: + return 0 + } + }) + + // If current version is not valid, return the highest semver release + if currentVer == nil { + latestRelease := validReleases[0].tag + sha, err := deps.getActionSHAForTag(ctx, baseRepo, latestRelease) + if err != nil { + return "", "", fmt.Errorf("failed to get SHA for %s: %w", latestRelease, err) + } + return latestRelease, sha, nil + } + + // Find the highest compatible release (respecting major version if !allowMajor) + var latestCompatible string + var latestCompatibleVersion *semverutil.SemanticVersion + + for _, rel := range validReleases { + // Check if compatible based on major version + if !allowMajor && rel.version.Major != currentVer.Major { + continue + } + + // Since releases are sorted by semver descending, first match is highest + if latestCompatibleVersion == nil || rel.version.IsNewer(latestCompatibleVersion) { + latestCompatible = rel.tag + latestCompatibleVersion = rel.version + } else if !rel.version.IsNewer(latestCompatibleVersion) && + rel.version.Major == latestCompatibleVersion.Major && + rel.version.Minor == latestCompatibleVersion.Minor && + rel.version.Patch == latestCompatibleVersion.Patch { + // If versions are equal, prefer the less precise one (e.g., "v8" over "v8.0.0") + // This follows GitHub Actions convention of using major version tags + if !rel.version.IsPreciseVersion() && latestCompatibleVersion.IsPreciseVersion() { + latestCompatible = rel.tag + latestCompatibleVersion = rel.version + } + } + } + + if latestCompatible == "" { + return "", "", errors.New("no compatible release found") + } + + // Get the SHA for the latest compatible release + sha, err := deps.getActionSHAForTag(ctx, baseRepo, latestCompatible) + if err != nil { + return "", "", fmt.Errorf("failed to get SHA for %s: %w", latestCompatible, err) + } + + return latestCompatible, sha, nil +} + +// getLatestActionReleaseViaGit gets the latest release using git ls-remote (fallback) +func getLatestActionReleaseViaGit(ctx context.Context, repo, currentVersion string, allowMajor, verbose bool) (string, string, error) { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatVerboseMessage(fmt.Sprintf("Fetching latest release for %s via git ls-remote (current: %s, allow major: %v)", repo, currentVersion, allowMajor))) + } + + // Extract base repository (e.g., "actions/cache/restore" -> "actions/cache") + baseRepo := gitutil.ExtractBaseRepo(repo) + updateLog.Printf("Using base repository: %s for action: %s (git fallback)", baseRepo, repo) + + githubHost := getGitHubHostForRepo(baseRepo) + repoURL := fmt.Sprintf("%s/%s.git", githubHost, baseRepo) + + // List all tags + // #nosec G204 -- repoURL is constructed from workflow configuration authored by the developer + cmd := exec.CommandContext(ctx, "git", "ls-remote", "--tags", repoURL) + output, err := cmd.Output() + if err != nil { + return "", "", fmt.Errorf("failed to fetch releases via git ls-remote: %w", err) + } + + releases, tagToSHA := parseActionTagRefs(string(output)) + + if len(releases) == 0 { + return "", "", errors.New("no releases found") + } + + // Parse current version + currentVer := parseVersion(currentVersion) + + // Find all valid stable semantic version releases (skip prereleases such as v1.0.0-beta.1). + // Per semver rules, v1.1.0-beta.1 > v1.0.0, so without this filter a prerelease of a + // higher base version could be incorrectly selected as the upgrade target. + // git ls-remote --tags returns every tag, so the prerelease check is especially important + // for this fallback path. + type releaseWithVersion struct { + tag string + version *semverutil.SemanticVersion + } + var validReleases []releaseWithVersion + for _, release := range releases { + releaseVer := parseVersion(release) + if releaseVer != nil && releaseVer.Pre == "" { + validReleases = append(validReleases, releaseWithVersion{ + tag: release, + version: releaseVer, + }) + } + } + + if len(validReleases) == 0 { + return "", "", errors.New("no valid semantic version releases found") + } + + // Sort releases by semver in descending order (highest first) + slices.SortFunc(validReleases, func(a, b releaseWithVersion) int { + switch { + case a.version.IsNewer(b.version): + return -1 + case b.version.IsNewer(a.version): + return 1 + default: + return 0 + } + }) + + // If current version is not valid, return the highest semver release + if currentVer == nil { + latestRelease := validReleases[0].tag + sha := tagToSHA[latestRelease] + if verbose { + fmt.Fprintln(os.Stderr, console.FormatVerboseMessage(fmt.Sprintf("Current version is not valid, using highest semver release: %s (via git)", latestRelease))) + } + return latestRelease, sha, nil + } + + // Find the highest compatible release (respecting major version if !allowMajor) + var latestCompatible string + var latestCompatibleVersion *semverutil.SemanticVersion + + for _, rel := range validReleases { + // Check if compatible based on major version + if !allowMajor && rel.version.Major != currentVer.Major { + continue + } + + // Since releases are sorted by semver descending, first match is highest + if latestCompatibleVersion == nil || rel.version.IsNewer(latestCompatibleVersion) { + latestCompatible = rel.tag + latestCompatibleVersion = rel.version + } else if !rel.version.IsNewer(latestCompatibleVersion) && + rel.version.Major == latestCompatibleVersion.Major && + rel.version.Minor == latestCompatibleVersion.Minor && + rel.version.Patch == latestCompatibleVersion.Patch { + // If versions are equal, prefer the less precise one (e.g., "v8" over "v8.0.0") + // This follows GitHub Actions convention of using major version tags + if !rel.version.IsPreciseVersion() && latestCompatibleVersion.IsPreciseVersion() { + latestCompatible = rel.tag + latestCompatibleVersion = rel.version + } + } + } + + if latestCompatible == "" { + return "", "", errors.New("no compatible release found") + } + + sha := tagToSHA[latestCompatible] + if verbose { + fmt.Fprintln(os.Stderr, console.FormatVerboseMessage(fmt.Sprintf("Latest compatible release: %s (via git)", latestCompatible))) + } + + return latestCompatible, sha, nil +} + +// parseActionTagRefs parses git ls-remote --tags output, preferring peeled commit +// SHAs over annotated tag-object SHAs while retaining lightweight tag SHAs. +func parseActionTagRefs(output string) ([]string, map[string]string) { + var releases []string + tagToSHA := make(map[string]string) + seenTags := make(map[string]struct{}) + + for line := range strings.SplitSeq(strings.TrimSpace(output), "\n") { + parts := strings.Fields(line) + if len(parts) < 2 || !strings.HasPrefix(parts[1], "refs/tags/") { + continue + } + + sha := parts[0] + tagRef := strings.TrimPrefix(parts[1], "refs/tags/") + peeled := strings.HasSuffix(tagRef, "^{}") + tag := strings.TrimSuffix(tagRef, "^{}") + + if _, seen := seenTags[tag]; !seen { + releases = append(releases, tag) + seenTags[tag] = struct{}{} + } + if peeled { + tagToSHA[tag] = sha + } else if _, exists := tagToSHA[tag]; !exists { + tagToSHA[tag] = sha + } + } + + return releases, tagToSHA +} + +// findCooledDownActionVersion searches for the newest release that is strictly +// newer than currentVersion but has passed the cooldown period. It is used as +// a fallback when the highest candidate is still in cooldown: rather than +// skipping the update entirely, we walk down the release list toward older +// (but still upgrading) versions until one has cooled down. +// +// Returns ("", "", nil) when no suitable release is found (fail-open). +func findCooledDownActionVersion( + ctx context.Context, + deps actionUpdateDeps, + repo, currentVersion string, + allowMajor, verbose bool, + coolDown time.Duration, + skipTag string, +) (string, string, error) { + baseRepo := gitutil.ExtractBaseRepo(repo) + + output, err := deps.runGHReleasesAPI(ctx, baseRepo) + if err != nil { + updateLog.Printf("findCooledDownActionVersion: failed to fetch releases for %s: %v", repo, err) + return "", "", nil // fail-open + } + + releases := strings.Split(strings.TrimSpace(string(output)), "\n") + + currentVer := parseVersion(currentVersion) + + compatibleReleases := sortedCompatibleReleaseCandidates(releases, currentVer, allowMajor) + candidates := newerReleaseCandidates(compatibleReleases, currentVer) + + for _, c := range candidates { + if skipTag != "" && c.tag == skipTag { + continue + } + result := deps.checkCoolDown(ctx, repo, c.tag, coolDown) + if result.InCoolDown { + cooldownLog.Printf("Action fallback %s@%s: %s", repo, c.tag, result.Message) + if verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Skipping release candidate %s@%s: %s", repo, c.tag, result.Message))) + } + continue + } + sha, err := deps.getActionSHAForTag(ctx, baseRepo, c.tag) + if err != nil { + updateLog.Printf("findCooledDownActionVersion: failed to get SHA for %s@%s: %v", repo, c.tag, err) + continue // try next candidate + } + if sha == "" { + updateLog.Printf("findCooledDownActionVersion: empty SHA returned for %s@%s; skipping", repo, c.tag) + continue // skip; never store an entry without a SHA + } + return c.tag, sha, nil + } + + return "", "", nil +} + +// getActionSHAForTag gets the commit SHA for a given tag in an action repository. +// For annotated tags (and chained tag objects), it iteratively peels until it +// reaches the underlying non-tag object SHA, matching what tools like Renovate expect. +func getActionSHAForTag(ctx context.Context, repo, tag string) (string, error) { + updateLog.Printf("Getting SHA for %s@%s", repo, tag) + + // Fetch both SHA and object type to detect annotated tags. + // Annotated tags have type "tag" and their SHA points to the tag object, + // not the underlying commit. We must peel to get the commit SHA. + output, err := workflow.RunGHContext(ctx, "Fetching tag info...", "api", fmt.Sprintf("/repos/%s/git/ref/tags/%s", repo, tag), "--jq", "[.object.sha, .object.type] | @tsv") + if err != nil { + return "", fmt.Errorf("failed to resolve tag: %w", err) + } + + sha, objType, err := workflow.ParseTagRefTSV(string(output)) + if err != nil { + return "", fmt.Errorf("failed to parse API response for %s@%s: %w", repo, tag, err) + } + + // Annotated tags (and chained tag objects) point to a tag object rather than + // directly to a commit. Iteratively peel until we reach a non-tag object so + // that emitted action pins use the stable underlying commit SHA rather than a + // mutable tag object SHA (which changes when the tag is re-created). + const maxTagPeelDepth = 10 + for depth := 0; objType == "tag"; depth++ { + if depth >= maxTagPeelDepth { + return "", fmt.Errorf("failed to peel annotated tag: exceeded max depth %d for %s@%s", maxTagPeelDepth, repo, tag) + } + updateLog.Printf("Detected annotated tag for %s@%s (depth %d, tag object SHA: %s), peeling to underlying object", repo, tag, depth, sha) + output2, err := workflow.RunGHContext(ctx, "Peeling annotated tag...", "api", fmt.Sprintf("/repos/%s/git/tags/%s", repo, sha), "--jq", "[.object.sha, .object.type] | @tsv") + if err != nil { + return "", fmt.Errorf("failed to peel annotated tag: %w", err) + } + sha, objType, err = workflow.ParseTagRefTSV(string(output2)) + if err != nil { + return "", fmt.Errorf("failed to parse peeled tag API response for %s@%s: %w", repo, tag, err) + } + } + updateLog.Printf("Resolved %s@%s to %s SHA: %s", repo, tag, objType, sha) + + return sha, nil +} diff --git a/pkg/cli/update_actions_workflow_refs.go b/pkg/cli/update_actions_workflow_refs.go new file mode 100644 index 00000000000..15ec4813a19 --- /dev/null +++ b/pkg/cli/update_actions_workflow_refs.go @@ -0,0 +1,379 @@ +package cli + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/goccy/go-yaml" + + "github.com/github/gh-aw/pkg/console" + "github.com/github/gh-aw/pkg/constants" + "github.com/github/gh-aw/pkg/gitutil" + "github.com/github/gh-aw/pkg/parser" +) + +// 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+)([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*)$`) + +// latestReleaseResult caches a resolved version/SHA pair. +type latestReleaseResult struct { + version string + sha string +} + +// UpdateActionsInWorkflowFiles scans all workflow .md files under workflowsDir +// (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(ctx context.Context, workflowsDir, engineOverride string, verbose, disableReleaseBump bool, noCompile bool, coolDown time.Duration, approve bool) error { + return updateActionsInWorkflowFiles(ctx, defaultActionUpdateDeps(), updateActionsOptions{ + workflowsDir: workflowsDir, + engineOverride: engineOverride, + verbose: verbose, + disableReleaseBump: disableReleaseBump, + noCompile: noCompile, + coolDown: coolDown, + approve: approve, + }) +} + +// updateActionsOptions bundles the configuration parameters for updateActionsInWorkflowFiles, +// collapsing a long positional parameter list into a struct. +// engineOverride sets a non-default agentic engine for recompiled workflows. +// disableReleaseBump prevents upgrading action/skill references to newer releases. +// noCompile skips recompilation of updated workflow files. +// coolDown is the minimum age a release must have before it is considered for upgrade. +// approve auto-approves any interactive prompts during recompilation. +type updateActionsOptions struct { + workflowsDir string + engineOverride string + verbose bool + disableReleaseBump bool + noCompile bool + coolDown time.Duration + approve bool +} + +func updateActionsInWorkflowFiles(ctx context.Context, deps actionUpdateDeps, opts updateActionsOptions) error { + if opts.workflowsDir == "" { + opts.workflowsDir = getWorkflowsDir() + } + + updateLog.Printf("Updating action references in workflow files: dir=%s", opts.workflowsDir) + + // Per-invocation cache: key = "repo@currentVersion", avoids repeated API calls + cache := make(map[string]latestReleaseResult) + // Per-invocation cooldown cache: key = "repo@tag", avoids redundant date API calls + coolDownCache := make(map[string]coolDownCheckResult) + + var updatedFiles []string + + err := filepath.WalkDir(opts.workflowsDir, func(path string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if ctx.Err() != nil { + return ctx.Err() + } + if d.IsDir() || !strings.HasSuffix(d.Name(), ".md") { + return nil + } + + content, err := os.ReadFile(path) + if err != nil { + if opts.verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to read %s: %v", path, err))) + } + return nil + } + + updatedActions, newContent, err := updateActionRefsInContentWithDeps(ctx, deps, string(content), cache, coolDownCache, !opts.disableReleaseBump, opts.verbose, opts.coolDown) + if err != nil { + if opts.verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to update action refs in %s: %v", path, err))) + } + return nil + } + updatedSkills, newContent, err := updateSkillRefsInContent(ctx, newContent, !opts.disableReleaseBump, opts.verbose, opts.coolDown) + if err != nil { + if opts.verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to update skill refs in %s: %v", path, err))) + } + return nil + } + + if !updatedActions && !updatedSkills { + return nil + } + + if err := os.WriteFile(path, []byte(newContent), constants.FilePermPublic); err != nil { + return fmt.Errorf("failed to write updated workflow %s: %w", path, err) + } + + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Updated action/skill references in "+d.Name())) + updatedFiles = append(updatedFiles, path) + return nil + }) + if err != nil { + return fmt.Errorf("failed to walk workflows directory: %w", err) + } + + if len(updatedFiles) > 0 && !opts.noCompile { + if err := compileWorkflowsForUpdate(ctx, updatedFiles, opts.workflowsDir, opts.engineOverride, opts.verbose, opts.approve); err != nil { + return fmt.Errorf("failed to compile workflows with updated action references: %w", err) + } + } + + if len(updatedFiles) == 0 && opts.verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No action references needed updating in workflow files")) + } + + return nil +} + +type skillRefUpdateResolver func(ctx context.Context, repo, currentRef string, allowMajor, verbose bool, coolDown time.Duration) (string, error) + +func updateSkillRefsInContent(ctx context.Context, content string, allowMajor, verbose bool, coolDown time.Duration) (bool, string, error) { + return updateSkillRefsInContentWithResolver(ctx, content, allowMajor, verbose, coolDown, resolveLatestRef) +} + +func updateSkillRefsInContentWithResolver( + ctx context.Context, + content string, + allowMajor, verbose bool, + coolDown time.Duration, + resolver skillRefUpdateResolver, +) (bool, string, error) { + result, err := parser.ExtractFrontmatterFromContent(content) + if err != nil { + if verbose { + updateLog.Printf("Skipping skill update for content without parseable frontmatter: %v", err) + } + return false, content, nil + } + if result == nil || result.Frontmatter == nil { + return false, content, nil + } + + rawSkills, ok := result.Frontmatter["skills"].([]any) + if !ok || len(rawSkills) == 0 { + return false, content, nil + } + + changed := false + for i, rawSkill := range rawSkills { + switch typed := rawSkill.(type) { + case string: + updated, updatedRef, err := updateSkillRefValue(ctx, typed, allowMajor, verbose, coolDown, resolver) + if err != nil { + return false, content, err + } + if updated { + rawSkills[i] = updatedRef + changed = true + } + case map[string]any: + skillRef, ok := typed["skill"].(string) + if !ok { + continue + } + updated, updatedRef, err := updateSkillRefValue(ctx, skillRef, allowMajor, verbose, coolDown, resolver) + if err != nil { + return false, content, err + } + if updated { + typed["skill"] = updatedRef + changed = true + } + } + } + if !changed { + return false, content, nil + } + result.Frontmatter["skills"] = rawSkills + + updatedFrontmatter, err := yaml.Marshal(result.Frontmatter) + if err != nil { + return false, content, fmt.Errorf("failed to marshal updated frontmatter: %w", err) + } + updatedContent, err := parser.ReconstructWorkflowFile(parser.QuoteCronExpressions(string(updatedFrontmatter)), result.Markdown) + if err != nil { + return false, content, fmt.Errorf("failed to reconstruct workflow file: %w", err) + } + return true, updatedContent, nil +} + +func updateSkillRefValue( + ctx context.Context, + skillRef string, + allowMajor, verbose bool, + coolDown time.Duration, + resolver skillRefUpdateResolver, +) (bool, string, error) { + trimmedSkillRef := strings.TrimSpace(skillRef) + if trimmedSkillRef == "" || strings.Contains(trimmedSkillRef, "${{") { + return false, skillRef, nil + } + spec, currentRef, ok := strings.Cut(trimmedSkillRef, "@") + spec = strings.TrimSpace(spec) + currentRef = strings.TrimSpace(currentRef) + if !ok || spec == "" || currentRef == "" { + return false, skillRef, nil + } + + repo := gitutil.ExtractBaseRepo(spec) + if repo == "" { + return false, skillRef, nil + } + latestRef, err := resolver(ctx, repo, currentRef, allowMajor, verbose, coolDown) + if err != nil { + if verbose { + updateLog.Printf("Skipping skill update for %s@%s: %v", spec, currentRef, err) + } + return false, skillRef, nil + } + if latestRef == "" || latestRef == currentRef { + return false, skillRef, nil + } + return true, spec + "@" + latestRef, nil +} + +func updateActionRefsInContentWithDeps(ctx context.Context, deps actionUpdateDeps, content string, cache map[string]latestReleaseResult, coolDownCache map[string]coolDownCheckResult, allowMajor, verbose bool, coolDown time.Duration) (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]] + } + + // 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 the latest-release resolver. + 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), "#")) + if commentVersion != "" { + currentVersion = commentVersion + } else { + currentVersion = "" + } + } else { + currentVersion = "" + } + } + + // Resolve latest version/SHA, using the cache to avoid redundant API calls. + // Use "|" as separator since GitHub repo names cannot contain "|". + cacheKey := repo + "|" + currentVersion + result, cached := cache[cacheKey] + if !cached { + latestVersion, latestSHA, err := deps.getLatestRelease(ctx, repo, currentVersion, effectiveAllowMajor, verbose) + if err != nil { + updateLog.Printf("Failed to get latest release for %s: %v", repo, err) + continue + } + result = latestReleaseResult{version: latestVersion, sha: latestSHA} + cache[cacheKey] = result + } + latestVersion := result.version + latestSHA := result.sha + + if isSHA { + if latestSHA == ref { + continue // SHA unchanged + } + } else { + if latestVersion == ref { + continue // Version tag unchanged + } + // Prevent downgrades: if the proposed version is older than the current, skip. + currentVer := parseVersion(ref) + proposedVer := parseVersion(latestVersion) + if currentVer != nil && proposedVer != nil && currentVer.IsNewer(proposedVer) { + updateLog.Printf("Skipping %s in workflow file: proposed version %s is older than current %s (would be a downgrade)", repo, latestVersion, ref) + continue + } + } + + // Apply cooldown: if the repo is not exempt and the release is too recent, try + // progressively older releases (still newer than current) until finding one that + // has passed the cooldown period. + if !isExemptFromCoolDown(repo) { + coolDownKey := repo + "@" + latestVersion + coolDownResult, coolDownCached := coolDownCache[coolDownKey] + if !coolDownCached { + coolDownResult = deps.checkCoolDown(ctx, repo, latestVersion, coolDown) + coolDownCache[coolDownKey] = coolDownResult + } + if coolDownResult.InCoolDown { + cooldownLog.Printf("Action ref %s in workflow: %s", repo, coolDownResult.Message) + + // Try to find an older release that has passed the cooldown period. + olderVersion, olderSHA, findErr := findCooledDownActionVersion(ctx, deps, repo, currentVersion, effectiveAllowMajor, verbose, coolDown, latestVersion) + if findErr != nil || olderVersion == "" || olderSHA == "" { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Skipping release candidate %s@%s: %s", repo, latestVersion, coolDownResult.Message))) + } + continue + } + if verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Falling back to %s for %s (latest release candidate is still in cooldown)", olderVersion, repo))) + } + // Use the older, cooled-down release and update the per-invocation cache. + result = latestReleaseResult{version: olderVersion, sha: olderSHA} + cache[cacheKey] = result + latestVersion = olderVersion + latestSHA = olderSHA + } + } + + // 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 +}