Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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: 4 additions & 15 deletions pkg/cli/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,12 @@ package cli

import "github.com/github/gh-aw/pkg/workflow"

// Package-level version information
var (
version = "dev"
)

func init() {
// Set the version in the workflow package so NewCompiler() auto-detects it
workflow.SetVersion(version)
}

// SetVersionInfo sets the version information for the CLI and workflow package
// SetVersionInfo sets version information for the workflow package.
func SetVersionInfo(v string) {
version = v
workflow.SetVersion(v) // Keep workflow package in sync
workflow.SetVersion(v)
}

// GetVersion returns the current version
// GetVersion returns the current version.
func GetVersion() string {
return version
return workflow.GetVersion()
}
27 changes: 27 additions & 0 deletions pkg/cli/version_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//go:build !integration

package cli

import (
"testing"

"github.com/github/gh-aw/pkg/workflow"
"github.com/stretchr/testify/assert"
)

func TestGetVersionDelegatesToWorkflowVersion(t *testing.T) {
originalVersion := workflow.GetVersion()
defer workflow.SetVersion(originalVersion)

workflow.SetVersion("workflow-direct")
assert.Equal(t, "workflow-direct", GetVersion(), "cli.GetVersion should read workflow version")
}

func TestSetVersionInfoUpdatesWorkflowVersion(t *testing.T) {
originalVersion := workflow.GetVersion()
defer workflow.SetVersion(originalVersion)

SetVersionInfo("set-version-info")
assert.Equal(t, "set-version-info", workflow.GetVersion(), "SetVersionInfo should set workflow version")
assert.Equal(t, "set-version-info", GetVersion(), "cli.GetVersion should return workflow version")
}
10 changes: 2 additions & 8 deletions pkg/workflow/dispatch_repository_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ func (c *Compiler) validateDispatchRepository(data *WorkflowData, workflowPath s
}

