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
52 changes: 46 additions & 6 deletions pkg/cli/actionlint.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,11 @@ var actionlintStats *ActionlintStats

// ActionlintStats tracks actionlint validation statistics across all files
type ActionlintStats struct {
TotalWorkflows int
TotalErrors int
TotalWarnings int
ErrorsByKind map[string]int
TotalWorkflows int
TotalErrors int
TotalWarnings int
IntegrationErrors int // counts tooling/subprocess failures, not lint findings
ErrorsByKind map[string]int
}

// actionlintError represents a single error from actionlint JSON output
Expand Down Expand Up @@ -120,11 +121,25 @@ func displayActionlintSummary() {
fmt.Fprintf(os.Stderr, " • %s: %d\n", kind, count)
}
}
} else if actionlintStats.IntegrationErrors > 0 {
// Integration failures occurred but no lint issues were parsed.
// Explicitly distinguish this from a clean run so users are not misled.
msg := fmt.Sprintf("No lint issues found, but %d actionlint invocation(s) failed. "+
"This likely indicates a tooling or integration error, not a workflow problem.",
actionlintStats.IntegrationErrors)
fmt.Fprintf(os.Stderr, "%s\n", console.FormatWarningMessage(msg))
} else {
fmt.Fprintf(os.Stderr, "%s\n",
console.FormatSuccessMessage("No issues found"))
}

// Report any integration failures alongside lint findings
if totalIssues > 0 && actionlintStats.IntegrationErrors > 0 {
msg := fmt.Sprintf("%d actionlint invocation(s) also failed with tooling errors (not workflow validation failures)",
actionlintStats.IntegrationErrors)
fmt.Fprintf(os.Stderr, "\n%s\n", console.FormatWarningMessage(msg))
}

fmt.Fprintf(os.Stderr, "\n%s\n", separator)
}

Expand Down Expand Up @@ -252,6 +267,9 @@ func runActionlintOnFile(lockFiles []string, verbose bool, strict bool) error {
if len(lockFiles) == 1 {
fileList = filepath.Base(lockFiles[0])
}
if actionlintStats != nil {
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

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

In the timeout path, IntegrationErrors is incremented but TotalWorkflows is not. Since displayActionlintSummary currently returns early when TotalWorkflows == 0, a run where all actionlint invocations time out (or fail before TotalWorkflows is incremented) will produce no summary at all, even though integration failures occurred. Consider incrementing TotalWorkflows for attempted files before running (or in this timeout branch), and/or adjusting the summary guard to also render when IntegrationErrors > 0.

This issue also appears on line 282 of the same file.

Suggested change
if actionlintStats != nil {
if actionlintStats != nil {
// Count workflows we attempted to validate before timing out
actionlintStats.TotalWorkflows += len(lockFiles)

Copilot uses AI. Check for mistakes.
actionlintStats.IntegrationErrors++
}
return fmt.Errorf("actionlint timed out after %d minutes on %s - this may indicate a Docker or network issue", int(timeoutDuration.Minutes()), fileList)
}

Expand All @@ -264,6 +282,12 @@ func runActionlintOnFile(lockFiles []string, verbose bool, strict bool) error {
totalErrors, errorsByKind, parseErr := parseAndDisplayActionlintOutput(stdout.String(), verbose)
if parseErr != nil {
actionlintLog.Printf("Failed to parse actionlint output: %v", parseErr)
// Track this as an integration error: output was produced but could not be parsed
if actionlintStats != nil {
actionlintStats.IntegrationErrors++
}
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(
"actionlint output could not be parsed — this is a tooling error, not a workflow validation failure: "+parseErr.Error()))
// Fall back to showing raw output
if stdout.Len() > 0 {
fmt.Fprint(os.Stderr, stdout.String())
Expand Down Expand Up @@ -299,19 +323,35 @@ func runActionlintOnFile(lockFiles []string, verbose bool, strict bool) error {
if len(lockFiles) == 1 {
fileDescription = filepath.Base(lockFiles[0])
}
// When the output could not be parsed (parseErr != nil), totalErrors will be
// 0 even though actionlint signalled failures via exit code 1. Produce an
// unambiguous message so the caller understands this is a tooling issue.
if parseErr != nil {
return fmt.Errorf("strict mode: actionlint exited with errors on %s but output could not be parsed — this is likely a tooling or integration error", fileDescription)
}
return fmt.Errorf("strict mode: actionlint found %d errors in %s - workflows must have no actionlint errors in strict mode", totalErrors, fileDescription)
}
// In non-strict mode, errors are logged but not treated as failures
return nil
}
// Other exit codes are actual errors
// Other exit codes indicate actual tooling/subprocess failures, not lint findings.
fileDescription := "workflows"
if len(lockFiles) == 1 {
fileDescription = filepath.Base(lockFiles[0])
}
if actionlintStats != nil {
actionlintStats.IntegrationErrors++
}
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(
fmt.Sprintf("actionlint failed with exit code %d on %s — this is a tooling error, not a workflow validation failure", exitCode, fileDescription)))
return fmt.Errorf("actionlint failed with exit code %d on %s", exitCode, fileDescription)
}
// Non-ExitError errors (e.g., command not found)
// Non-ExitError errors (e.g., command not found) are integration/tooling failures.
if actionlintStats != nil {
actionlintStats.IntegrationErrors++
}
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(
"actionlint could not be invoked — this is a tooling error, not a workflow validation failure: "+err.Error()))
return fmt.Errorf("actionlint failed: %w", err)
}

