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
11 changes: 10 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,19 @@ deps:

# Install development tools (including linter)
.PHONY: deps-dev
deps-dev: deps copy-copilot-to-claude
deps-dev: deps copy-copilot-to-claude download-github-actions-schema
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
npm ci

# Download GitHub Actions workflow schema for embedded validation
.PHONY: download-github-actions-schema
download-github-actions-schema:
@echo "Downloading GitHub Actions workflow schema..."
@mkdir -p pkg/workflow/schemas
@curl -s -o pkg/workflow/schemas/github-workflow.json \
"https://raw.githubusercontent.com/SchemaStore/schemastore/master/src/schemas/json/github-workflow.json"
@echo "βœ“ Downloaded GitHub Actions schema to pkg/workflow/schemas/github-workflow.json"

# Run linter
.PHONY: golint
golint:
Expand Down
2 changes: 1 addition & 1 deletion cmd/gh-aw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,7 @@ func init() {

// Add AI flag to compile and add commands
compileCmd.Flags().StringP("engine", "a", "", "Override AI engine (claude, codex)")
compileCmd.Flags().Bool("validate", false, "Enable GitHub Actions workflow schema validation")
compileCmd.Flags().Bool("validate", true, "Enable GitHub Actions workflow schema validation (default: true)")
compileCmd.Flags().BoolP("watch", "w", false, "Watch for changes to workflow files and recompile automatically")
compileCmd.Flags().Bool("instructions", false, "Generate or update GitHub Copilot instructions file")
compileCmd.Flags().Bool("no-emit", false, "Validate workflow without generating lock files")
Expand Down
51 changes: 43 additions & 8 deletions pkg/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"github.com/githubnext/gh-aw/pkg/constants"
"github.com/githubnext/gh-aw/pkg/parser"
"github.com/githubnext/gh-aw/pkg/workflow"
"github.com/goccy/go-yaml"
)

// Package-level version information
Expand Down Expand Up @@ -567,7 +568,41 @@ func AddWorkflowWithTracking(workflow string, number int, verbose bool, engineOv
return nil
}

