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
19 changes: 19 additions & 0 deletions pkg/workflow/compiler.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,25 @@ func (c *Compiler) CompileWorkflowData(workflowData *WorkflowData, markdownPath
return errors.New(formattedErr)
}

// Validate expressions in runtime-import files at compile time
log.Printf("Validating runtime-import files")
// Go up from .github/workflows/file.md to repo root
workflowDir := filepath.Dir(markdownPath) // .github/workflows
githubDir := filepath.Dir(workflowDir) // .github
workspaceDir := filepath.Dir(githubDir) // repo root
if err := validateRuntimeImportFiles(workflowData.MarkdownContent, workspaceDir); err != nil {
formattedErr := console.FormatError(console.CompilerError{
Position: console.ErrorPosition{
File: markdownPath,
Line: 1,
Column: 1,
},
Type: "error",
Message: err.Error(),
})
return errors.New(formattedErr)
}

// Validate feature flags
log.Printf("Validating feature flags")
if err := validateFeatures(workflowData); err != nil {
Expand Down
117 changes: 117 additions & 0 deletions pkg/workflow/expression_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ package workflow

import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"

Expand Down Expand Up @@ -275,3 +277,118 @@ func validateSingleExpression(expression string, opts ExpressionValidationOption
func ValidateExpressionSafetyPublic(markdownContent string) error {
return validateExpressionSafety(markdownContent)
}

// extractRuntimeImportPaths extracts all runtime-import file paths from markdown content.
// Returns a list of file paths (not URLs) referenced in {{#runtime-import}} macros.
// URLs (http:// or https://) are excluded since they are validated separately.
func extractRuntimeImportPaths(markdownContent string) []string {
if markdownContent == "" {
return nil
}

var paths []string
seen := make(map[string]bool)

// Pattern to match {{#runtime-import filepath}} or {{#runtime-import? filepath}}
// Also handles line ranges like filepath:10-20
macroPattern := `\{\{#runtime-import\??[ \t]+([^\}]+)\}\}`
macroRe := regexp.MustCompile(macroPattern)
matches := macroRe.FindAllStringSubmatch(markdownContent, -1)

for _, match := range matches {
if len(match) > 1 {
pathWithRange := strings.TrimSpace(match[1])

// Remove line range if present (e.g., "file.md:10-20" -> "file.md")
filepath := pathWithRange
if colonIdx := strings.Index(pathWithRange, ":"); colonIdx > 0 {
// Check if what follows colon looks like a line range (digits-digits)
afterColon := pathWithRange[colonIdx+1:]
if regexp.MustCompile(`^\d+-\d+$`).MatchString(afterColon) {
filepath = pathWithRange[:colonIdx]
}
}

// Skip URLs - they don't need file validation
if strings.HasPrefix(filepath, "http://") || strings.HasPrefix(filepath, "https://") {
continue
}

// Add to list if not already seen
if !seen[filepath] {
paths = append(paths, filepath)
seen[filepath] = true
}
}
}

return paths
}

// validateRuntimeImportFiles validates expressions in all runtime-import files at compile time.
// This catches expression errors early, before the workflow runs.
// workspaceDir should be the root of the repository (containing .github folder).
func validateRuntimeImportFiles(markdownContent string, workspaceDir string) error {
expressionValidationLog.Print("Validating runtime-import files")

// Extract all runtime-import file paths
paths := extractRuntimeImportPaths(markdownContent)
if len(paths) == 0 {
expressionValidationLog.Print("No runtime-import files to validate")
return nil
}

expressionValidationLog.Printf("Found %d runtime-import file(s) to validate", len(paths))

var validationErrors []string

for _, filePath := range paths {
// Normalize the path to be relative to .github folder
normalizedPath := filePath
if strings.HasPrefix(normalizedPath, ".github/") {
normalizedPath = normalizedPath[8:] // Remove ".github/"
} else if strings.HasPrefix(normalizedPath, ".github\\") {
normalizedPath = normalizedPath[8:] // Remove ".github\" (Windows)
}
if strings.HasPrefix(normalizedPath, "./") {
normalizedPath = normalizedPath[2:] // Remove "./"
} else if strings.HasPrefix(normalizedPath, ".\\") {
normalizedPath = normalizedPath[2:] // Remove ".\" (Windows)
}

// Build absolute path to the file
githubFolder := filepath.Join(workspaceDir, ".github")
absolutePath := filepath.Join(githubFolder, normalizedPath)

// Check if file exists
if _, err := os.Stat(absolutePath); os.IsNotExist(err) {
// Skip validation for optional imports ({{#runtime-import? ...}})
// We can't determine if it's optional here, but missing files will be caught at runtime
expressionValidationLog.Printf("Skipping validation for non-existent file: %s", filePath)
continue
}

// Read the file content
content, err := os.ReadFile(absolutePath)
if err != nil {
validationErrors = append(validationErrors, fmt.Sprintf("%s: failed to read file: %v", filePath, err))
continue
}

// Validate expressions in the imported file
if err := validateExpressionSafety(string(content)); err != nil {
validationErrors = append(validationErrors, fmt.Sprintf("%s: %v", filePath, err))
} else {
expressionValidationLog.Printf("✓ Validated expressions in %s", filePath)
}
}

if len(validationErrors) > 0 {
expressionValidationLog.Printf("Runtime-import validation failed: %d file(s) with errors", len(validationErrors))
return fmt.Errorf("runtime-import files contain expression errors:\n\n%s",
strings.Join(validationErrors, "\n\n"))
}

expressionValidationLog.Print("All runtime-import files validated successfully")
return nil
}
Loading