diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 48a96b7333..6bcaec47fe 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -443,8 +443,20 @@ func (c *Compiler) generateAndValidateYAML(workflowData *WorkflowData, markdownP if !c.skipValidation { log.Print("Validating workflow against GitHub Actions schema") if err := c.validateGitHubActionsSchema(yamlContent); err != nil { + // Try to point at the exact line of the failing field in the source markdown. + // extractSchemaErrorField unwraps the error chain to find the top-level field + // name (e.g. "timeout-minutes"), which findFrontmatterFieldLine then locates in + // the source frontmatter so the error is IDE-navigable. + fieldLine := 1 + if fieldName := extractSchemaErrorField(err); fieldName != "" { + frontmatterLines := strings.Split(workflowData.FrontmatterYAML, "\n") + if line := findFrontmatterFieldLine(frontmatterLines, 2, fieldName); line > 0 { + fieldLine = line + } + } // Store error first so we can write invalid YAML before returning - formattedErr := formatCompilerError(markdownPath, "error", fmt.Sprintf("workflow schema validation failed: %v", err), err) + formattedErr := formatCompilerErrorWithPosition(markdownPath, fieldLine, 1, "error", + fmt.Sprintf("invalid workflow: %v", err), err) // Write the invalid YAML to a .invalid.yml file for inspection invalidFile := strings.TrimSuffix(lockFile, ".lock.yml") + ".invalid.yml" if writeErr := os.WriteFile(invalidFile, []byte(yamlContent), 0644); writeErr == nil { diff --git a/pkg/workflow/schema_validation.go b/pkg/workflow/schema_validation.go index 3e73178a60..fb99ea5a5b 100644 --- a/pkg/workflow/schema_validation.go +++ b/pkg/workflow/schema_validation.go @@ -154,6 +154,21 @@ func extractFieldPath(location []string) string { return location[len(location)-1] // Return the last element as the field name } +// extractSchemaErrorField extracts the top-level field name from a schema validation error. +// It unwraps the error chain to find a *jsonschema.ValidationError and returns the first +// element of InstanceLocation, which is the top-level frontmatter key (e.g. "timeout-minutes"). +// Returns "" if no field name can be extracted. +func extractSchemaErrorField(err error) string { + var ve *jsonschema.ValidationError + if !errors.As(err, &ve) { + return "" + } + if len(ve.InstanceLocation) == 0 { + return "" + } + return ve.InstanceLocation[0] // top-level field name +} + // getFieldExample returns an example for the given field based on the validation error func getFieldExample(fieldPath string, err error) string { // Map of common fields to their examples diff --git a/pkg/workflow/schema_validation_test.go b/pkg/workflow/schema_validation_test.go index 438340806e..b6ff913d53 100644 --- a/pkg/workflow/schema_validation_test.go +++ b/pkg/workflow/schema_validation_test.go @@ -3,8 +3,12 @@ package workflow import ( + "errors" + "fmt" "strings" "testing" + + "github.com/santhosh-tekuri/jsonschema/v6" ) func TestExtractFieldPath(t *testing.T) { @@ -236,3 +240,47 @@ jobs: }) } } + +func TestExtractSchemaErrorField(t *testing.T) { + tests := []struct { + name string + err error + expected string + }{ + { + name: "top-level field from ValidationError", + err: fmt.Errorf("wrapped: %w", &jsonschema.ValidationError{ + InstanceLocation: []string{"timeout-minutes"}, + }), + expected: "timeout-minutes", + }, + { + name: "nested field returns top-level", + err: fmt.Errorf("wrapped: %w", &jsonschema.ValidationError{ + InstanceLocation: []string{"jobs", "build", "runs-on"}, + }), + expected: "jobs", + }, + { + name: "non-ValidationError returns empty", + err: errors.New("some other error"), + expected: "", + }, + { + name: "ValidationError with empty location returns empty", + err: fmt.Errorf("wrapped: %w", &jsonschema.ValidationError{ + InstanceLocation: []string{}, + }), + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractSchemaErrorField(tt.err) + if result != tt.expected { + t.Errorf("extractSchemaErrorField() = %q, want %q", result, tt.expected) + } + }) + } +}