Skip to content

Commit 867aa72

Browse files
authored
Standardize error wrapping in compiler to preserve error chains (#14435)
1 parent 457223e commit 867aa72

4 files changed

Lines changed: 103 additions & 36 deletions

File tree

pkg/workflow/agent_validation.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,11 +84,11 @@ func (c *Compiler) validateAgentFile(workflowData *WorkflowData, markdownPath st
8484
if _, err := os.Stat(fullAgentPath); err != nil {
8585
if os.IsNotExist(err) {
8686
return formatCompilerError(markdownPath, "error",
87-
fmt.Sprintf("agent file '%s' does not exist. Ensure the file exists in the repository and is properly imported.", agentPath))
87+
fmt.Sprintf("agent file '%s' does not exist. Ensure the file exists in the repository and is properly imported.", agentPath), nil)
8888
}
8989
// Other error (permissions, etc.)
9090
return formatCompilerError(markdownPath, "error",
91-
fmt.Sprintf("failed to access agent file '%s': %v", agentPath, err))
91+
fmt.Sprintf("failed to access agent file '%s': %v", agentPath, err), err)
9292
}
9393

9494
if c.verbose {
@@ -228,7 +228,7 @@ func (c *Compiler) validateWorkflowRunBranches(workflowData *WorkflowData, markd
228228

229229
if c.strictMode {
230230
// In strict mode, this is an error
231-
return formatCompilerError(markdownPath, "error", message)
231+
return formatCompilerError(markdownPath, "error", message, nil)
232232
}
233233

234234
// In normal mode, this is a warning

pkg/workflow/compiler.go

Lines changed: 33 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,12 @@ const (
3737
//go:embed schemas/github-workflow.json
3838
var githubWorkflowSchema string
3939

40-
// formatCompilerError creates a formatted compiler error message
40+
// formatCompilerError creates a formatted compiler error message with optional error wrapping
4141
// filePath: the file path to include in the error (typically markdownPath or lockFile)
4242
// errType: the error type ("error" or "warning")
4343
// message: the error message text
44-
func formatCompilerError(filePath string, errType string, message string) error {
44+
// cause: optional underlying error to wrap (use nil for validation errors)
45+
func formatCompilerError(filePath string, errType string, message string, cause error) error {
4546
formattedErr := console.FormatError(console.CompilerError{
4647
Position: console.ErrorPosition{
4748
File: filePath,
@@ -51,6 +52,13 @@ func formatCompilerError(filePath string, errType string, message string) error
5152
Type: errType,
5253
Message: message,
5354
})
55+
56+
// Wrap the underlying error if provided (preserves error chain)
57+
if cause != nil {
58+
return fmt.Errorf("%s: %w", formattedErr, cause)
59+
}
60+
61+
// Create new error for validation errors (no underlying cause)
5462
return errors.New(formattedErr)
5563
}
5664

@@ -84,8 +92,8 @@ func (c *Compiler) CompileWorkflow(markdownPath string) error {
8492
// Already formatted, return as-is
8593
return err
8694
}
87-
// Otherwise, create a basic formatted error
88-
return formatCompilerError(markdownPath, "error", err.Error())
95+
// Otherwise, create a basic formatted error with wrapping
96+
return formatCompilerError(markdownPath, "error", err.Error(), err)
8997
}
9098

9199
return c.CompileWorkflowData(workflowData, markdownPath)
@@ -97,7 +105,7 @@ func (c *Compiler) validateWorkflowData(workflowData *WorkflowData, markdownPath
97105
// Validate expression safety - check that all GitHub Actions expressions are in the allowed list
98106
log.Printf("Validating expression safety")
99107
if err := validateExpressionSafety(workflowData.MarkdownContent); err != nil {
100-
return formatCompilerError(markdownPath, "error", err.Error())
108+
return formatCompilerError(markdownPath, "error", err.Error(), err)
101109
}
102110

103111
// Validate expressions in runtime-import files at compile time
@@ -107,13 +115,13 @@ func (c *Compiler) validateWorkflowData(workflowData *WorkflowData, markdownPath
107115
githubDir := filepath.Dir(workflowDir) // .github
108116
workspaceDir := filepath.Dir(githubDir) // repo root
109117
if err := validateRuntimeImportFiles(workflowData.MarkdownContent, workspaceDir); err != nil {
110-
return formatCompilerError(markdownPath, "error", err.Error())
118+
return formatCompilerError(markdownPath, "error", err.Error(), err)
111119
}
112120

113121
// Validate feature flags
114122
log.Printf("Validating feature flags")
115123
if err := validateFeatures(workflowData); err != nil {
116-
return formatCompilerError(markdownPath, "error", err.Error())
124+
return formatCompilerError(markdownPath, "error", err.Error(), err)
117125
}
118126

119127
// Check for action-mode feature flag override
@@ -122,7 +130,7 @@ func (c *Compiler) validateWorkflowData(workflowData *WorkflowData, markdownPath
122130
if actionModeStr, ok := actionModeVal.(string); ok && actionModeStr != "" {
123131
mode := ActionMode(actionModeStr)
124132
if !mode.IsValid() {
125-
return formatCompilerError(markdownPath, "error", fmt.Sprintf("invalid action-mode feature flag '%s'. Must be 'dev', 'release', or 'script'", actionModeStr))
133+
return formatCompilerError(markdownPath, "error", fmt.Sprintf("invalid action-mode feature flag '%s'. Must be 'dev', 'release', or 'script'", actionModeStr), nil)
126134
}
127135
log.Printf("Overriding action mode from feature flag: %s", mode)
128136
c.SetActionMode(mode)
@@ -133,7 +141,7 @@ func (c *Compiler) validateWorkflowData(workflowData *WorkflowData, markdownPath
133141
// Validate dangerous permissions
134142
log.Printf("Validating dangerous permissions")
135143
if err := validateDangerousPermissions(workflowData); err != nil {
136-
return formatCompilerError(markdownPath, "error", err.Error())
144+
return formatCompilerError(markdownPath, "error", err.Error(), err)
137145
}
138146

139147
// Validate agent file exists if specified in engine config
@@ -145,25 +153,25 @@ func (c *Compiler) validateWorkflowData(workflowData *WorkflowData, markdownPath
145153
// Validate sandbox configuration
146154
log.Printf("Validating sandbox configuration")
147155
if err := validateSandboxConfig(workflowData); err != nil {
148-
return formatCompilerError(markdownPath, "error", err.Error())
156+
return formatCompilerError(markdownPath, "error", err.Error(), err)
149157
}
150158

151159
// Validate safe-outputs target configuration
152160
log.Printf("Validating safe-outputs target fields")
153161
if err := validateSafeOutputsTarget(workflowData.SafeOutputs); err != nil {
154-
return formatCompilerError(markdownPath, "error", err.Error())
162+
return formatCompilerError(markdownPath, "error", err.Error(), err)
155163
}
156164

157165
// Validate safe-outputs allowed-domains configuration
158166
log.Printf("Validating safe-outputs allowed-domains")
159167
if err := c.validateSafeOutputsAllowedDomains(workflowData.SafeOutputs); err != nil {
160-
return formatCompilerError(markdownPath, "error", err.Error())
168+
return formatCompilerError(markdownPath, "error", err.Error(), err)
161169
}
162170

163171
// Validate network allowed domains configuration
164172
log.Printf("Validating network allowed domains")
165173
if err := c.validateNetworkAllowedDomains(workflowData.NetworkPermissions); err != nil {
166-
return formatCompilerError(markdownPath, "error", err.Error())
174+
return formatCompilerError(markdownPath, "error", err.Error(), err)
167175
}
168176

169177
// Emit experimental warning for sandbox-runtime feature
@@ -207,7 +215,7 @@ func (c *Compiler) validateWorkflowData(workflowData *WorkflowData, markdownPath
207215
if len(validationResult.MissingPermissions) > 0 {
208216
if c.strictMode {
209217
// In strict mode, missing permissions are errors
210-
return formatCompilerError(markdownPath, "error", message)
218+
return formatCompilerError(markdownPath, "error", message, nil)
211219
} else {
212220
// In non-strict mode, missing permissions are warnings
213221
fmt.Fprintln(os.Stderr, formatCompilerMessage(markdownPath, "warning", message))
@@ -226,7 +234,7 @@ func (c *Compiler) validateWorkflowData(workflowData *WorkflowData, markdownPath
226234

227235
// Validate that all allowed tools have their toolsets enabled
228236
if err := ValidateGitHubToolsAgainstToolsets(allowedTools, enabledToolsets); err != nil {
229-
return formatCompilerError(markdownPath, "error", err.Error())
237+
return formatCompilerError(markdownPath, "error", err.Error(), err)
230238
}
231239

232240
// Print informational message if "projects" toolset is explicitly specified
@@ -258,14 +266,14 @@ func (c *Compiler) validateWorkflowData(workflowData *WorkflowData, markdownPath
258266
message += "permissions:\n"
259267
message += " actions: read"
260268

261-
return formatCompilerError(markdownPath, "error", message)
269+
return formatCompilerError(markdownPath, "error", message, nil)
262270
}
263271
}
264272

265273
// Validate dispatch-workflow configuration (independent of agentic-workflows tool)
266274
log.Print("Validating dispatch-workflow configuration")
267275
if err := c.validateDispatchWorkflow(workflowData, markdownPath); err != nil {
268-
return formatCompilerError(markdownPath, "error", fmt.Sprintf("dispatch-workflow validation failed: %v", err))
276+
return formatCompilerError(markdownPath, "error", fmt.Sprintf("dispatch-workflow validation failed: %v", err), err)
269277
}
270278

271279
return nil
@@ -277,15 +285,15 @@ func (c *Compiler) generateAndValidateYAML(workflowData *WorkflowData, markdownP
277285
// Generate the YAML content
278286
yamlContent, err := c.generateYAML(workflowData, markdownPath)
279287
if err != nil {
280-
return "", formatCompilerError(markdownPath, "error", fmt.Sprintf("failed to generate YAML: %v", err))
288+
return "", formatCompilerError(markdownPath, "error", fmt.Sprintf("failed to generate YAML: %v", err), err)
281289
}
282290

283291
// Always validate expression sizes - this is a hard limit from GitHub Actions (21KB)
284292
// that cannot be bypassed, so we validate it unconditionally
285293
log.Print("Validating expression sizes")
286294
if err := c.validateExpressionSizes(yamlContent); err != nil {
287295
// Store error first so we can write invalid YAML before returning
288-
formattedErr := formatCompilerError(markdownPath, "error", fmt.Sprintf("expression size validation failed: %v", err))
296+
formattedErr := formatCompilerError(markdownPath, "error", fmt.Sprintf("expression size validation failed: %v", err), err)
289297
// Write the invalid YAML to a .invalid.yml file for inspection
290298
invalidFile := strings.TrimSuffix(lockFile, ".lock.yml") + ".invalid.yml"
291299
if writeErr := os.WriteFile(invalidFile, []byte(yamlContent), 0644); writeErr == nil {
@@ -298,7 +306,7 @@ func (c *Compiler) generateAndValidateYAML(workflowData *WorkflowData, markdownP
298306
log.Print("Validating for template injection vulnerabilities")
299307
if err := validateNoTemplateInjection(yamlContent); err != nil {
300308
// Store error first so we can write invalid YAML before returning
301-
formattedErr := formatCompilerError(markdownPath, "error", err.Error())
309+
formattedErr := formatCompilerError(markdownPath, "error", err.Error(), err)
302310
// Write the invalid YAML to a .invalid.yml file for inspection
303311
invalidFile := strings.TrimSuffix(lockFile, ".lock.yml") + ".invalid.yml"
304312
if writeErr := os.WriteFile(invalidFile, []byte(yamlContent), 0644); writeErr == nil {
@@ -312,7 +320,7 @@ func (c *Compiler) generateAndValidateYAML(workflowData *WorkflowData, markdownP
312320
log.Print("Validating workflow against GitHub Actions schema")
313321
if err := c.validateGitHubActionsSchema(yamlContent); err != nil {
314322
// Store error first so we can write invalid YAML before returning
315-
formattedErr := formatCompilerError(markdownPath, "error", fmt.Sprintf("workflow schema validation failed: %v", err))
323+
formattedErr := formatCompilerError(markdownPath, "error", fmt.Sprintf("workflow schema validation failed: %v", err), err)
316324
// Write the invalid YAML to a .invalid.yml file for inspection
317325
invalidFile := strings.TrimSuffix(lockFile, ".lock.yml") + ".invalid.yml"
318326
if writeErr := os.WriteFile(invalidFile, []byte(yamlContent), 0644); writeErr == nil {
@@ -333,19 +341,19 @@ func (c *Compiler) generateAndValidateYAML(workflowData *WorkflowData, markdownP
333341
// Validate runtime packages (npx, uv)
334342
log.Print("Validating runtime packages")
335343
if err := c.validateRuntimePackages(workflowData); err != nil {
336-
return "", formatCompilerError(markdownPath, "error", fmt.Sprintf("runtime package validation failed: %v", err))
344+
return "", formatCompilerError(markdownPath, "error", fmt.Sprintf("runtime package validation failed: %v", err), err)
337345
}
338346

339347
// Validate firewall configuration (log-level enum)
340348
log.Print("Validating firewall configuration")
341349
if err := c.validateFirewallConfig(workflowData); err != nil {
342-
return "", formatCompilerError(markdownPath, "error", fmt.Sprintf("firewall configuration validation failed: %v", err))
350+
return "", formatCompilerError(markdownPath, "error", fmt.Sprintf("firewall configuration validation failed: %v", err), err)
343351
}
344352

345353
// Validate repository features (discussions, issues)
346354
log.Print("Validating repository features")
347355
if err := c.validateRepositoryFeatures(workflowData); err != nil {
348-
return "", formatCompilerError(markdownPath, "error", fmt.Sprintf("repository feature validation failed: %v", err))
356+
return "", formatCompilerError(markdownPath, "error", fmt.Sprintf("repository feature validation failed: %v", err), err)
349357
}
350358
} else if c.verbose {
351359
fmt.Fprintln(os.Stderr, console.FormatWarningMessage("Schema validation available but skipped (use SetSkipValidation(false) to enable)"))
@@ -377,7 +385,7 @@ func (c *Compiler) writeWorkflowOutput(lockFile, yamlContent string, markdownPat
377385
// Only write if content has changed
378386
if !contentUnchanged {
379387
if err := os.WriteFile(lockFile, []byte(yamlContent), 0644); err != nil {
380-
return formatCompilerError(lockFile, "error", fmt.Sprintf("failed to write lock file: %v", err))
388+
return formatCompilerError(lockFile, "error", fmt.Sprintf("failed to write lock file: %v", err), err)
381389
}
382390
log.Print("Lock file written successfully")
383391
}

pkg/workflow/compiler_error_formatting_test.go

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
package workflow
44

55
import (
6+
"fmt"
67
"testing"
78

89
"github.com/stretchr/testify/assert"
@@ -16,13 +17,15 @@ func TestFormatCompilerError(t *testing.T) {
1617
filePath string
1718
errType string
1819
message string
20+
cause error
1921
wantContain []string
2022
}{
2123
{
22-
name: "error type with simple message",
24+
name: "error type with simple message, no cause",
2325
filePath: "/path/to/workflow.md",
2426
errType: "error",
2527
message: "validation failed",
28+
cause: nil,
2629
wantContain: []string{
2730
"/path/to/workflow.md",
2831
"1:1",
@@ -31,22 +34,37 @@ func TestFormatCompilerError(t *testing.T) {
3134
},
3235
},
3336
{
34-
name: "warning type with detailed message",
37+
name: "warning type with detailed message, no cause",
3538
filePath: "/path/to/workflow.md",
3639
errType: "warning",
3740
message: "missing required permission",
41+
cause: nil,
3842
wantContain: []string{
3943
"/path/to/workflow.md",
4044
"1:1",
4145
"warning",
4246
"missing required permission",
4347
},
4448
},
49+
{
50+
name: "error with underlying cause",
51+
filePath: "/path/to/workflow.md",
52+
errType: "error",
53+
message: "failed to parse YAML",
54+
cause: fmt.Errorf("syntax error at line 42"),
55+
wantContain: []string{
56+
"/path/to/workflow.md",
57+
"1:1",
58+
"error",
59+
"failed to parse YAML",
60+
},
61+
},
4562
{
4663
name: "lock file path",
4764
filePath: "/path/to/workflow.lock.yml",
4865
errType: "error",
4966
message: "failed to write lock file",
67+
cause: nil,
5068
wantContain: []string{
5169
"/path/to/workflow.lock.yml",
5270
"1:1",
@@ -55,10 +73,11 @@ func TestFormatCompilerError(t *testing.T) {
5573
},
5674
},
5775
{
58-
name: "formatted message with error details",
76+
name: "formatted message with error details and cause",
5977
filePath: "test.md",
6078
errType: "error",
6179
message: "failed to generate YAML: syntax error",
80+
cause: fmt.Errorf("underlying error"),
6281
wantContain: []string{
6382
"test.md",
6483
"1:1",
@@ -70,20 +89,25 @@ func TestFormatCompilerError(t *testing.T) {
7089

7190
for _, tt := range tests {
7291
t.Run(tt.name, func(t *testing.T) {
73-
err := formatCompilerError(tt.filePath, tt.errType, tt.message)
92+
err := formatCompilerError(tt.filePath, tt.errType, tt.message, tt.cause)
7493
require.Error(t, err, "formatCompilerError should return an error")
7594

7695
errStr := err.Error()
7796
for _, want := range tt.wantContain {
7897
assert.Contains(t, errStr, want, "Error message should contain: %s", want)
7998
}
99+
100+
// If cause is provided, verify error wrapping
101+
if tt.cause != nil {
102+
assert.ErrorIs(t, err, tt.cause, "Error should wrap the cause")
103+
}
80104
})
81105
}
82106
}
83107

84108
// TestFormatCompilerError_OutputFormat verifies the output format remains consistent
85109
func TestFormatCompilerError_OutputFormat(t *testing.T) {
86-
err := formatCompilerError("/test/workflow.md", "error", "test message")
110+
err := formatCompilerError("/test/workflow.md", "error", "test message", nil)
87111
require.Error(t, err)
88112

89113
errStr := err.Error()
@@ -97,8 +121,8 @@ func TestFormatCompilerError_OutputFormat(t *testing.T) {
97121

98122
// TestFormatCompilerError_ErrorVsWarning tests differentiation between error and warning types
99123
func TestFormatCompilerError_ErrorVsWarning(t *testing.T) {
100-
errorErr := formatCompilerError("test.md", "error", "error message")
101-
warningErr := formatCompilerError("test.md", "warning", "warning message")
124+
errorErr := formatCompilerError("test.md", "error", "error message", nil)
125+
warningErr := formatCompilerError("test.md", "warning", "warning message", nil)
102126

103127
require.Error(t, errorErr)
104128
require.Error(t, warningErr)
@@ -155,3 +179,37 @@ func TestFormatCompilerMessage(t *testing.T) {
155179
})
156180
}
157181
}
182+
183+
// TestFormatCompilerError_ErrorWrapping verifies that error wrapping preserves error chains
184+
func TestFormatCompilerError_ErrorWrapping(t *testing.T) {
185+
// Create an underlying error
186+
underlyingErr := fmt.Errorf("underlying validation error")
187+
188+
// Wrap it with formatCompilerError
189+
wrappedErr := formatCompilerError("test.md", "error", "validation failed", underlyingErr)
190+
191+
require.Error(t, wrappedErr)
192+
193+
// Verify error chain is preserved
194+
require.ErrorIs(t, wrappedErr, underlyingErr, "Should preserve error chain with %w")
195+
196+
// Verify formatted message is in the error string
197+
assert.Contains(t, wrappedErr.Error(), "test.md")
198+
assert.Contains(t, wrappedErr.Error(), "validation failed")
199+
}
200+
201+
// TestFormatCompilerError_NilCause verifies that nil cause creates a new error
202+
func TestFormatCompilerError_NilCause(t *testing.T) {
203+
err := formatCompilerError("test.md", "error", "validation error", nil)
204+
205+
require.Error(t, err)
206+
207+
// Verify error message contains expected content
208+
assert.Contains(t, err.Error(), "test.md")
209+
assert.Contains(t, err.Error(), "validation error")
210+
211+
// Verify it's a new error (not wrapping anything)
212+
// This is a validation error, so it should not wrap
213+
dummyErr := fmt.Errorf("some other error")
214+
assert.NotErrorIs(t, err, dummyErr, "Should not wrap any error when cause is nil")
215+
}

0 commit comments

Comments
 (0)