Skip to content
Closed
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
2 changes: 1 addition & 1 deletion .github/workflows/daily-model-inventory.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 1 addition & 8 deletions pkg/workflow/antigravity_mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,9 @@ package workflow

import (
"strings"

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

var antigravityMCPLog = logger.New("workflow:antigravity_mcp")

// RenderMCPConfig renders MCP server configuration for Antigravity CLI
func (e *AntigravityEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]any, mcpTools []string, workflowData *WorkflowData) error {
antigravityMCPLog.Printf("Rendering MCP config for Antigravity: tool_count=%d, mcp_tool_count=%d", len(tools), len(mcpTools))

// Antigravity uses JSON format without Copilot-specific fields and multi-line args
return renderDefaultJSONMCPConfig(yaml, tools, mcpTools, workflowData, "${RUNNER_TEMP}/gh-aw/mcp-config/mcp-servers.json")
return renderJSONMCPConfigForEngine("Antigravity", yaml, tools, mcpTools, workflowData, "${RUNNER_TEMP}/gh-aw/mcp-config/mcp-servers.json")
}
2 changes: 1 addition & 1 deletion pkg/workflow/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -567,7 +567,7 @@ func generateCacheMemoryGitSetupStep(builder *strings.Builder, cache CacheMemory
// Single quotes in the value are escaped ('' in YAML single-quoted scalars) as defense-in-depth,
// even though isValidFileExtension already rejects values containing single quotes at parse time.
if len(cache.AllowedExtensions) > 0 {
escaped := strings.ReplaceAll(strings.Join(cache.AllowedExtensions, ":"), "'", "''")
escaped := escapeYAMLSingleQuotedScalar(strings.Join(cache.AllowedExtensions, ":"))
fmt.Fprintf(builder, " GH_AW_ALLOWED_EXTENSIONS: '%s'\n", escaped)
}
builder.WriteString(" run: bash \"${RUNNER_TEMP}/gh-aw/actions/setup_cache_memory_git.sh\"\n")
Expand Down
63 changes: 14 additions & 49 deletions pkg/workflow/call_workflow_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (

"github.com/github/gh-aw/pkg/constants"
"github.com/github/gh-aw/pkg/parser"
"github.com/goccy/go-yaml"
)

var callWorkflowValidationLog = newValidationLogger("call_workflow")
Expand Down Expand Up @@ -74,75 +73,40 @@ func (c *Compiler) validateCallWorkflow(data *WorkflowData, workflowPath string)
githubDir := filepath.Dir(currentDir)
repoRoot := filepath.Dir(githubDir)
workflowsDir := filepath.Join(repoRoot, constants.GetWorkflowDir())

notFoundErr := fmt.Errorf("call-workflow: workflow '%s' not found in %s\n\nChecked for: %s.md, %s.lock.yml, %s.yml\n\nTo fix:\n1. Verify the workflow file exists in %s/\n2. Ensure the filename matches exactly (case-sensitive)\n3. Use the filename without extension in your configuration", workflowName, workflowsDir, workflowName, workflowName, workflowName, workflowsDir)
notFoundErr := formatWorkflowNotFoundError("call-workflow", workflowName, workflowsDir)
if returnErr := collector.Add(notFoundErr); returnErr != nil {
return returnErr
}
continue
}