// Validate single repository format (skip if it looks like a GitHub Actions expression)
if hasRepository && !isGitHubActionsExpression(tool.Repository) {
if hasRepository && !hasExpressionMarker(tool.Repository) {
if !repoSlugPattern.MatchString(tool.Repository) {
repoFmtErr := fmt.Errorf("dispatch_repository: tool %q has invalid 'repository' format %q (expected 'owner/repo')", toolKey, tool.Repository)
if returnErr := collector.Add(repoFmtErr); returnErr != nil {
Expand All @@ -76,7 +76,7 @@ func (c *Compiler) validateDispatchRepository(data *WorkflowData, workflowPath s

// Validate allowed_repositories format
for _, repo := range tool.AllowedRepositories {
if isGitHubActionsExpression(repo) {
if hasExpressionMarker(repo) {
continue
}
// Allow glob patterns like "org/*"
Expand All @@ -99,9 +99,3 @@ func (c *Compiler) validateDispatchRepository(data *WorkflowData, workflowPath s

return collector.FormattedError("dispatch_repository")
}

// isGitHubActionsExpression returns true if the string contains a GitHub Actions
// expression syntax (${{ }}), which should not be validated as a static value.
func isGitHubActionsExpression(value string) bool {
return strings.Contains(value, "${{")
}
25 changes: 25 additions & 0 deletions pkg/workflow/expression_patterns.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,35 @@ package workflow

import (
"regexp"
"strings"

"github.com/github/gh-aw/pkg/logger"
)

// hasExpressionMarker reports whether s contains a GitHub Actions expression opening marker.
// This is a permissive check used in scenarios where partial expressions should be treated
// as dynamic values.
func hasExpressionMarker(s string) bool {
return strings.Contains(s, "${{")
}

// containsExpression reports whether s contains a complete non-empty GitHub Actions expression.
// A complete expression has a "${{" marker that appears before a closing "}}" marker
// with at least one character between them.
func containsExpression(s string) bool {
_, afterOpen, found := strings.Cut(s, "${{")
if !found {
return false
}
closeIdx := strings.Index(afterOpen, "}}")
return closeIdx > 0
Comment on lines +73 to +82
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

containsExpression can return true for strings that are not actually a complete ${{ ... }} expression because it just looks for the first ${{ and then the first }} anywhere later in the string. For example, it will treat ${{ '}}' (no closing marker) or a string with an unmatched opening marker followed by a later unrelated }} as containing a complete expression. Since this helper is now used for validation/quoting decisions, consider implementing it using the existing ExpressionPattern (or a small scanner that finds a properly paired ${{}}) so it only returns true when a real expression token is present.

Suggested change
// containsExpression reports whether s contains a complete non-empty GitHub Actions expression.
// A complete expression has a "${{" marker that appears before a closing "}}" marker
// with at least one character between them.
func containsExpression(s string) bool {
_, afterOpen, found := strings.Cut(s, "${{")
if !found {
return false
}
closeIdx := strings.Index(afterOpen, "}}")
return closeIdx > 0
// findExpressionEnd returns the index of the closing "}}" for the expression that starts at
// openIdx, or -1 if no properly closed expression exists. Closing markers inside quoted
// string literals are ignored.
func findExpressionEnd(s string, openIdx int) int {
start := openIdx + len("${{")
if start >= len(s) {
return -1
}
var quote byte
for i := start; i < len(s)-1; i++ {
ch := s[i]
if quote != 0 {
if ch == '\\' && i+1 < len(s) {
i++
continue
}
if ch == quote {
quote = 0
}
continue
}
switch ch {
case '\'', '"', '`':
quote = ch
case '}':
if s[i+1] == '}' && i > start {
return i
}
}
}
return -1
}
// containsExpression reports whether s contains a complete non-empty GitHub Actions expression.
// A complete expression has a "${{" marker that appears before a matching closing "}}"
// marker, with at least one character between them.
func containsExpression(s string) bool {
for searchFrom := 0; searchFrom < len(s); {
openRel := strings.Index(s[searchFrom:], "${{")
if openRel == -1 {
return false
}
openIdx := searchFrom + openRel
if findExpressionEnd(s, openIdx) != -1 {
return true
}
searchFrom = openIdx + len("${{")
}
return false

Copilot uses AI. Check for mistakes.
}

// isExpression reports whether the entire string s is a GitHub Actions expression.
func isExpression(s string) bool {
return strings.HasPrefix(s, "${{") && strings.HasSuffix(s, "}}")
}

var expressionPatternsLog = logger.New("workflow:expression_patterns")

func init() {
Expand Down
63 changes: 63 additions & 0 deletions pkg/workflow/expression_patterns_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -456,3 +456,66 @@ func TestRangePattern(t *testing.T) {
})
}
}

func TestExpressionHelpers(t *testing.T) {
tests := []struct {
name string
input string
wantHasMarker bool
wantContainsExpr bool
wantWholeExprOnly bool
}{
{
name: "full expression",
input: "${{ github.actor }}",
wantHasMarker: true,
wantContainsExpr: true,
wantWholeExprOnly: true,
},
{
name: "embedded expression",
input: "prefix ${{ github.actor }} suffix",
wantHasMarker: true,
wantContainsExpr: true,
wantWholeExprOnly: false,
},
{
name: "partial expression marker only",
input: "${{ github.actor",
wantHasMarker: true,
wantContainsExpr: false,
wantWholeExprOnly: false,
},
{
name: "empty expression body",
input: "${{}}",
// isExpression is a strict wrapper check, while containsExpression
// requires a non-empty expression body between markers.
wantHasMarker: true,
wantContainsExpr: false,
wantWholeExprOnly: true,
},
{
name: "wrong marker order",
input: "}} before ${{",
wantHasMarker: true,
wantContainsExpr: false,
wantWholeExprOnly: false,
},
{
name: "no expression",
input: "plain text",
wantHasMarker: false,
wantContainsExpr: false,
wantWholeExprOnly: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.wantHasMarker, hasExpressionMarker(tt.input), "hasExpressionMarker result mismatch")
assert.Equal(t, tt.wantContainsExpr, containsExpression(tt.input), "containsExpression result mismatch")
assert.Equal(t, tt.wantWholeExprOnly, isExpression(tt.input), "isExpression result mismatch")
})
}
}
8 changes: 4 additions & 4 deletions pkg/workflow/expression_safety_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ func validateSingleExpression(expression string, opts ExpressionValidationOption
rightExpr := strings.TrimSpace(orMatch[2])

leftErr := validateSingleExpression(leftExpr, opts)
leftIsSafe := leftErr == nil && !containsExpression(opts.UnauthorizedExpressions, leftExpr)
leftIsSafe := leftErr == nil && !containsExpressionInList(opts.UnauthorizedExpressions, leftExpr)

if leftIsSafe {
// Check if right side is a literal string (single, double, or backtick quotes)
Expand All @@ -242,7 +242,7 @@ func validateSingleExpression(expression string, opts ExpressionValidationOption
} else {
// If right side is also a safe expression, recursively check it
rightErr := validateSingleExpression(rightExpr, opts)
if rightErr == nil && !containsExpression(opts.UnauthorizedExpressions, rightExpr) {
if rightErr == nil && !containsExpressionInList(opts.UnauthorizedExpressions, rightExpr) {
allowed = true
}
}
Expand Down Expand Up @@ -296,7 +296,7 @@ func validateSingleExpression(expression string, opts ExpressionValidationOption
return nil
}

// containsExpression checks if an expression is in the list
func containsExpression(list *[]string, expr string) bool {
// containsExpressionInList checks if an expression is in the list.
func containsExpressionInList(list *[]string, expr string) bool {
return slices.Contains(*list, expr)
}
4 changes: 2 additions & 2 deletions pkg/workflow/frontmatter_extraction_metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ func (c *Compiler) extractToolsTimeout(tools map[string]any) (string, error) {
frontmatterMetadataLog.Printf("Extracting tools.timeout value: type=%T", timeoutValue)
// Handle GitHub Actions expression strings
if strVal, ok := timeoutValue.(string); ok {
if isExpressionString(strVal) {
if isExpression(strVal) {
frontmatterMetadataLog.Printf("Extracted tools.timeout as expression: %s", strVal)
return strVal, nil
}
Expand Down Expand Up @@ -224,7 +224,7 @@ func (c *Compiler) extractToolsStartupTimeout(tools map[string]any) (string, err
if timeoutValue, exists := tools["startup-timeout"]; exists {
// Handle GitHub Actions expression strings
if strVal, ok := timeoutValue.(string); ok {
if isExpressionString(strVal) {
if isExpression(strVal) {
return strVal, nil
}
return "", fmt.Errorf("tools.startup-timeout must be an integer or a GitHub Actions expression (e.g. '${{ inputs.startup-timeout }}'), got string %q", strVal)
Expand Down
4 changes: 2 additions & 2 deletions pkg/workflow/safe_outputs_max_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ func validateSafeOutputsMax(config *SafeOutputsConfig) error {
}

maxPtr, ok := maxField.Interface().(*string)
if !ok || maxPtr == nil || isExpressionString(*maxPtr) {
if !ok || maxPtr == nil || isExpression(*maxPtr) {
continue
}

Expand Down Expand Up @@ -94,7 +94,7 @@ func validateSafeOutputsMax(config *SafeOutputsConfig) error {

for _, toolName := range sortedToolNames {
tool := config.DispatchRepository.Tools[toolName]
if tool == nil || tool.Max == nil || isExpressionString(*tool.Max) {
if tool == nil || tool.Max == nil || isExpression(*tool.Max) {
continue
}

Expand Down
6 changes: 3 additions & 3 deletions pkg/workflow/safe_outputs_target_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@ func TestValidateTargetValue(t *testing.T) {
}
}

func TestIsGitHubExpression(t *testing.T) {
func TestContainsExpressionForTargetValidation(t *testing.T) {
tests := []struct {
name string
s string
Expand Down Expand Up @@ -434,9 +434,9 @@ func TestIsGitHubExpression(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isGitHubExpression(tt.s)
got := containsExpression(tt.s)
if got != tt.want {
t.Errorf("isGitHubExpression() = %v, want %v", got, tt.want)
t.Errorf("containsExpression() = %v, want %v", got, tt.want)
}
})
}
Expand Down
19 changes: 1 addition & 18 deletions pkg/workflow/safe_outputs_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ func validateTargetValue(configName, target string) error {
}

// Check if it's a GitHub Actions expression
if isGitHubExpression(target) {
if containsExpression(target) {
safeOutputsTargetValidationLog.Printf("Target for %s is a GitHub Actions expression", configName)
return nil
}
Expand All @@ -179,23 +179,6 @@ func validateTargetValue(configName, target string) error {
)
}

// isGitHubExpression checks if a string is a valid GitHub Actions expression
// A valid expression must have properly balanced ${{ and }} markers
func isGitHubExpression(s string) bool {
// Must contain both opening and closing markers
if !strings.Contains(s, "${{") || !strings.Contains(s, "}}") {
return false
}

// Basic validation: opening marker must come before closing marker
openIndex := strings.Index(s, "${{")
closeIndex := strings.Index(s, "}}")

// The closing marker must come after the opening marker
// and there must be something between them
return openIndex >= 0 && closeIndex > openIndex+3
}

var safeOutputsAllowWorkflowsValidationLog = newValidationLogger("safe_outputs_allow_workflows")

// validateSafeOutputsAllowWorkflows validates that allow-workflows: true requires
Expand Down
12 changes: 1 addition & 11 deletions pkg/workflow/shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func shellEscapeArg(arg string) string {
// so single-quoting would mangle the expression syntax (e.g., 'staging' inside
// ${{ env.X == 'staging' }} becomes '\''staging'\'' which GA cannot parse).
// Double-quoting preserves the expression for GA evaluation.
if containsGitHubActionsExpression(arg) {
if containsExpression(arg) {
shellLog.Print("Argument contains GitHub Actions expression, using double-quote wrapping")
escaped := strings.ReplaceAll(arg, `"`, `\"`)
return `"` + escaped + `"`
Expand All @@ -49,16 +49,6 @@ func shellEscapeArg(arg string) string {
return arg
}

// containsGitHubActionsExpression checks if a string contains GitHub Actions
// expressions (${{ ... }}). It verifies that ${{ appears before }}.
func containsGitHubActionsExpression(s string) bool {
openIdx := strings.Index(s, "${{")
if openIdx < 0 {
return false
}
return strings.Contains(s[openIdx:], "}}")
}

// buildDockerCommandWithExpandableVars builds a properly quoted docker command
// that allows ${VAR_NAME} variables to be expanded at runtime.
func buildDockerCommandWithExpandableVars(cmd string) string {
Expand Down
Loading
Loading