Skip to content

Commit 35ba212

Browse files
Copilotpelikhan
andauthored
Add action SHA validation to compile --validate command (#3631)
* Initial plan * Initial exploration complete Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * Add action SHA validation to compile --validate command Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * Add comprehensive integration tests for action SHA validation Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * Update help text for --validate flag to mention SHA validation Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
1 parent 2c547f8 commit 35ba212

6 files changed

Lines changed: 699 additions & 10 deletions

File tree

cmd/gh-aw/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,7 @@ Use "` + constants.CLIExtensionPrefix + ` help all" to show help for all command
332332

333333
// Add AI flag to compile and add commands
334334
compileCmd.Flags().StringP("engine", "a", "", "Override AI engine (claude, codex, copilot)")
335-
compileCmd.Flags().Bool("validate", false, "Enable GitHub Actions workflow schema validation and container image validation")
335+
compileCmd.Flags().Bool("validate", false, "Enable GitHub Actions workflow schema validation, container image validation, and action SHA validation")
336336
compileCmd.Flags().BoolP("watch", "w", false, "Watch for changes to workflow files and recompile automatically")
337337
compileCmd.Flags().String("workflows-dir", "", "Relative directory containing workflows (default: .github/workflows)")
338338
compileCmd.Flags().Bool("no-emit", false, "Validate workflow without generating lock files")

pkg/cli/add_command.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -745,7 +745,7 @@ func updateWorkflowTitle(content string, number int) string {
745745
func compileWorkflow(filePath string, verbose bool, engineOverride string) error {
746746
// Create compiler and compile the workflow
747747
compiler := workflow.NewCompiler(verbose, engineOverride, GetVersion())
748-
if err := CompileWorkflowWithValidation(compiler, filePath, verbose, false, false, false, false); err != nil {
748+
if err := CompileWorkflowWithValidation(compiler, filePath, verbose, false, false, false, false, false); err != nil {
749749
return err
750750
}
751751

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

pkg/cli/compile_command.go

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import (
2020
var compileLog = logger.New("cli:compile_command")
2121

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

49+
// Validate action SHAs if requested
50+
if validateActionSHAs {
51+
compileLog.Print("Validating action SHAs in lock file")
52+
// Find git root for action cache
53+
gitRoot, err := findGitRoot()
54+
if err != nil {
55+
compileLog.Printf("Unable to find git root for action cache: %v", err)
56+
// Continue without validation if we can't find git root
57+
} else {
58+
// Create action cache for validation
59+
actionCache := workflow.NewActionCache(gitRoot)
60+
if err := workflow.ValidateActionSHAsInLockFile(lockFile, actionCache, verbose); err != nil {
61+
// Action SHA validation warnings are non-fatal
62+
compileLog.Printf("Action SHA validation completed with warnings: %v", err)
63+
}
64+
}
65+
}
66+
4967
// Run zizmor on the generated lock file if requested
5068
if runZizmorPerFile {
5169
if err := runZizmorOnFile(lockFile, verbose, strict); err != nil {
@@ -72,7 +90,7 @@ func CompileWorkflowWithValidation(compiler *workflow.Compiler, filePath string,
7290

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

119+
// Validate action SHAs if requested
120+
if validateActionSHAs {
121+
compileLog.Print("Validating action SHAs in lock file")
122+
// Find git root for action cache
123+
gitRoot, err := findGitRoot()
124+
if err != nil {
125+
compileLog.Printf("Unable to find git root for action cache: %v", err)
126+
// Continue without validation if we can't find git root
127+
} else {
128+
// Create action cache for validation
129+
actionCache := workflow.NewActionCache(gitRoot)
130+
if err := workflow.ValidateActionSHAsInLockFile(lockFile, actionCache, verbose); err != nil {
131+
// Action SHA validation warnings are non-fatal
132+
compileLog.Printf("Action SHA validation completed with warnings: %v", err)
133+
}
134+
}
135+
}
136+
101137
// Run zizmor on the generated lock file if requested
102138
if runZizmorPerFile {
103139
if err := runZizmorOnFile(lockFile, verbose, strict); err != nil {
@@ -281,7 +317,7 @@ func CompileWorkflows(config CompileConfig) ([]*workflow.WorkflowData, error) {
281317
workflowDataList = append(workflowDataList, workflowData)
282318

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

425-
if err := CompileWorkflowDataWithValidation(compiler, workflowData, file, verbose, zizmor && !noEmit, poutine && !noEmit, actionlint && !noEmit, strict); err != nil {
461+
if err := CompileWorkflowDataWithValidation(compiler, workflowData, file, verbose, zizmor && !noEmit, poutine && !noEmit, actionlint && !noEmit, strict, validate && !noEmit); err != nil {
426462
// Print the error to stderr (errors from CompileWorkflow are already formatted)
427463
fmt.Fprintln(os.Stderr, err.Error())
428464
errorCount++
@@ -610,7 +646,7 @@ func watchAndCompileWorkflows(markdownFile string, compiler *workflow.Compiler,
610646
if verbose {
611647
fmt.Fprintf(os.Stderr, "🔨 Initial compilation of %s...\n", markdownFile)
612648
}
613-
if err := CompileWorkflowWithValidation(compiler, markdownFile, verbose, false, false, false, false); err != nil {
649+
if err := CompileWorkflowWithValidation(compiler, markdownFile, verbose, false, false, false, false, false); err != nil {
614650
// Always show initial compilation errors on new line without wrapping
615651
fmt.Fprintln(os.Stderr, err.Error())
616652
stats.Errors++
@@ -722,7 +758,7 @@ func compileAllWorkflowFiles(compiler *workflow.Compiler, workflowsDir string, v
722758
if verbose {
723759
fmt.Printf("🔨 Compiling: %s\n", file)
724760
}
725-
if err := CompileWorkflowWithValidation(compiler, file, verbose, false, false, false, false); err != nil {
761+
if err := CompileWorkflowWithValidation(compiler, file, verbose, false, false, false, false, false); err != nil {
726762
// Always show compilation errors on new line
727763
fmt.Fprintln(os.Stderr, err.Error())
728764
stats.Errors++
@@ -779,7 +815,7 @@ func compileModifiedFiles(compiler *workflow.Compiler, files []string, verbose b
779815
fmt.Fprintf(os.Stderr, "🔨 Compiling: %s\n", file)
780816
}
781817

782-
if err := CompileWorkflowWithValidation(compiler, file, verbose, false, false, false, false); err != nil {
818+
if err := CompileWorkflowWithValidation(compiler, file, verbose, false, false, false, false, false); err != nil {
783819
// Always show compilation errors on new line
784820
fmt.Fprintln(os.Stderr, err.Error())
785821
stats.Errors++

pkg/workflow/action_sha_checker.go

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
package workflow
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"regexp"
7+
8+
"github.com/githubnext/gh-aw/pkg/console"
9+
"github.com/githubnext/gh-aw/pkg/logger"
10+
"github.com/goccy/go-yaml"
11+
)
12+
13+
var actionSHACheckerLog = logger.New("workflow:action_sha_checker")
14+
15+
// ActionUsage represents an action used in a workflow with its SHA
16+
type ActionUsage struct {
17+
Repo string // e.g., "actions/checkout"
18+
SHA string // The SHA currently used
19+
Version string // The version tag if available (e.g., "v5")
20+
}
21+
22+
// ActionUpdateCheck represents the result of checking if an action needs updating
23+
type ActionUpdateCheck struct {
24+
Action ActionUsage
25+
NeedsUpdate bool
26+
LatestSHA string
27+
Message string
28+
}
29+
30+
// ExtractActionsFromLockFile parses a lock.yml file and extracts all action usages
31+
func ExtractActionsFromLockFile(lockFilePath string) ([]ActionUsage, error) {
32+
actionSHACheckerLog.Printf("Extracting actions from lock file: %s", lockFilePath)
33+
34+
content, err := os.ReadFile(lockFilePath)
35+
if err != nil {
36+
return nil, fmt.Errorf("failed to read lock file: %w", err)
37+
}
38+
39+
// Parse YAML to extract actions from "uses" fields
40+
var workflowData map[string]any
41+
if err := yaml.Unmarshal(content, &workflowData); err != nil {
42+
return nil, fmt.Errorf("failed to parse lock file YAML: %w", err)
43+
}
44+
45+
// Regular expression to match uses: owner/repo@sha
46+
// This matches: owner/repo@40-char-hex-sha or owner/repo/subpath@40-char-hex-sha
47+
usesPattern := regexp.MustCompile(`([a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+(?:/[a-zA-Z0-9_.-]+)*)@([0-9a-f]{40})`)
48+
49+
actions := make(map[string]ActionUsage) // Use map to deduplicate
50+
51+
// Convert to string and extract all uses fields
52+
contentStr := string(content)
53+
matches := usesPattern.FindAllStringSubmatch(contentStr, -1)
54+
55+
for _, match := range matches {
56+
if len(match) >= 3 {
57+
repo := match[1]
58+
sha := match[2]
59+
60+
// Skip if we've already seen this action
61+
if _, exists := actions[repo+"@"+sha]; exists {
62+
continue
63+
}
64+
65+
actionSHACheckerLog.Printf("Found action: %s@%s", repo, sha)
66+
67+
// Try to determine the version tag from action_pins.json
68+
version := ""
69+
if pin, found := GetActionPinByRepo(repo); found {
70+
version = pin.Version
71+
}
72+
73+
actions[repo+"@"+sha] = ActionUsage{
74+
Repo: repo,
75+
SHA: sha,
76+
Version: version,
77+
}
78+
}
79+
}
80+
81+
// Convert map to slice
82+
result := make([]ActionUsage, 0, len(actions))
83+
for _, action := range actions {
84+
result = append(result, action)
85+
}
86+
87+
actionSHACheckerLog.Printf("Extracted %d unique actions", len(result))
88+
return result, nil
89+
}
90+
91+
// CheckActionSHAUpdates checks if actions need updating by comparing with latest SHAs
92+
func CheckActionSHAUpdates(actions []ActionUsage, resolver *ActionResolver) []ActionUpdateCheck {
93+
actionSHACheckerLog.Printf("Checking %d actions for updates", len(actions))
94+
95+
results := make([]ActionUpdateCheck, 0, len(actions))
96+
97+
for _, action := range actions {
98+
check := ActionUpdateCheck{
99+
Action: action,
100+
NeedsUpdate: false,
101+
}
102+
103+
// Skip if we don't have a version to check against
104+
if action.Version == "" {
105+
actionSHACheckerLog.Printf("Skipping %s: no version tag available", action.Repo)
106+
continue
107+
}
108+
109+
// Resolve the latest SHA for this version
110+
latestSHA, err := resolver.ResolveSHA(action.Repo, action.Version)
111+
if err != nil {
112+
actionSHACheckerLog.Printf("Failed to resolve %s@%s: %v", action.Repo, action.Version, err)
113+
check.Message = fmt.Sprintf("Unable to check for updates: %v", err)
114+
results = append(results, check)
115+
continue
116+
}
117+
118+
check.LatestSHA = latestSHA
119+
120+
// Compare SHAs
121+
if action.SHA != latestSHA {
122+
check.NeedsUpdate = true
123+
check.Message = fmt.Sprintf("Action %s@%s is using SHA %s but latest is %s",
124+
action.Repo, action.Version, action.SHA[:7], latestSHA[:7])
125+
actionSHACheckerLog.Printf("UPDATE NEEDED: %s", check.Message)
126+
} else {
127+
actionSHACheckerLog.Printf("Action %s@%s is up to date", action.Repo, action.Version)
128+
}
129+
130+
results = append(results, check)
131+
}
132+
133+
return results
134+
}
135+
136+
// ValidateActionSHAsInLockFile validates action SHAs in a lock file and emits warnings
137+
func ValidateActionSHAsInLockFile(lockFilePath string, cache *ActionCache, verbose bool) error {
138+
actionSHACheckerLog.Printf("Validating action SHAs in: %s", lockFilePath)
139+
140+
// Extract actions from lock file
141+
actions, err := ExtractActionsFromLockFile(lockFilePath)
142+
if err != nil {
143+
return fmt.Errorf("failed to extract actions: %w", err)
144+
}
145+
146+
if len(actions) == 0 {
147+
actionSHACheckerLog.Print("No pinned actions found in lock file")
148+
if verbose {
149+
fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No pinned actions to validate"))
150+
}
151+
return nil
152+
}
153+
154+
// Create resolver for checking latest SHAs
155+
resolver := NewActionResolver(cache)
156+
157+
// Check for updates
158+
checks := CheckActionSHAUpdates(actions, resolver)
159+
160+
// Count and report updates
161+
updateCount := 0
162+
for _, check := range checks {
163+
if check.NeedsUpdate {
164+
updateCount++
165+
// Emit warning
166+
warningMsg := fmt.Sprintf("⚠️ %s@%s has a newer SHA available: %s → %s",
167+
check.Action.Repo,
168+
check.Action.Version,
169+
check.Action.SHA[:7],
170+
check.LatestSHA[:7])
171+
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(warningMsg))
172+
173+
// Show full SHA in verbose mode
174+
if verbose {
175+
fmt.Fprintf(os.Stderr, " Current: %s\n", check.Action.SHA)
176+
fmt.Fprintf(os.Stderr, " Latest: %s\n", check.LatestSHA)
177+
}
178+
}
179+
}
180+
181+
if updateCount > 0 {
182+
actionSHACheckerLog.Printf("Found %d actions that need updating", updateCount)
183+
if verbose {
184+
fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Found %d action(s) with available updates", updateCount)))
185+
}
186+
} else {
187+
actionSHACheckerLog.Print("All actions are up to date")
188+
if verbose {
189+
fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("All pinned actions are up to date"))
190+
}
191+
}
192+
193+
return nil
194+
}

0 commit comments

Comments
 (0)