diff --git a/cmd/gh-aw/main.go b/cmd/gh-aw/main.go index f20a6e9febc..4d792fb246c 100644 --- a/cmd/gh-aw/main.go +++ b/cmd/gh-aw/main.go @@ -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") diff --git a/pkg/cli/add_command.go b/pkg/cli/add_command.go index f5f7de4c5fa..91515086339 100644 --- a/pkg/cli/add_command.go +++ b/pkg/cli/add_command.go @@ -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 } @@ -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 } diff --git a/pkg/cli/compile_command.go b/pkg/cli/compile_command.go index eb646e5666e..a9b9dff0df7 100644 --- a/pkg/cli/compile_command.go +++ b/pkg/cli/compile_command.go @@ -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 @@ -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) + } + } + } + // Run zizmor on the generated lock file if requested if runZizmorPerFile { if err := runZizmorOnFile(lockFile, verbose, strict); err != nil { @@ -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 @@ -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 { @@ -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()) @@ -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++ @@ -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++ @@ -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++ @@ -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++ diff --git a/pkg/workflow/action_sha_checker.go b/pkg/workflow/action_sha_checker.go new file mode 100644 index 00000000000..2d8d07ae65c --- /dev/null +++ b/pkg/workflow/action_sha_checker.go @@ -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) + } + + // 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) + 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 +} diff --git a/pkg/workflow/action_sha_checker_integration_test.go b/pkg/workflow/action_sha_checker_integration_test.go new file mode 100644 index 00000000000..214c233f1ed --- /dev/null +++ b/pkg/workflow/action_sha_checker_integration_test.go @@ -0,0 +1,267 @@ +package workflow + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestActionSHAValidationIntegration tests the complete SHA validation flow +func TestActionSHAValidationIntegration(t *testing.T) { + // Create a temporary directory for testing + tmpDir := t.TempDir() + + // Create an actions-lock.json cache file with test data + cacheDir := filepath.Join(tmpDir, ".github", "aw") + if err := os.MkdirAll(cacheDir, 0755); err != nil { + t.Fatalf("Failed to create cache directory: %v", err) + } + + // Create a cache with some pre-populated data + cache := NewActionCache(tmpDir) + + // Pre-populate cache with "current" SHAs + cache.Set("actions/checkout", "v5", "08c6903cd8c0fde910a37f88322edcfb5dd907a8") + cache.Set("actions/setup-node", "v6", "2028fbc5c25fe9cf00d9f06a71cc4710d4507903") + + // Save the cache + if err := cache.Save(); err != nil { + t.Fatalf("Failed to save cache: %v", err) + } + + // Create a lock file with the same SHAs (up-to-date scenario) + lockFile := filepath.Join(tmpDir, "test.lock.yml") + lockContent := ` +name: Test Workflow +on: push +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 +` + + if err := os.WriteFile(lockFile, []byte(lockContent), 0644); err != nil { + t.Fatalf("Failed to create lock file: %v", err) + } + + // Test 1: Validation with up-to-date actions (should not error) + t.Run("UpToDate", func(t *testing.T) { + err := ValidateActionSHAsInLockFile(lockFile, cache, false) + if err != nil { + t.Errorf("Unexpected error with up-to-date actions: %v", err) + } + }) + + // Create a lock file with outdated SHAs + outdatedLockFile := filepath.Join(tmpDir, "outdated.lock.yml") + outdatedContent := ` +name: Test Workflow +on: push +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@0000000000000000000000000000000000000000 + - uses: actions/setup-node@1111111111111111111111111111111111111111 +` + + if err := os.WriteFile(outdatedLockFile, []byte(outdatedContent), 0644); err != nil { + t.Fatalf("Failed to create outdated lock file: %v", err) + } + + // Test 2: Validation with outdated actions (should emit warnings but not error) + t.Run("Outdated", func(t *testing.T) { + // Note: This will emit warnings to stderr, but should not return an error + err := ValidateActionSHAsInLockFile(outdatedLockFile, cache, false) + if err != nil { + t.Errorf("Unexpected error with outdated actions: %v", err) + } + }) +} + +// TestActionSHAValidationWithMissingCache tests validation when cache doesn't exist +func TestActionSHAValidationWithMissingCache(t *testing.T) { + tmpDir := t.TempDir() + + // Create a lock file + lockFile := filepath.Join(tmpDir, "test.lock.yml") + lockContent := ` +name: Test Workflow +on: push +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 +` + + if err := os.WriteFile(lockFile, []byte(lockContent), 0644); err != nil { + t.Fatalf("Failed to create lock file: %v", err) + } + + // Create cache but don't pre-populate it (simulates first run) + cache := NewActionCache(tmpDir) + + // Validation should handle missing cache gracefully + err := ValidateActionSHAsInLockFile(lockFile, cache, false) + if err != nil { + t.Errorf("Unexpected error with missing cache: %v", err) + } +} + +// TestExtractActionsFromRealLockFile tests extraction from a realistic lock file +func TestExtractActionsFromRealLockFile(t *testing.T) { + tmpDir := t.TempDir() + lockFile := filepath.Join(tmpDir, "realistic.lock.yml") + + // Create a more realistic lock file with multiple jobs and steps + lockContent := ` +# This file was automatically generated by gh-aw +name: CI Workflow +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 + with: + fetch-depth: 0 + + - name: Setup Node + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 + with: + node-version: '20' + + - name: Run tests + run: npm test + + deploy: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 + - name: Upload artifact + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: results + path: ./dist +` + + if err := os.WriteFile(lockFile, []byte(lockContent), 0644); err != nil { + t.Fatalf("Failed to create lock file: %v", err) + } + + actions, err := ExtractActionsFromLockFile(lockFile) + if err != nil { + t.Fatalf("Failed to extract actions: %v", err) + } + + // Should find 3 unique actions (checkout appears twice but should be deduplicated) + if len(actions) != 3 { + t.Errorf("Expected 3 unique actions, got %d", len(actions)) + } + + // Verify we found the expected actions + foundRepos := make(map[string]bool) + for _, action := range actions { + foundRepos[action.Repo] = true + } + + expectedRepos := []string{ + "actions/checkout", + "actions/setup-node", + "actions/upload-artifact", + } + + for _, expected := range expectedRepos { + if !foundRepos[expected] { + t.Errorf("Expected to find action %s", expected) + } + } +} + +// TestValidationMessageFormat tests that validation messages are properly formatted +func TestValidationMessageFormat(t *testing.T) { + tmpDir := t.TempDir() + lockFile := filepath.Join(tmpDir, "test.lock.yml") + + lockContent := ` +name: Test +on: push +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 +` + + if err := os.WriteFile(lockFile, []byte(lockContent), 0644); err != nil { + t.Fatalf("Failed to create lock file: %v", err) + } + + cache := NewActionCache(tmpDir) + cache.Set("actions/checkout", "v5", "08c6903cd8c0fde910a37f88322edcfb5dd907a8") + + // Capture stderr output to verify message format + // Note: In a real scenario, we'd redirect stderr, but for this test + // we just ensure it doesn't error + err := ValidateActionSHAsInLockFile(lockFile, cache, true) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } +} + +// TestActionUsageVersionPopulation tests that version is populated from action_pins.json +func TestActionUsageVersionPopulation(t *testing.T) { + tmpDir := t.TempDir() + lockFile := filepath.Join(tmpDir, "test.lock.yml") + + // Use an action that exists in action_pins.json + lockContent := ` +name: Test +on: push +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 +` + + if err := os.WriteFile(lockFile, []byte(lockContent), 0644); err != nil { + t.Fatalf("Failed to create lock file: %v", err) + } + + actions, err := ExtractActionsFromLockFile(lockFile) + if err != nil { + t.Fatalf("Failed to extract actions: %v", err) + } + + // Check that actions/checkout has its version populated + found := false + for _, action := range actions { + if action.Repo == "actions/checkout" { + found = true + if action.Version == "" { + t.Error("Expected version to be populated for actions/checkout") + } + if !strings.HasPrefix(action.Version, "v") { + t.Errorf("Expected version to start with 'v', got: %s", action.Version) + } + } + } + + if !found { + t.Error("Expected to find actions/checkout in extracted actions") + } +} diff --git a/pkg/workflow/action_sha_checker_test.go b/pkg/workflow/action_sha_checker_test.go new file mode 100644 index 00000000000..b8f15c9ef63 --- /dev/null +++ b/pkg/workflow/action_sha_checker_test.go @@ -0,0 +1,192 @@ +package workflow + +import ( + "os" + "path/filepath" + "testing" +) + +func TestExtractActionsFromLockFile(t *testing.T) { + // Create a temporary lock file with test content + tmpDir := t.TempDir() + lockFile := filepath.Join(tmpDir, "test.lock.yml") + + lockContent := ` +name: Test Workflow +on: push +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 + - name: Run tests + run: npm test + - uses: github/codeql-action/upload-sarif@ab2e54f42aa112ff08704159b88a57517f6f0ebb +` + + if err := os.WriteFile(lockFile, []byte(lockContent), 0644); err != nil { + t.Fatalf("Failed to create test lock file: %v", err) + } + + // Extract actions + actions, err := ExtractActionsFromLockFile(lockFile) + if err != nil { + t.Fatalf("ExtractActionsFromLockFile failed: %v", err) + } + + // Verify we extracted the expected actions + if len(actions) != 3 { + t.Errorf("Expected 3 actions, got %d", len(actions)) + } + + // Check that we have the expected repositories + expectedRepos := map[string]bool{ + "actions/checkout": false, + "actions/setup-node": false, + "github/codeql-action/upload-sarif": false, + } + + for _, action := range actions { + if _, exists := expectedRepos[action.Repo]; exists { + expectedRepos[action.Repo] = true + } + } + + for repo, found := range expectedRepos { + if !found { + t.Errorf("Expected to find action %s, but it was not extracted", repo) + } + } + + // Verify SHA format + for _, action := range actions { + if len(action.SHA) != 40 { + t.Errorf("Expected SHA to be 40 characters, got %d for %s", len(action.SHA), action.Repo) + } + } +} + +func TestExtractActionsFromLockFileNoDuplicates(t *testing.T) { + // Create a temporary lock file with duplicate actions + tmpDir := t.TempDir() + lockFile := filepath.Join(tmpDir, "test.lock.yml") + + lockContent := ` +name: Test Workflow +on: push +jobs: + test1: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 + test2: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 +` + + if err := os.WriteFile(lockFile, []byte(lockContent), 0644); err != nil { + t.Fatalf("Failed to create test lock file: %v", err) + } + + // Extract actions + actions, err := ExtractActionsFromLockFile(lockFile) + if err != nil { + t.Fatalf("ExtractActionsFromLockFile failed: %v", err) + } + + // Verify we only have 2 unique actions despite being used twice + if len(actions) != 2 { + t.Errorf("Expected 2 unique actions, got %d", len(actions)) + } +} + +func TestCheckActionSHAUpdates(t *testing.T) { + // Create a test action cache + tmpDir := t.TempDir() + cache := NewActionCache(tmpDir) + + // Create test actions with known SHAs + actions := []ActionUsage{ + { + Repo: "actions/checkout", + SHA: "08c6903cd8c0fde910a37f88322edcfb5dd907a8", // Current SHA + Version: "v5", + }, + { + Repo: "actions/setup-node", + SHA: "oldsha0000000000000000000000000000000000", // Outdated SHA + Version: "v6", + }, + } + + // Pre-populate the cache with known values + // For actions/checkout@v5, use the same SHA (up to date) + cache.Set("actions/checkout", "v5", "08c6903cd8c0fde910a37f88322edcfb5dd907a8") + // For actions/setup-node@v6, use a different SHA (needs update) + cache.Set("actions/setup-node", "v6", "newsha0000000000000000000000000000000000") + + // Create resolver with the cache + resolver := NewActionResolver(cache) + + // Check for updates + checks := CheckActionSHAUpdates(actions, resolver) + + // Verify results + if len(checks) != 2 { + t.Errorf("Expected 2 check results, got %d", len(checks)) + } + + // First action (actions/checkout) should be up to date + if checks[0].NeedsUpdate { + t.Errorf("Expected actions/checkout to be up to date, but it needs update") + } + + // Second action (actions/setup-node) should need update + if !checks[1].NeedsUpdate { + t.Errorf("Expected actions/setup-node to need update, but it's marked as up to date") + } +} + +func TestExtractActionsFromLockFileNoActions(t *testing.T) { + // Create a temporary lock file with no actions + tmpDir := t.TempDir() + lockFile := filepath.Join(tmpDir, "test.lock.yml") + + lockContent := ` +name: Test Workflow +on: push +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Run tests + run: npm test +` + + if err := os.WriteFile(lockFile, []byte(lockContent), 0644); err != nil { + t.Fatalf("Failed to create test lock file: %v", err) + } + + // Extract actions + actions, err := ExtractActionsFromLockFile(lockFile) + if err != nil { + t.Fatalf("ExtractActionsFromLockFile failed: %v", err) + } + + // Verify we have no actions + if len(actions) != 0 { + t.Errorf("Expected 0 actions, got %d", len(actions)) + } +} + +func TestExtractActionsFromLockFileInvalidFile(t *testing.T) { + // Try to extract from non-existent file + _, err := ExtractActionsFromLockFile("/nonexistent/file.yml") + if err == nil { + t.Error("Expected error when reading non-existent file, got nil") + } +}