// CompileWorkflows compiles markdown files into GitHub Actions workflow files
// CompileWorkflowWithValidation compiles a workflow with always-on YAML validation for CLI usage
func CompileWorkflowWithValidation(compiler *workflow.Compiler, filePath string, verbose bool) error {
// Compile the workflow first
if err := compiler.CompileWorkflow(filePath); err != nil {
return err
}

// Always validate that the generated lock file is valid YAML (CLI requirement)
lockFile := strings.TrimSuffix(filePath, ".md") + ".lock.yml"
if _, err := os.Stat(lockFile); err != nil {
// Lock file doesn't exist (likely due to no-emit), skip YAML validation
return nil
}

if verbose {
fmt.Println(console.FormatInfoMessage("Validating generated lock file YAML syntax..."))
}

lockContent, err := os.ReadFile(lockFile)
if err != nil {
return fmt.Errorf("failed to read generated lock file for validation: %w", err)
}

// Validate the lock file is valid YAML
var yamlValidationTest interface{}
if err := yaml.Unmarshal(lockContent, &yamlValidationTest); err != nil {
return fmt.Errorf("generated lock file is not valid YAML: %w", err)
}

if verbose {
fmt.Println(console.FormatSuccessMessage("Generated lock file YAML syntax validation passed"))
}

return nil
}
func CompileWorkflows(markdownFiles []string, verbose bool, engineOverride string, validate bool, watch bool, writeInstructions bool, noEmit bool) error {
// Create compiler with verbose flag and AI engine override
compiler := workflow.NewCompiler(verbose, engineOverride, GetVersion())
Expand Down Expand Up @@ -609,7 +644,7 @@ func CompileWorkflows(markdownFiles []string, verbose bool, engineOverride strin
if verbose {
fmt.Println(console.FormatInfoMessage(fmt.Sprintf("Compiling %s", resolvedFile)))
}
if err := compiler.CompileWorkflow(resolvedFile); err != nil {
if err := CompileWorkflowWithValidation(compiler, resolvedFile, verbose); err != nil {
return fmt.Errorf("failed to compile workflow '%s': %w", markdownFile, err)
}
compiledCount++
Expand Down Expand Up @@ -673,7 +708,7 @@ func CompileWorkflows(markdownFiles []string, verbose bool, engineOverride strin
if verbose {
fmt.Printf("Compiling: %s\n", file)
}
if err := compiler.CompileWorkflow(file); err != nil {
if err := CompileWorkflowWithValidation(compiler, file, verbose); err != nil {
return err
}
}
Expand Down Expand Up @@ -793,7 +828,7 @@ func watchAndCompileWorkflows(markdownFile string, compiler *workflow.Compiler,
if verbose {
fmt.Printf("πŸ”¨ Initial compilation of %s...\n", markdownFile)
}
if err := compiler.CompileWorkflow(markdownFile); err != nil {
if err := CompileWorkflowWithValidation(compiler, markdownFile, verbose); err != nil {
// Always show initial compilation errors, not just in verbose mode
fmt.Println(console.FormatWarningMessage(fmt.Sprintf("Initial compilation failed: %v", err)))
}
Expand Down Expand Up @@ -888,7 +923,7 @@ func compileAllWorkflowFiles(compiler *workflow.Compiler, workflowsDir string, v
if verbose {
fmt.Printf("πŸ”¨ Compiling: %s\n", file)
}
if err := compiler.CompileWorkflow(file); err != nil {
if err := CompileWorkflowWithValidation(compiler, file, verbose); err != nil {
// Always show compilation errors, not just in verbose mode
fmt.Println(err)
} else if verbose {
Expand Down Expand Up @@ -930,7 +965,7 @@ func compileModifiedFiles(compiler *workflow.Compiler, files []string, verbose b
fmt.Printf("πŸ”¨ Compiling: %s\n", file)
}

if err := compiler.CompileWorkflow(file); err != nil {
if err := CompileWorkflowWithValidation(compiler, file, verbose); err != nil {
// Always show compilation errors, not just in verbose mode
fmt.Println(err)
} else if verbose {
Expand Down Expand Up @@ -1450,7 +1485,7 @@ func updateWorkflowTitle(content string, number int) string {
func compileWorkflow(filePath string, verbose bool, engineOverride string) error {
// Create compiler and compile the workflow
compiler := workflow.NewCompiler(verbose, engineOverride, GetVersion())
if err := compiler.CompileWorkflow(filePath); err != nil {
if err := CompileWorkflowWithValidation(compiler, filePath, verbose); err != nil {
return err
}

Expand Down Expand Up @@ -1506,7 +1541,7 @@ func compileWorkflowWithTracking(filePath string, verbose bool, engineOverride s
// Create compiler and set the file tracker
compiler := workflow.NewCompiler(verbose, engineOverride, GetVersion())
compiler.SetFileTracker(tracker)
if err := compiler.CompileWorkflow(filePath); err != nil {
if err := CompileWorkflowWithValidation(compiler, filePath, verbose); err != nil {
return err
}

Expand Down
80 changes: 30 additions & 50 deletions pkg/workflow/compiler.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package workflow

import (
_ "embed"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"sort"
Expand All @@ -23,6 +23,9 @@ const (
OutputArtifactName = "safe_output.jsonl"
)

//go:embed schemas/github-workflow.json
var githubWorkflowSchema string

// FileTracker interface for tracking files created during compilation
type FileTracker interface {
TrackCreated(filePath string)
Expand Down Expand Up @@ -326,26 +329,30 @@ func (c *Compiler) CompileWorkflow(markdownPath string) error {
fmt.Println(console.FormatSuccessMessage(fmt.Sprintf("Generated YAML content (%d bytes)", len(yamlContent))))
}

// Validate generated YAML against GitHub Actions schema (unless skipped)
if c.verbose {
fmt.Println(console.FormatSuccessMessage(fmt.Sprintf("Generated YAML content (%d bytes)", len(yamlContent))))
}

// Validate against GitHub Actions schema (unless skipped)
if !c.skipValidation {
if c.verbose {
fmt.Println(console.FormatInfoMessage("Validating workflow against GitHub Actions schema..."))
}
if err := c.validateWorkflowSchema(yamlContent); err != nil {
if err := c.validateGitHubActionsSchema(yamlContent); err != nil {
formattedErr := console.FormatError(console.CompilerError{
Position: console.ErrorPosition{
File: markdownPath,
Line: 1,
Column: 1,
},
Type: "error",
Message: fmt.Sprintf("workflow validation failed: %v", err),
Message: fmt.Sprintf("workflow schema validation failed: %v", err),
})
return errors.New(formattedErr)
}

if c.verbose {
fmt.Println(console.FormatSuccessMessage("Workflow validation passed"))
fmt.Println(console.FormatSuccessMessage("GitHub Actions schema validation passed"))
}
} else if c.verbose {
fmt.Println(console.FormatWarningMessage("Schema validation available but skipped (use SetSkipValidation(false) to enable)"))
Expand Down Expand Up @@ -378,64 +385,37 @@ func (c *Compiler) CompileWorkflow(markdownPath string) error {
return nil
}

// httpURLLoader implements URLLoader for HTTP(S) URLs
type httpURLLoader struct {
client *http.Client
}

// Load implements URLLoader interface for HTTP URLs
func (h *httpURLLoader) Load(url string) (any, error) {
resp, err := h.client.Get(url)
if err != nil {
return nil, fmt.Errorf("failed to fetch URL %s: %w", url, err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to fetch URL %s: HTTP %d", url, resp.StatusCode)
}

var result interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode JSON from %s: %w", url, err)
}

return result, nil
}

// validateWorkflowSchema validates the generated YAML content against the GitHub Actions workflow schema
func (c *Compiler) validateWorkflowSchema(yamlContent string) error {
// Convert YAML to JSON for validation
// validateGitHubActionsSchema validates the generated YAML content against the GitHub Actions workflow schema
func (c *Compiler) validateGitHubActionsSchema(yamlContent string) error {
// Convert YAML to interface{} for JSON conversion
var workflowData interface{}
if err := yaml.Unmarshal([]byte(yamlContent), &workflowData); err != nil {
return fmt.Errorf("failed to parse generated YAML: %w", err)
return fmt.Errorf("failed to parse YAML for schema validation: %w", err)
}

// Convert to JSON
// Convert to JSON for schema validation
jsonData, err := json.Marshal(workflowData)
if err != nil {
return fmt.Errorf("failed to convert YAML to JSON: %w", err)
return fmt.Errorf("failed to convert YAML to JSON for validation: %w", err)
}

// Load GitHub Actions workflow schema from SchemaStore
schemaURL := "https://raw.githubusercontent.com/SchemaStore/schemastore/master/src/schemas/json/github-workflow.json"

// Create compiler with HTTP loader
loader := jsonschema.NewCompiler()
httpLoader := &httpURLLoader{
client: &http.Client{Timeout: 30 * time.Second},
// Parse the embedded schema
var schemaDoc interface{}
if err := json.Unmarshal([]byte(githubWorkflowSchema), &schemaDoc); err != nil {
return fmt.Errorf("failed to parse embedded GitHub Actions schema: %w", err)
}

// Configure the compiler to use HTTP loader for https and http schemes
schemeLoader := jsonschema.SchemeURLLoader{
"https": httpLoader,
"http": httpLoader,
// Create compiler and add the schema as a resource
loader := jsonschema.NewCompiler()
schemaURL := "https://json.schemastore.org/github-workflow.json"
if err := loader.AddResource(schemaURL, schemaDoc); err != nil {
return fmt.Errorf("failed to add schema resource: %w", err)
}
loader.UseLoader(schemeLoader)

// Compile the schema
schema, err := loader.Compile(schemaURL)
if err != nil {
return fmt.Errorf("failed to load GitHub Actions schema from %s: %w", schemaURL, err)
return fmt.Errorf("failed to compile GitHub Actions schema: %w", err)
}

// Validate the JSON data against the schema
Expand All @@ -445,7 +425,7 @@ func (c *Compiler) validateWorkflowSchema(yamlContent string) error {
}

if err := schema.Validate(jsonObj); err != nil {
return fmt.Errorf("workflow schema validation failed: %w", err)
return fmt.Errorf("GitHub Actions schema validation failed: %w", err)
}

return nil
Expand Down
61 changes: 51 additions & 10 deletions pkg/workflow/compiler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1997,41 +1997,82 @@ on: push`,
errMsg: "missing property 'jobs'",
},
{
name: "invalid workflow - invalid YAML",
name: "invalid workflow - invalid job structure",
yaml: `name: "Test Workflow"
on: push
jobs:
test: [invalid yaml structure`,
test:
invalid-property: value`,
wantErr: true,
errMsg: "failed to parse generated YAML",
errMsg: "validation failed",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := compiler.validateGitHubActionsSchema(tt.yaml)

if tt.wantErr {
if err == nil {
t.Errorf("validateGitHubActionsSchema() expected error but got none")
return
}
if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("validateGitHubActionsSchema() error = %v, expected to contain %v", err, tt.errMsg)
}
} else {
if err != nil {
t.Errorf("validateGitHubActionsSchema() unexpected error = %v", err)
}
}
})
}
}

func TestBasicYAMLValidation(t *testing.T) {
tests := []struct {
name string
yaml string
wantErr bool
errMsg string
}{
{
name: "invalid workflow - invalid job structure",
name: "valid YAML",
yaml: `name: "Test Workflow"
on: push
jobs:
test:
invalid-property: value`,
runs-on: ubuntu-latest`,
wantErr: false,
},
{
name: "invalid YAML syntax",
yaml: `name: "Test Workflow"
on: push
jobs:
test: [invalid yaml structure`,
wantErr: true,
errMsg: "validation failed",
errMsg: "sequence end token",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := compiler.validateWorkflowSchema(tt.yaml)
// Test basic YAML validation (this is now always performed)
var yamlTest interface{}
err := yaml.Unmarshal([]byte(tt.yaml), &yamlTest)

if tt.wantErr {
if err == nil {
t.Errorf("validateWorkflowSchema() expected error but got none")
t.Errorf("YAML validation expected error but got none")
return
}
if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("validateWorkflowSchema() error = %v, expected to contain %v", err, tt.errMsg)
t.Errorf("YAML validation error = %v, expected to contain %v", err, tt.errMsg)
}
} else {
if err != nil {
t.Errorf("validateWorkflowSchema() unexpected error = %v", err)
t.Errorf("YAML validation unexpected error = %v", err)
}
}
})
Expand Down
Loading