// Validate that the workflow supports workflow_call.
// Priority: .lock.yml > .yml > .md (same-batch compilation target)
if fileResult.lockExists {
workflowContent, readErr := os.ReadFile(fileResult.lockPath) // #nosec G304 -- lockPath is validated via isPathWithinDir() in findWorkflowFile() before being returned
if readErr != nil {
fileReadErr := fmt.Errorf("call-workflow: failed to read workflow file %s: %w", fileResult.lockPath, readErr)
// Validate trigger: .lock.yml and .yml take priority over .md
v := readAndValidateWorkflowFileTrigger(fileResult, "workflow_call")
if v.filePath != "" {
// A compiled file (.lock.yml or .yml) was found
if v.readErr != nil {
fileReadErr := fmt.Errorf("call-workflow: failed to read workflow file %s: %w", v.filePath, v.readErr)
if returnErr := collector.Add(fileReadErr); returnErr != nil {
return returnErr
}
continue
}
var workflow map[string]any
if err := yaml.Unmarshal(workflowContent, &workflow); err != nil {
parseErr := fmt.Errorf("call-workflow: failed to parse workflow file %s: %w", fileResult.lockPath, err)
if v.parseErr != nil {
parseErr := fmt.Errorf("call-workflow: failed to parse workflow file %s: %w", v.filePath, v.parseErr)
if returnErr := collector.Add(parseErr); returnErr != nil {
return returnErr
}
continue
}
onSection, hasOn := workflow["on"]
if !hasOn {
if v.noOnSection {
onErr := fmt.Errorf("call-workflow: workflow '%s' does not have an 'on' trigger section", workflowName)
if returnErr := collector.Add(onErr); returnErr != nil {
return returnErr
}
continue
}
if !containsWorkflowCall(onSection) {
callErr := fmt.Errorf("call-workflow: workflow '%s' does not support workflow_call trigger (must include 'workflow_call' in the 'on' section)", workflowName)
if returnErr := collector.Add(callErr); returnErr != nil {
return returnErr
}
continue
}
} else if fileResult.ymlExists {
workflowContent, readErr := os.ReadFile(fileResult.ymlPath) // #nosec G304 -- ymlPath is validated via isPathWithinDir() in findWorkflowFile() before being returned
if readErr != nil {
fileReadErr := fmt.Errorf("call-workflow: failed to read workflow file %s: %w", fileResult.ymlPath, readErr)
if returnErr := collector.Add(fileReadErr); returnErr != nil {
return returnErr
}
continue
}
var workflow map[string]any
if err := yaml.Unmarshal(workflowContent, &workflow); err != nil {
parseErr := fmt.Errorf("call-workflow: failed to parse workflow file %s: %w", fileResult.ymlPath, err)
if returnErr := collector.Add(parseErr); returnErr != nil {
return returnErr
}
continue
}
onSection, hasOn := workflow["on"]
if !hasOn {
onErr := fmt.Errorf("call-workflow: workflow '%s' does not have an 'on' trigger section", workflowName)
if returnErr := collector.Add(onErr); returnErr != nil {
return returnErr
}
continue
}
if !containsWorkflowCall(onSection) {
callErr := fmt.Errorf("call-workflow: workflow '%s' does not support workflow_call trigger (must include 'workflow_call' in the 'on' section)", workflowName)
if !v.hasTrigger {
callErr := formatMissingTriggerError("call-workflow", workflowName, "workflow_call")
if returnErr := collector.Add(callErr); returnErr != nil {
return returnErr
}
Expand All @@ -160,7 +124,7 @@ func (c *Compiler) validateCallWorkflow(data *WorkflowData, workflowPath string)
continue
}
if !mdHasCall {
callErr := fmt.Errorf("call-workflow: workflow '%s' does not support workflow_call trigger (must include 'workflow_call' in the 'on' section)", workflowName)
callErr := formatMissingTriggerError("call-workflow", workflowName, "workflow_call")
if returnErr := collector.Add(callErr); returnErr != nil {
return returnErr
}
Expand All @@ -179,6 +143,7 @@ func (c *Compiler) validateCallWorkflow(data *WorkflowData, workflowPath string)
return collector.FormattedError("call-workflow")
}


// extractWorkflowCallInputs parses a workflow file and extracts the workflow_call inputs schema.
// Returns a map of input definitions that can be used to generate MCP tool schemas.
func extractWorkflowCallInputs(workflowPath string) (map[string]any, error) {
Expand Down
8 changes: 2 additions & 6 deletions pkg/workflow/central_slash_command_workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -309,8 +309,8 @@ jobs:
- name: Route slash command
uses: ` + getActionPin("actions/github-script") + `
env:
GH_AW_SLASH_ROUTING: '` + escapeSingleQuotedYAMLString(string(slashRoutesJSON)) + `'
GH_AW_LABEL_ROUTING: '` + escapeSingleQuotedYAMLString(string(labelRoutesJSON)) + `'
GH_AW_SLASH_ROUTING: '` + escapeYAMLSingleQuotedScalar(string(slashRoutesJSON)) + `'
GH_AW_LABEL_ROUTING: '` + escapeYAMLSingleQuotedScalar(string(labelRoutesJSON)) + `'
with:
script: |
const { setupGlobals } = require('` + SetupActionDestination + `/setup_globals.cjs');
Expand Down Expand Up @@ -496,7 +496,3 @@ func uniqueSorted(values []string) []string {
sort.Strings(result)
return result
}

func escapeSingleQuotedYAMLString(input string) string {
return strings.ReplaceAll(input, "'", "''")
}
9 changes: 1 addition & 8 deletions pkg/workflow/claude_mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,9 @@ package workflow

import (
"strings"

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

var claudeMCPLog = logger.New("workflow:claude_mcp")

// RenderMCPConfig renders the MCP configuration for Claude engine
func (e *ClaudeEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]any, mcpTools []string, workflowData *WorkflowData) error {
claudeMCPLog.Printf("Rendering MCP config for Claude: tool_count=%d, mcp_tool_count=%d", len(tools), len(mcpTools))

// Claude uses JSON format without Copilot-specific fields and multi-line args
return renderDefaultJSONMCPConfig(yaml, tools, mcpTools, workflowData, "${RUNNER_TEMP}/gh-aw/mcp-config/mcp-servers.json")
return renderJSONMCPConfigForEngine("Claude", yaml, tools, mcpTools, workflowData, "${RUNNER_TEMP}/gh-aw/mcp-config/mcp-servers.json")
}
2 changes: 1 addition & 1 deletion pkg/workflow/compiler_experiments.go
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,7 @@ func (c *Compiler) generatePickExperimentStep(data *WorkflowData, experimentName
" id: pick-experiment\n",
fmt.Sprintf(" uses: %s\n", getCachedActionPin("actions/github-script", data)),
" env:\n",
fmt.Sprintf(" GH_AW_EXPERIMENT_SPEC: '%s'\n", strings.ReplaceAll(specJSON, "'", "''")),
fmt.Sprintf(" GH_AW_EXPERIMENT_SPEC: '%s'\n", escapeYAMLSingleQuotedScalar(specJSON)),
fmt.Sprintf(" GH_AW_EXPERIMENT_STATE_FILE: %s\n", experimentStateFile),
fmt.Sprintf(" GH_AW_EXPERIMENT_STATE_DIR: %s\n", experimentsCacheDir),
" with:\n",
Expand Down
2 changes: 1 addition & 1 deletion pkg/workflow/compiler_yaml.go
Original file line number Diff line number Diff line change
Expand Up @@ -899,7 +899,7 @@ func (c *Compiler) generateCreateAwInfo(yaml *strings.Builder, data *WorkflowDat
if data.EngineConfig != nil && data.EngineConfig.TokenWeights != nil && len(data.EngineConfig.TokenWeights.Multipliers) > 0 {
if tokenWeightsJSON, err := json.Marshal(data.EngineConfig.TokenWeights); err == nil {
// Escape single quotes for YAML single-quoted scalar safety
escapedTokenWeightsJSON := strings.ReplaceAll(string(tokenWeightsJSON), "'", "''")
escapedTokenWeightsJSON := escapeYAMLSingleQuotedScalar(string(tokenWeightsJSON))
fmt.Fprintf(yaml, " GH_AW_INFO_TOKEN_WEIGHTS: '%s'\n", escapedTokenWeightsJSON)
}
}
Expand Down
9 changes: 1 addition & 8 deletions pkg/workflow/crush_mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,9 @@ package workflow

import (
"strings"

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

var crushMCPLog = logger.New("workflow:crush_mcp")

// RenderMCPConfig renders MCP server configuration for Crush CLI
func (e *CrushEngine) RenderMCPConfig(sb *strings.Builder, tools map[string]any, mcpTools []string, workflowData *WorkflowData) error {
crushMCPLog.Printf("Rendering MCP config for Crush: tool_count=%d, mcp_tool_count=%d", len(tools), len(mcpTools))

// Crush uses JSON format without Copilot-specific fields and multi-line args
return renderDefaultJSONMCPConfig(sb, tools, mcpTools, workflowData, "/tmp/gh-aw/mcp-config/mcp-servers.json")
return renderJSONMCPConfigForEngine("Crush", sb, tools, mcpTools, workflowData, "/tmp/gh-aw/mcp-config/mcp-servers.json")
}
56 changes: 56 additions & 0 deletions pkg/workflow/dispatch_workflow_file_resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/github/gh-aw/pkg/constants"
"github.com/github/gh-aw/pkg/fileutil"
"github.com/github/gh-aw/pkg/parser"
"github.com/goccy/go-yaml"
)

// getCurrentWorkflowName extracts the workflow name from the file path
Expand Down Expand Up @@ -126,3 +127,58 @@ func extractMDWorkflowDispatchInputs(mdPath string) (map[string]any, error) {
dispatchWorkflowValidationLog.Printf("Extracted %d workflow_dispatch input(s) from: %s", len(inputs), mdPath)
return inputs, nil
}

// workflowFileValidation holds the result of readAndValidateWorkflowFileTrigger.
type workflowFileValidation struct {
// filePath is the compiled file that was read (.lock.yml or .yml).
// Empty when neither file exists (md-only case).
filePath string
// readErr is set when the file could not be read.
readErr error
// parseErr is set when the file content could not be parsed as YAML.
parseErr error
// noOnSection is true when the workflow has no 'on:' section at all.
noOnSection bool
// hasTrigger is true when the workflow's 'on:' section includes triggerName.
hasTrigger bool
}

// readAndValidateWorkflowFileTrigger reads the compiled workflow file (preferring
// .lock.yml over .yml) from fileResult and checks whether it declares triggerName.
// Returns an empty workflowFileValidation (filePath == "") when neither compiled
// file exists; callers handle the .md-only case separately.
func readAndValidateWorkflowFileTrigger(fileResult *findWorkflowFileResult, triggerName string) *workflowFileValidation {
result := &workflowFileValidation{}

if !fileResult.lockExists && !fileResult.ymlExists {
return result // md-only case — caller handles it
}

if fileResult.lockExists {
result.filePath = fileResult.lockPath
} else {
result.filePath = fileResult.ymlPath
}

content, err := os.ReadFile(result.filePath) // #nosec G304 -- paths are validated via isPathWithinDir() in findWorkflowFile()
if err != nil {
result.readErr = err
return result
}

var workflow map[string]any
if err = yaml.Unmarshal(content, &workflow); err != nil {
result.parseErr = err
return result
}

onSection, hasOn := workflow["on"]
if !hasOn {
result.noOnSection = true
return result
}

result.hasTrigger = containsTrigger(onSection, triggerName)
return result
}

Loading