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
14 changes: 13 additions & 1 deletion pkg/workflow/compiler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
15 changes: 15 additions & 0 deletions pkg/workflow/schema_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions pkg/workflow/schema_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@
package workflow

import (
"errors"
"fmt"
"strings"
"testing"

"github.com/santhosh-tekuri/jsonschema/v6"
)

func TestExtractFieldPath(t *testing.T) {
Expand Down Expand Up @@ -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)
}
})
}
}
Loading