-
Notifications
You must be signed in to change notification settings - Fork 155
Add compile-time syntax validation for concurrency group expressions #15082
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
8483a43
21816cf
e2bc4de
ab7ffe5
ceefc87
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,346 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // This file provides validation for custom concurrency group expressions. | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // # Concurrency Group Expression Validation | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // This file validates that custom concurrency group expressions specified by users | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // have correct syntax and can be safely compiled into GitHub Actions workflows. | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // # Validation Functions | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // - validateConcurrencyGroupExpression() - Validates syntax of a single group expression | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // - extractGroupExpression() - Extracts group value from concurrency configuration | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // # Validation Coverage | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // The validation detects common syntactic errors at compile time: | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // - Unbalanced ${{ }} braces | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // - Missing closing braces | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // - Malformed GitHub Actions expressions | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // - Invalid logical operators placement | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // - Unclosed parentheses or quotes | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // # When to Add Validation Here | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Add validation to this file when: | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // - Adding new concurrency group syntax checks | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // - Detecting new types of expression syntax errors | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // - Improving error messages for concurrency configuration | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| package workflow | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| import ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| "fmt" | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| "regexp" | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| "strings" | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| "github.com/github/gh-aw/pkg/logger" | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| var concurrencyValidationLog = logger.New("workflow:concurrency_validation") | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| // validateConcurrencyGroupExpression validates the syntax of a custom concurrency group expression. | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // It checks for common syntactic errors that would cause runtime failures: | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // - Unbalanced ${{ }} braces | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // - Missing closing braces | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // - Malformed GitHub Actions expressions | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // - Invalid operator placement | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Returns an error if validation fails, nil otherwise. | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| func validateConcurrencyGroupExpression(group string) error { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if strings.TrimSpace(group) == "" { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return NewValidationError( | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| "concurrency", | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| "empty concurrency group expression", | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| "the concurrency group expression is empty or contains only whitespace", | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| "Provide a non-empty concurrency group name or expression. Example: 'my-workflow-${{ github.ref }}'", | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| concurrencyValidationLog.Printf("Validating concurrency group expression: %s", group) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Check for balanced ${{ }} braces | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if err := validateBalancedBraces(group); err != nil { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return err | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Extract and validate each GitHub Actions expression within ${{ }} | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if err := validateExpressionSyntax(group); err != nil { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return err | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| concurrencyValidationLog.Print("Concurrency group expression validation passed") | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return nil | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| // validateBalancedBraces checks that all ${{ }} braces are balanced and properly closed | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| func validateBalancedBraces(group string) error { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| openCount := 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| i := 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| positions := []int{} // Track positions of opening braces for error reporting | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| for i < len(group) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Check for opening ${{ | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if i+2 < len(group) && group[i:i+3] == "${{" { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| openCount++ | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| positions = append(positions, i) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| i += 3 | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| continue | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Check for closing }} | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if i+1 < len(group) && group[i:i+2] == "}}" { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if openCount == 0 { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return NewValidationError( | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| "concurrency", | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| "unbalanced closing braces", | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| fmt.Sprintf("found '}}' at position %d without matching opening '${{' in expression: %s", i, group), | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| "Ensure all '}}' have a corresponding opening '${{'. Check for typos or missing opening braces.", | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| openCount-- | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if len(positions) > 0 { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| positions = positions[:len(positions)-1] | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| i += 2 | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| continue | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| i++ | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| if openCount > 0 { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Find the position of the first unclosed opening brace | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| pos := positions[0] | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return NewValidationError( | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| "concurrency", | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| "unclosed expression braces", | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| fmt.Sprintf("found opening '${{' at position %d without matching closing '}}' in expression: %s", pos, group), | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| "Ensure all '${{' have a corresponding closing '}}'. Add the missing closing braces.", | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| return nil | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| // validateExpressionSyntax validates the syntax of expressions within ${{ }} | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| func validateExpressionSyntax(group string) error { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Pattern to extract content between ${{ and }} | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| expressionPattern := regexp.MustCompile(`\$\{\{([^}]*)\}\}`) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| matches := expressionPattern.FindAllStringSubmatch(group, -1) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| for _, match := range matches { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if len(match) < 2 { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| continue | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| exprContent := strings.TrimSpace(match[1]) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if exprContent == "" { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return NewValidationError( | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| "concurrency", | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| "empty expression content", | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| fmt.Sprintf("found empty expression '${{ }}' in concurrency group: %s", group), | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| "Provide a valid GitHub Actions expression inside '${{ }}'. Example: '${{ github.ref }}'", | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Check for common syntax errors | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if err := validateExpressionContent(exprContent, group); err != nil { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return err | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| return nil | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| // validateExpressionContent validates the content inside ${{ }} | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| func validateExpressionContent(expr string, fullGroup string) error { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Check for unbalanced parentheses | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| parenCount := 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| for i, ch := range expr { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| switch ch { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| case '(': | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| parenCount++ | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| case ')': | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| parenCount-- | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if parenCount < 0 { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return NewValidationError( | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| "concurrency", | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| "unbalanced parentheses in expression", | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| fmt.Sprintf("found closing ')' without matching opening '(' at position %d in expression: %s", i, expr), | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| "Ensure all parentheses are properly balanced in your concurrency group expression.", | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+157
to
+174
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| if parenCount > 0 { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return NewValidationError( | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| "concurrency", | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| "unclosed parentheses in expression", | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| fmt.Sprintf("found %d unclosed opening '(' in expression: %s", parenCount, expr), | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| "Add the missing closing ')' to balance parentheses in your expression.", | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Check for unbalanced quotes (single, double, backtick) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if err := validateBalancedQuotes(expr); err != nil { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return err | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Try to parse complex expressions with logical operators | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if containsLogicalOperators(expr) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if _, err := ParseExpression(expr); err != nil { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return NewValidationError( | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| "concurrency", | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| "invalid expression syntax", | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| fmt.Sprintf("failed to parse expression in concurrency group: %s", err.Error()), | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| fmt.Sprintf("Fix the syntax error in your concurrency group expression. Full expression: %s", fullGroup), | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+190
to
+201
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Try to parse complex expressions with logical operators | |
| if containsLogicalOperators(expr) { | |
| if _, err := ParseExpression(expr); err != nil { | |
| return NewValidationError( | |
| "concurrency", | |
| "invalid expression syntax", | |
| fmt.Sprintf("failed to parse expression in concurrency group: %s", err.Error()), | |
| fmt.Sprintf("Fix the syntax error in your concurrency group expression. Full expression: %s", fullGroup), | |
| ) | |
| } | |
| } | |
| // Parse all non-empty expressions to validate syntax | |
| trimmedExpr := strings.TrimSpace(expr) | |
| if trimmedExpr == "" { | |
| return nil | |
| } | |
| if _, err := ParseExpression(trimmedExpr); err != nil { | |
| return NewValidationError( | |
| "concurrency", | |
| "invalid expression syntax", | |
| fmt.Sprintf("failed to parse expression in concurrency group: %s", err.Error()), | |
| fmt.Sprintf("Fix the syntax error in your concurrency group expression. Full expression: %s", fullGroup), | |
| ) | |
| } |
Copilot
AI
Feb 12, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unclosed quotes can be missed when the final character is a backslash. In that case the loop hits ch == '\\\\', sets escaped = true, continues, and never runs the i == len(expr)-1 unclosed-quote checks. A concrete fix is to move 'unclosed quote' checks after the loop (based on inSingleQuote/inDoubleQuote/inBacktick), rather than relying on the last-iteration branch inside the loop.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The extraction regex
\\$\\{\\{([^}]*)\\}\\}fails to match valid expressions that contain a}character inside the expression (e.g., string literals or function calls producing/containing braces). In that case,matchesbecomes empty and the validator silently skips validating expression content (parentheses/quotes/parser), allowing syntax errors to pass. Consider replacing this regex-based extraction with a small scanner that finds${{and the corresponding}}using index searching (and/or reusing the brace-balance traversal) so expressions are always extracted and validated correctly.