Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/gh-aw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@ Use "` + constants.CLIExtensionPrefix + ` help all" to show help for all command

// Add AI flag to compile and add commands
compileCmd.Flags().StringP("engine", "a", "", "Override AI engine (claude, codex, copilot)")
compileCmd.Flags().Bool("validate", false, "Enable GitHub Actions workflow schema validation and container image validation")
compileCmd.Flags().Bool("validate", false, "Enable GitHub Actions workflow schema validation, container image validation, and action SHA validation")
compileCmd.Flags().BoolP("watch", "w", false, "Watch for changes to workflow files and recompile automatically")
compileCmd.Flags().String("workflows-dir", "", "Relative directory containing workflows (default: .github/workflows)")
compileCmd.Flags().Bool("no-emit", false, "Validate workflow without generating lock files")
Expand Down
4 changes: 2 additions & 2 deletions pkg/cli/add_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -745,7 +745,7 @@ func updateWorkflowTitle(content string, number int) string {
func compileWorkflow(filePath string, verbose bool, engineOverride string) error {
// Create compiler and compile the workflow
compiler := workflow.NewCompiler(verbose, engineOverride, GetVersion())
if err := CompileWorkflowWithValidation(compiler, filePath, verbose, false, false, false, false); err != nil {
if err := CompileWorkflowWithValidation(compiler, filePath, verbose, false, false, false, false, false); err != nil {
return err
}

Expand Down Expand Up @@ -801,7 +801,7 @@ func compileWorkflowWithTracking(filePath string, verbose bool, engineOverride s
// Create compiler and set the file tracker
compiler := workflow.NewCompiler(verbose, engineOverride, GetVersion())
compiler.SetFileTracker(tracker)
if err := CompileWorkflowWithValidation(compiler, filePath, verbose, false, false, false, false); err != nil {
if err := CompileWorkflowWithValidation(compiler, filePath, verbose, false, false, false, false, false); err != nil {
return err
}

Expand Down
50 changes: 43 additions & 7 deletions pkg/cli/compile_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import (
var compileLog = logger.New("cli:compile_command")

// CompileWorkflowWithValidation compiles a workflow with always-on YAML validation for CLI usage
func CompileWorkflowWithValidation(compiler *workflow.Compiler, filePath string, verbose bool, runZizmorPerFile bool, runPoutinePerFile bool, runActionlintPerFile bool, strict bool) error {
func CompileWorkflowWithValidation(compiler *workflow.Compiler, filePath string, verbose bool, runZizmorPerFile bool, runPoutinePerFile bool, runActionlintPerFile bool, strict bool, validateActionSHAs bool) error {
// Compile the workflow first
if err := compiler.CompileWorkflow(filePath); err != nil {
return err
Expand All @@ -46,6 +46,24 @@ func CompileWorkflowWithValidation(compiler *workflow.Compiler, filePath string,
return fmt.Errorf("generated lock file is not valid YAML: %w", err)
}

// Validate action SHAs if requested
if validateActionSHAs {
compileLog.Print("Validating action SHAs in lock file")
// Find git root for action cache
gitRoot, err := findGitRoot()
if err != nil {
compileLog.Printf("Unable to find git root for action cache: %v", err)
// Continue without validation if we can't find git root
} else {
// Create action cache for validation
actionCache := workflow.NewActionCache(gitRoot)
if err := workflow.ValidateActionSHAsInLockFile(lockFile, actionCache, verbose); err != nil {
// Action SHA validation warnings are non-fatal
compileLog.Printf("Action SHA validation completed with warnings: %v", err)
}
Comment on lines +60 to +63
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

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

The comment on line 61 says 'Action SHA validation warnings are non-fatal,' but looking at ValidateActionSHAsInLockFile in action_sha_checker.go (line 193), the function always returns nil and never returns an error. This means the error handling code on lines 60-63 can never execute. Either remove the error handling since it's unreachable, or update ValidateActionSHAsInLockFile to return an error in appropriate cases.

Suggested change
if err := workflow.ValidateActionSHAsInLockFile(lockFile, actionCache, verbose); err != nil {
// Action SHA validation warnings are non-fatal
compileLog.Printf("Action SHA validation completed with warnings: %v", err)
}
workflow.ValidateActionSHAsInLockFile(lockFile, actionCache, verbose)

Copilot uses AI. Check for mistakes.
}
}

// Run zizmor on the generated lock file if requested
if runZizmorPerFile {
if err := runZizmorOnFile(lockFile, verbose, strict); err != nil {
Expand All @@ -72,7 +90,7 @@ func CompileWorkflowWithValidation(compiler *workflow.Compiler, filePath string,

// CompileWorkflowDataWithValidation compiles from already-parsed WorkflowData with validation
// This avoids re-parsing when the workflow data has already been parsed
func CompileWorkflowDataWithValidation(compiler *workflow.Compiler, workflowData *workflow.WorkflowData, filePath string, verbose bool, runZizmorPerFile bool, runPoutinePerFile bool, runActionlintPerFile bool, strict bool) error {
func CompileWorkflowDataWithValidation(compiler *workflow.Compiler, workflowData *workflow.WorkflowData, filePath string, verbose bool, runZizmorPerFile bool, runPoutinePerFile bool, runActionlintPerFile bool, strict bool, validateActionSHAs bool) error {
// Compile the workflow using already-parsed data
if err := compiler.CompileWorkflowData(workflowData, filePath); err != nil {
return err
Expand All @@ -98,6 +116,24 @@ func CompileWorkflowDataWithValidation(compiler *workflow.Compiler, workflowData
return fmt.Errorf("generated lock file is not valid YAML: %w", err)
}

// Validate action SHAs if requested
if validateActionSHAs {
compileLog.Print("Validating action SHAs in lock file")
// Find git root for action cache
gitRoot, err := findGitRoot()
if err != nil {
compileLog.Printf("Unable to find git root for action cache: %v", err)
// Continue without validation if we can't find git root
} else {
// Create action cache for validation
actionCache := workflow.NewActionCache(gitRoot)
if err := workflow.ValidateActionSHAsInLockFile(lockFile, actionCache, verbose); err != nil {
// Action SHA validation warnings are non-fatal
compileLog.Printf("Action SHA validation completed with warnings: %v", err)
}
}
}

// Run zizmor on the generated lock file if requested
if runZizmorPerFile {
if err := runZizmorOnFile(lockFile, verbose, strict); err != nil {
Expand Down Expand Up @@ -281,7 +317,7 @@ func CompileWorkflows(config CompileConfig) ([]*workflow.WorkflowData, error) {
workflowDataList = append(workflowDataList, workflowData)

compileLog.Printf("Starting compilation of %s", resolvedFile)
if err := CompileWorkflowDataWithValidation(compiler, workflowData, resolvedFile, verbose, zizmor && !noEmit, poutine && !noEmit, actionlint && !noEmit, strict); err != nil {
if err := CompileWorkflowDataWithValidation(compiler, workflowData, resolvedFile, verbose, zizmor && !noEmit, poutine && !noEmit, actionlint && !noEmit, strict, validate && !noEmit); err != nil {
// Always put error on a new line and don't wrap with "failed to compile workflow"
fmt.Fprintln(os.Stderr, err.Error())
errorMessages = append(errorMessages, err.Error())
Expand Down Expand Up @@ -422,7 +458,7 @@ func CompileWorkflows(config CompileConfig) ([]*workflow.WorkflowData, error) {
}
workflowDataList = append(workflowDataList, workflowData)

if err := CompileWorkflowDataWithValidation(compiler, workflowData, file, verbose, zizmor && !noEmit, poutine && !noEmit, actionlint && !noEmit, strict); err != nil {
if err := CompileWorkflowDataWithValidation(compiler, workflowData, file, verbose, zizmor && !noEmit, poutine && !noEmit, actionlint && !noEmit, strict, validate && !noEmit); err != nil {
// Print the error to stderr (errors from CompileWorkflow are already formatted)
fmt.Fprintln(os.Stderr, err.Error())
errorCount++
Expand Down Expand Up @@ -610,7 +646,7 @@ func watchAndCompileWorkflows(markdownFile string, compiler *workflow.Compiler,
if verbose {
fmt.Fprintf(os.Stderr, "🔨 Initial compilation of %s...\n", markdownFile)
}
if err := CompileWorkflowWithValidation(compiler, markdownFile, verbose, false, false, false, false); err != nil {
if err := CompileWorkflowWithValidation(compiler, markdownFile, verbose, false, false, false, false, false); err != nil {
// Always show initial compilation errors on new line without wrapping
fmt.Fprintln(os.Stderr, err.Error())
stats.Errors++
Expand Down Expand Up @@ -722,7 +758,7 @@ func compileAllWorkflowFiles(compiler *workflow.Compiler, workflowsDir string, v
if verbose {
fmt.Printf("🔨 Compiling: %s\n", file)
}
if err := CompileWorkflowWithValidation(compiler, file, verbose, false, false, false, false); err != nil {
if err := CompileWorkflowWithValidation(compiler, file, verbose, false, false, false, false, false); err != nil {
// Always show compilation errors on new line
fmt.Fprintln(os.Stderr, err.Error())
stats.Errors++
Expand Down Expand Up @@ -779,7 +815,7 @@ func compileModifiedFiles(compiler *workflow.Compiler, files []string, verbose b
fmt.Fprintf(os.Stderr, "🔨 Compiling: %s\n", file)
}

if err := CompileWorkflowWithValidation(compiler, file, verbose, false, false, false, false); err != nil {
if err := CompileWorkflowWithValidation(compiler, file, verbose, false, false, false, false, false); err != nil {
// Always show compilation errors on new line
fmt.Fprintln(os.Stderr, err.Error())
stats.Errors++
Expand Down
194 changes: 194 additions & 0 deletions pkg/workflow/action_sha_checker.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package workflow

import (
"fmt"
"os"
"regexp"

"github.com/githubnext/gh-aw/pkg/console"
"github.com/githubnext/gh-aw/pkg/logger"
"github.com/goccy/go-yaml"
)

var actionSHACheckerLog = logger.New("workflow:action_sha_checker")

// ActionUsage represents an action used in a workflow with its SHA
type ActionUsage struct {
Repo string // e.g., "actions/checkout"
SHA string // The SHA currently used
Version string // The version tag if available (e.g., "v5")
}

// ActionUpdateCheck represents the result of checking if an action needs updating
type ActionUpdateCheck struct {
Action ActionUsage
NeedsUpdate bool
LatestSHA string
Message string
}

// ExtractActionsFromLockFile parses a lock.yml file and extracts all action usages
func ExtractActionsFromLockFile(lockFilePath string) ([]ActionUsage, error) {
actionSHACheckerLog.Printf("Extracting actions from lock file: %s", lockFilePath)

content, err := os.ReadFile(lockFilePath)
if err != nil {
return nil, fmt.Errorf("failed to read lock file: %w", err)
}

// Parse YAML to extract actions from "uses" fields
var workflowData map[string]any
if err := yaml.Unmarshal(content, &workflowData); err != nil {
return nil, fmt.Errorf("failed to parse lock file YAML: %w", err)
}
Comment on lines +39 to +43
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

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

The YAML parsing on lines 39-43 appears to be unnecessary. The code parses the YAML into a workflowData variable but never uses it. Instead, the code converts the content to a string on line 52 and uses regex matching. The YAML parsing serves only as a validation step to ensure the file is valid YAML, but this is redundant since the file was already validated in the compile command. Consider removing lines 39-43 and the yaml import, or add a comment explaining why the YAML validation is needed here.

Copilot uses AI. Check for mistakes.

// Regular expression to match uses: owner/repo@sha
// This matches: owner/repo@40-char-hex-sha or owner/repo/subpath@40-char-hex-sha
usesPattern := regexp.MustCompile(`([a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+(?:/[a-zA-Z0-9_.-]+)*)@([0-9a-f]{40})`)

actions := make(map[string]ActionUsage) // Use map to deduplicate

// Convert to string and extract all uses fields
contentStr := string(content)
matches := usesPattern.FindAllStringSubmatch(contentStr, -1)

for _, match := range matches {
if len(match) >= 3 {
repo := match[1]
sha := match[2]

// Skip if we've already seen this action
if _, exists := actions[repo+"@"+sha]; exists {
continue
}

actionSHACheckerLog.Printf("Found action: %s@%s", repo, sha)

// Try to determine the version tag from action_pins.json
version := ""
if pin, found := GetActionPinByRepo(repo); found {
version = pin.Version
}

actions[repo+"@"+sha] = ActionUsage{
Repo: repo,
SHA: sha,
Version: version,
}
}
}

// Convert map to slice
result := make([]ActionUsage, 0, len(actions))
for _, action := range actions {
result = append(result, action)
}

actionSHACheckerLog.Printf("Extracted %d unique actions", len(result))
return result, nil
}

// CheckActionSHAUpdates checks if actions need updating by comparing with latest SHAs
func CheckActionSHAUpdates(actions []ActionUsage, resolver *ActionResolver) []ActionUpdateCheck {
actionSHACheckerLog.Printf("Checking %d actions for updates", len(actions))

results := make([]ActionUpdateCheck, 0, len(actions))

for _, action := range actions {
check := ActionUpdateCheck{
Action: action,
NeedsUpdate: false,
}

// Skip if we don't have a version to check against
if action.Version == "" {
actionSHACheckerLog.Printf("Skipping %s: no version tag available", action.Repo)
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

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

When an action has no version tag (line 104-107), the function skips it by using continue, but it never appends any result to the results slice. This means the returned slice will have fewer elements than the input actions slice, which could be confusing to callers. Consider either: (1) appending a check result with NeedsUpdate: false and an informative message, or (2) documenting that the returned slice may be shorter than the input.

Suggested change
actionSHACheckerLog.Printf("Skipping %s: no version tag available", action.Repo)
actionSHACheckerLog.Printf("Skipping %s: no version tag available", action.Repo)
check.Message = "No version tag available; cannot check for updates"
results = append(results, check)

Copilot uses AI. Check for mistakes.
continue
}

// Resolve the latest SHA for this version
latestSHA, err := resolver.ResolveSHA(action.Repo, action.Version)
if err != nil {
actionSHACheckerLog.Printf("Failed to resolve %s@%s: %v", action.Repo, action.Version, err)
check.Message = fmt.Sprintf("Unable to check for updates: %v", err)
results = append(results, check)
continue
}

check.LatestSHA = latestSHA

// Compare SHAs
if action.SHA != latestSHA {
check.NeedsUpdate = true
check.Message = fmt.Sprintf("Action %s@%s is using SHA %s but latest is %s",
action.Repo, action.Version, action.SHA[:7], latestSHA[:7])
actionSHACheckerLog.Printf("UPDATE NEEDED: %s", check.Message)
} else {
actionSHACheckerLog.Printf("Action %s@%s is up to date", action.Repo, action.Version)
}

results = append(results, check)
}

return results
}

// ValidateActionSHAsInLockFile validates action SHAs in a lock file and emits warnings
func ValidateActionSHAsInLockFile(lockFilePath string, cache *ActionCache, verbose bool) error {
actionSHACheckerLog.Printf("Validating action SHAs in: %s", lockFilePath)

// Extract actions from lock file
actions, err := ExtractActionsFromLockFile(lockFilePath)
if err != nil {
return fmt.Errorf("failed to extract actions: %w", err)
}

if len(actions) == 0 {
actionSHACheckerLog.Print("No pinned actions found in lock file")
if verbose {
fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No pinned actions to validate"))
}
return nil
}

// Create resolver for checking latest SHAs
resolver := NewActionResolver(cache)

// Check for updates
checks := CheckActionSHAUpdates(actions, resolver)

// Count and report updates
updateCount := 0
for _, check := range checks {
if check.NeedsUpdate {
updateCount++
// Emit warning
warningMsg := fmt.Sprintf("⚠️ %s@%s has a newer SHA available: %s → %s",
check.Action.Repo,
check.Action.Version,
check.Action.SHA[:7],
check.LatestSHA[:7])
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(warningMsg))

// Show full SHA in verbose mode
if verbose {
fmt.Fprintf(os.Stderr, " Current: %s\n", check.Action.SHA)
fmt.Fprintf(os.Stderr, " Latest: %s\n", check.LatestSHA)
}
}
}

if updateCount > 0 {
actionSHACheckerLog.Printf("Found %d actions that need updating", updateCount)
if verbose {
fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Found %d action(s) with available updates", updateCount)))
}
} else {
actionSHACheckerLog.Print("All actions are up to date")
if verbose {
fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("All pinned actions are up to date"))
}
}

return nil
}
Loading
Loading