Expand Down
48 changes: 45 additions & 3 deletions pkg/cli/actionlint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,10 @@ func TestGetActionlintVersion(t *testing.T) {

func TestDisplayActionlintSummary(t *testing.T) {
tests := []struct {
name string
stats *ActionlintStats
expectedContains []string
name string
stats *ActionlintStats
expectedContains []string
notExpectedContains []string
}{
{
name: "summary with errors and warnings",
Expand Down Expand Up @@ -193,6 +194,42 @@ func TestDisplayActionlintSummary(t *testing.T) {
stats: nil,
expectedContains: []string{},
},
// Regression tests: integration failures must never produce "No issues found"
{
name: "integration errors only - no lint issues",
stats: &ActionlintStats{
TotalWorkflows: 3,
TotalErrors: 0,
TotalWarnings: 0,
IntegrationErrors: 2,
ErrorsByKind: map[string]int{},
},
expectedContains: []string{
"Actionlint Summary",
"Checked 3 workflow(s)",
"2 actionlint invocation(s) failed",
"tooling or integration error",
},
notExpectedContains: []string{
"No issues found",
},
},
{
name: "integration errors alongside lint issues",
stats: &ActionlintStats{
TotalWorkflows: 4,
TotalErrors: 5,
TotalWarnings: 0,
IntegrationErrors: 1,
ErrorsByKind: map[string]int{"syntax": 5},
},
expectedContains: []string{
"Actionlint Summary",
"Checked 4 workflow(s)",
"Found 5 issue(s)",
"1 actionlint invocation(s) also failed with tooling errors",
},
},
}

for _, tt := range tests {
Expand All @@ -207,6 +244,10 @@ func TestDisplayActionlintSummary(t *testing.T) {
assert.Contains(t, output, expected,
"output should contain %q", expected)
}
for _, notExpected := range tt.notExpectedContains {
assert.NotContains(t, output, notExpected,
"output must not contain %q", notExpected)
}
})
}
}
Expand All @@ -221,6 +262,7 @@ func TestInitActionlintStats(t *testing.T) {
assert.Zero(t, actionlintStats.TotalWorkflows, "TotalWorkflows should start at 0")
assert.Zero(t, actionlintStats.TotalErrors, "TotalErrors should start at 0")
assert.Zero(t, actionlintStats.TotalWarnings, "TotalWarnings should start at 0")
assert.Zero(t, actionlintStats.IntegrationErrors, "IntegrationErrors should start at 0")
assert.NotNil(t, actionlintStats.ErrorsByKind, "ErrorsByKind map should be initialized")
assert.Empty(t, actionlintStats.ErrorsByKind, "ErrorsByKind should start empty")
}
Expand Down
Loading