diff --git a/.github/workflows/smoke-gemini.lock.yml b/.github/workflows/smoke-gemini.lock.yml index 6d077ada7c..d1d6955c67 100644 --- a/.github/workflows/smoke-gemini.lock.yml +++ b/.github/workflows/smoke-gemini.lock.yml @@ -970,6 +970,17 @@ jobs: path: /tmp/gh-aw/aw-prompts - name: Clean git credentials run: bash /opt/gh-aw/actions/clean_git_credentials.sh + - name: Write Gemini settings + run: | + mkdir -p "$GITHUB_WORKSPACE/.gemini" + SETTINGS="$GITHUB_WORKSPACE/.gemini/settings.json" + BASE_CONFIG='{"context":{"includeDirectories":["/tmp/"]},"tools":{"core":["glob","grep_search","list_directory","read_file","read_many_files","replace","run_shell_command","write_file"]}}' + if [ -f "$SETTINGS" ]; then + MERGED=$(jq -n --argjson base "$BASE_CONFIG" --argjson existing "$(cat "$SETTINGS")" '$existing * $base') + echo "$MERGED" > "$SETTINGS" + else + echo "$BASE_CONFIG" > "$SETTINGS" + fi - name: Execute Gemini CLI id: agentic_execution run: | @@ -1298,6 +1309,17 @@ jobs: package-manager-cache: false - name: Install Gemini CLI run: npm install -g --silent @google/gemini-cli@0.29.0 + - name: Write Gemini settings + run: | + mkdir -p "$GITHUB_WORKSPACE/.gemini" + SETTINGS="$GITHUB_WORKSPACE/.gemini/settings.json" + BASE_CONFIG='{"context":{"includeDirectories":["/tmp/"]},"tools":{"core":["glob","grep_search","list_directory","read_file","read_many_files","run_shell_command(cat)","run_shell_command(grep)","run_shell_command(head)","run_shell_command(jq)","run_shell_command(ls)","run_shell_command(tail)","run_shell_command(wc)"]}}' + if [ -f "$SETTINGS" ]; then + MERGED=$(jq -n --argjson base "$BASE_CONFIG" --argjson existing "$(cat "$SETTINGS")" '$existing * $base') + echo "$MERGED" > "$SETTINGS" + else + echo "$BASE_CONFIG" > "$SETTINGS" + fi - name: Execute Gemini CLI id: agentic_execution run: | diff --git a/actions/setup/sh/convert_gateway_config_gemini.sh b/actions/setup/sh/convert_gateway_config_gemini.sh index ce2aa4cdd2..4b2b14d571 100644 --- a/actions/setup/sh/convert_gateway_config_gemini.sh +++ b/actions/setup/sh/convert_gateway_config_gemini.sh @@ -95,8 +95,8 @@ jq --arg urlPrefix "$URL_PREFIX" ' .url |= (. | sub("^http://[^/]+/mcp/"; $urlPrefix + "/mcp/")) ) ) | - # Allow Gemini CLI to read files from /tmp/gh-aw/ (e.g. MCP payload files) - .includeDirectories = ["/tmp/gh-aw/"] + # Allow Gemini CLI to read/write files from /tmp/ (e.g. MCP payload files, cache-memory, agent outputs) + .context.includeDirectories = ["/tmp/"] ' "$MCP_GATEWAY_OUTPUT" > "$GEMINI_SETTINGS_FILE" echo "Gemini configuration written to $GEMINI_SETTINGS_FILE" diff --git a/pkg/workflow/enable_api_proxy_test.go b/pkg/workflow/enable_api_proxy_test.go index 6d86cc0a8f..e23329bc57 100644 --- a/pkg/workflow/enable_api_proxy_test.go +++ b/pkg/workflow/enable_api_proxy_test.go @@ -105,11 +105,12 @@ func TestEngineAWFEnableApiProxy(t *testing.T) { engine := NewGeminiEngine() steps := engine.GetExecutionSteps(workflowData, "test.log") - if len(steps) == 0 { - t.Fatal("Expected at least one execution step") + if len(steps) < 2 { + t.Fatal("Expected at least two execution steps (settings + execution)") } - stepContent := strings.Join(steps[0], "\n") + // steps[0] = Write Gemini settings, steps[1] = Execute Gemini CLI + stepContent := strings.Join(steps[1], "\n") if !strings.Contains(stepContent, "--enable-api-proxy") { t.Error("Expected Gemini AWF command to contain '--enable-api-proxy' flag") diff --git a/pkg/workflow/gemini_engine.go b/pkg/workflow/gemini_engine.go index 2d7c23a456..d6d746c0f0 100644 --- a/pkg/workflow/gemini_engine.go +++ b/pkg/workflow/gemini_engine.go @@ -174,6 +174,12 @@ func (e *GeminiEngine) GetExecutionSteps(workflowData *WorkflowData, logFile str var steps []GitHubActionStep + // Write .gemini/settings.json with context.includeDirectories and tools.core. + // This step runs after the MCP gateway setup (which may have written mcpServers config) + // and merges the context/tools settings into any existing settings.json. + settingsStep := e.generateGeminiSettingsStep(workflowData) + steps = append(steps, settingsStep) + // Build gemini CLI arguments based on configuration var geminiArgs []string diff --git a/pkg/workflow/gemini_engine_test.go b/pkg/workflow/gemini_engine_test.go index c1aaf40ba8..a7dcbee85d 100644 --- a/pkg/workflow/gemini_engine_test.go +++ b/pkg/workflow/gemini_engine_test.go @@ -145,9 +145,10 @@ func TestGeminiEngineExecution(t *testing.T) { } steps := engine.GetExecutionSteps(workflowData, "/tmp/test.log") - require.Len(t, steps, 1, "Should generate one execution step") + require.Len(t, steps, 2, "Should generate settings step and execution step") - stepContent := strings.Join(steps[0], "\n") + // steps[0] = Write Gemini settings, steps[1] = Execute Gemini CLI + stepContent := strings.Join(steps[1], "\n") assert.Contains(t, stepContent, "name: Execute Gemini CLI", "Should have correct step name") assert.Contains(t, stepContent, "id: agentic_execution", "Should have agentic_execution ID") @@ -168,9 +169,9 @@ func TestGeminiEngineExecution(t *testing.T) { } steps := engine.GetExecutionSteps(workflowData, "/tmp/test.log") - require.Len(t, steps, 1, "Should generate one execution step") + require.Len(t, steps, 2, "Should generate settings step and execution step") - stepContent := strings.Join(steps[0], "\n") + stepContent := strings.Join(steps[1], "\n") // Model is passed via the native GEMINI_MODEL env var (not as a --model flag) assert.Contains(t, stepContent, "GEMINI_MODEL: gemini-1.5-pro", "Should set GEMINI_MODEL env var") @@ -189,9 +190,9 @@ func TestGeminiEngineExecution(t *testing.T) { } steps := engine.GetExecutionSteps(workflowData, "/tmp/test.log") - require.Len(t, steps, 1, "Should generate one execution step") + require.Len(t, steps, 2, "Should generate settings step and execution step") - stepContent := strings.Join(steps[0], "\n") + stepContent := strings.Join(steps[1], "\n") // Gemini CLI reads MCP config from .gemini/settings.json, not --mcp-config flag assert.NotContains(t, stepContent, "--mcp-config", "Should NOT include --mcp-config flag (Gemini CLI does not support it)") @@ -207,9 +208,9 @@ func TestGeminiEngineExecution(t *testing.T) { } steps := engine.GetExecutionSteps(workflowData, "/tmp/test.log") - require.Len(t, steps, 1, "Should generate one execution step") + require.Len(t, steps, 2, "Should generate settings step and execution step") - stepContent := strings.Join(steps[0], "\n") + stepContent := strings.Join(steps[1], "\n") assert.Contains(t, stepContent, "/custom/gemini", "Should use custom command") }) @@ -220,9 +221,9 @@ func TestGeminiEngineExecution(t *testing.T) { } steps := engine.GetExecutionSteps(workflowData, "/tmp/test.log") - require.Len(t, steps, 1, "Should generate one execution step") + require.Len(t, steps, 2, "Should generate settings step and execution step") - stepContent := strings.Join(steps[0], "\n") + stepContent := strings.Join(steps[1], "\n") assert.Contains(t, stepContent, "GEMINI_API_KEY:", "Should include GEMINI_API_KEY") assert.Contains(t, stepContent, "GH_AW_PROMPT:", "Should include GH_AW_PROMPT") @@ -238,8 +239,8 @@ func TestGeminiEngineExecution(t *testing.T) { } steps := engine.GetExecutionSteps(noModelWorkflow, "/tmp/test.log") - require.Len(t, steps, 1) - stepContent := strings.Join(steps[0], "\n") + require.Len(t, steps, 2, "Should generate settings step and execution step") + stepContent := strings.Join(steps[1], "\n") assert.NotContains(t, stepContent, "GH_AW_MODEL_DETECTION_GEMINI", "Should not include detection model env var when model is unconfigured") assert.NotContains(t, stepContent, "GH_AW_MODEL_AGENT_GEMINI", "Should not include agent model env var when model is unconfigured") assert.NotContains(t, stepContent, "GEMINI_MODEL", "Should not include GEMINI_MODEL when model is unconfigured") @@ -253,10 +254,27 @@ func TestGeminiEngineExecution(t *testing.T) { } steps = engine.GetExecutionSteps(modelWorkflow, "/tmp/test.log") - require.Len(t, steps, 1) - stepContent = strings.Join(steps[0], "\n") + require.Len(t, steps, 2, "Should generate settings step and execution step") + stepContent = strings.Join(steps[1], "\n") assert.Contains(t, stepContent, "GEMINI_MODEL: gemini-2.0-flash", "Should set GEMINI_MODEL when model is explicitly configured") }) + + t.Run("settings step is first", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + } + + steps := engine.GetExecutionSteps(workflowData, "/tmp/test.log") + require.Len(t, steps, 2, "Should generate settings step and execution step") + + settingsContent := strings.Join(steps[0], "\n") + execContent := strings.Join(steps[1], "\n") + + assert.Contains(t, settingsContent, "Write Gemini settings", "First step should be Write Gemini settings") + assert.Contains(t, settingsContent, "includeDirectories", "Settings step should set includeDirectories") + assert.Contains(t, settingsContent, "/tmp/", "Settings step should include /tmp/ in include directories") + assert.Contains(t, execContent, "Execute Gemini CLI", "Second step should be Execute Gemini CLI") + }) } func TestGeminiEngineFirewallIntegration(t *testing.T) { @@ -274,9 +292,9 @@ func TestGeminiEngineFirewallIntegration(t *testing.T) { } steps := engine.GetExecutionSteps(workflowData, "/tmp/test.log") - require.Len(t, steps, 1, "Should generate one execution step") + require.Len(t, steps, 2, "Should generate settings step and execution step") - stepContent := strings.Join(steps[0], "\n") + stepContent := strings.Join(steps[1], "\n") // Should use AWF command assert.Contains(t, stepContent, "awf", "Should use AWF when firewall is enabled") @@ -296,9 +314,9 @@ func TestGeminiEngineFirewallIntegration(t *testing.T) { } steps := engine.GetExecutionSteps(workflowData, "/tmp/test.log") - require.Len(t, steps, 1, "Should generate one execution step") + require.Len(t, steps, 2, "Should generate settings step and execution step") - stepContent := strings.Join(steps[0], "\n") + stepContent := strings.Join(steps[1], "\n") // Should use simple command without AWF assert.Contains(t, stepContent, "set -o pipefail", "Should use simple command with pipefail") @@ -306,3 +324,152 @@ func TestGeminiEngineFirewallIntegration(t *testing.T) { assert.NotContains(t, stepContent, "GEMINI_API_BASE_URL", "Should not set GEMINI_API_BASE_URL when firewall is disabled") }) } + +func TestComputeGeminiToolsCore(t *testing.T) { + t.Run("nil tools includes default read-only tools", func(t *testing.T) { + result := computeGeminiToolsCore(nil) + assert.Contains(t, result, "glob", "Should include glob") + assert.Contains(t, result, "grep_search", "Should include grep_search") + assert.Contains(t, result, "list_directory", "Should include list_directory") + assert.Contains(t, result, "read_file", "Should include read_file") + assert.Contains(t, result, "read_many_files", "Should include read_many_files") + assert.NotContains(t, result, "run_shell_command", "Should not include run_shell_command without bash tool") + assert.NotContains(t, result, "write_file", "Should not include write_file without edit tool") + assert.NotContains(t, result, "replace", "Should not include replace without edit tool") + }) + + t.Run("empty tools includes default read-only tools", func(t *testing.T) { + result := computeGeminiToolsCore(map[string]any{}) + assert.Contains(t, result, "read_file", "Should include read_file") + assert.NotContains(t, result, "run_shell_command", "Should not include run_shell_command") + }) + + t.Run("bash with specific commands maps to run_shell_command entries", func(t *testing.T) { + tools := map[string]any{ + "bash": []any{"grep", "ls", "git"}, + } + result := computeGeminiToolsCore(tools) + assert.Contains(t, result, "run_shell_command(grep)", "Should map grep to run_shell_command(grep)") + assert.Contains(t, result, "run_shell_command(ls)", "Should map ls to run_shell_command(ls)") + assert.Contains(t, result, "run_shell_command(git)", "Should map git to run_shell_command(git)") + assert.NotContains(t, result, "run_shell_command", "Should not include unrestricted run_shell_command") + }) + + t.Run("bash with wildcard * maps to unrestricted run_shell_command", func(t *testing.T) { + tools := map[string]any{ + "bash": []any{"*"}, + } + result := computeGeminiToolsCore(tools) + assert.Contains(t, result, "run_shell_command", "Should include unrestricted run_shell_command for wildcard") + assert.NotContains(t, result, "run_shell_command(*)", "Should not include run_shell_command(*)") + }) + + t.Run("bash with :* wildcard maps to unrestricted run_shell_command", func(t *testing.T) { + tools := map[string]any{ + "bash": []any{":*"}, + } + result := computeGeminiToolsCore(tools) + assert.Contains(t, result, "run_shell_command", "Should include unrestricted run_shell_command for :* wildcard") + }) + + t.Run("bash with no specific commands (nil) maps to unrestricted run_shell_command", func(t *testing.T) { + tools := map[string]any{ + "bash": nil, + } + result := computeGeminiToolsCore(tools) + assert.Contains(t, result, "run_shell_command", "Should include unrestricted run_shell_command when bash has no commands") + }) + + t.Run("edit tool maps to write_file and replace", func(t *testing.T) { + tools := map[string]any{ + "edit": map[string]any{}, + } + result := computeGeminiToolsCore(tools) + assert.Contains(t, result, "write_file", "Should map edit to write_file") + assert.Contains(t, result, "replace", "Should map edit to replace") + }) + + t.Run("combined bash and edit tools", func(t *testing.T) { + tools := map[string]any{ + "bash": []any{"grep"}, + "edit": map[string]any{}, + } + result := computeGeminiToolsCore(tools) + assert.Contains(t, result, "run_shell_command(grep)", "Should include run_shell_command(grep)") + assert.Contains(t, result, "write_file", "Should include write_file") + assert.Contains(t, result, "replace", "Should include replace") + assert.Contains(t, result, "read_file", "Should always include read_file") + }) + + t.Run("result is sorted", func(t *testing.T) { + tools := map[string]any{ + "bash": []any{"zzz", "aaa"}, + "edit": map[string]any{}, + } + result := computeGeminiToolsCore(tools) + for i := 1; i < len(result); i++ { + assert.LessOrEqual(t, result[i-1], result[i], "Tools should be sorted alphabetically") + } + }) +} + +func TestGenerateGeminiSettingsStep(t *testing.T) { + engine := NewGeminiEngine() + + t.Run("step sets context.includeDirectories to /tmp/", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + Tools: map[string]any{}, + } + step := engine.generateGeminiSettingsStep(workflowData) + content := strings.Join(step, "\n") + + assert.Contains(t, content, "Write Gemini settings", "Should have correct step name") + assert.Contains(t, content, "/tmp/", "Should include /tmp/ in include directories") + assert.Contains(t, content, "includeDirectories", "Should set includeDirectories") + assert.Contains(t, content, ".gemini", "Should reference .gemini directory") + assert.Contains(t, content, "GITHUB_WORKSPACE", "Should use GITHUB_WORKSPACE") + }) + + t.Run("step includes merge logic for existing settings.json", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + Tools: map[string]any{}, + } + step := engine.generateGeminiSettingsStep(workflowData) + content := strings.Join(step, "\n") + + assert.Contains(t, content, "if [ -f", "Should check for existing settings.json") + assert.Contains(t, content, "jq", "Should use jq for merging") + assert.Contains(t, content, "$existing * $base", "Should merge with base taking precedence") + }) + + t.Run("step includes tools.core with bash mapping", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + Tools: map[string]any{ + "bash": []any{"grep", "git"}, + }, + } + step := engine.generateGeminiSettingsStep(workflowData) + content := strings.Join(step, "\n") + + assert.Contains(t, content, "run_shell_command(grep)", "Should include run_shell_command(grep) for bash grep") + assert.Contains(t, content, "run_shell_command(git)", "Should include run_shell_command(git) for bash git") + assert.Contains(t, content, "core", "Should include tools.core") + }) + + t.Run("step includes tools.core with edit mapping", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + Tools: map[string]any{ + "edit": map[string]any{}, + }, + } + step := engine.generateGeminiSettingsStep(workflowData) + content := strings.Join(step, "\n") + + assert.Contains(t, content, "write_file", "Should include write_file for edit tool") + assert.Contains(t, content, "replace", "Should include replace for edit tool") + }) +} diff --git a/pkg/workflow/gemini_tools.go b/pkg/workflow/gemini_tools.go new file mode 100644 index 0000000000..9c563a2bdd --- /dev/null +++ b/pkg/workflow/gemini_tools.go @@ -0,0 +1,173 @@ +package workflow + +// This file provides Gemini engine tool configuration logic. +// +// It handles two key responsibilities: +// +// 1. Tool Core Mapping (computeGeminiToolsCore): +// Converts neutral tool names from the workflow configuration into +// Gemini CLI built-in tool names for the tools.core allowlist in +// .gemini/settings.json. This restricts the agent to only the tools +// explicitly requested by the workflow. +// +// 2. Settings Step Generation (generateGeminiSettingsStep): +// Generates a GitHub Actions step that writes or merges .gemini/settings.json +// before the Gemini CLI execution. This step always sets: +// - context.includeDirectories: ["/tmp/"] so file tools can access /tmp/ +// - tools.core: derived from neutral tool configuration +// The merge approach ensures MCP server config (written by convert_gateway_config_gemini.sh) +// is preserved while adding the context and tool settings. + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/github/gh-aw/pkg/logger" +) + +var geminiToolsLog = logger.New("workflow:gemini_tools") + +// computeGeminiToolsCore maps neutral tool names to Gemini CLI built-in tool names +// for use in the tools.core allowlist in .gemini/settings.json. +// +// Neutral tool → Gemini CLI tool mapping: +// - bash: [cmd, ...] → run_shell_command(cmd), ... (one entry per command) +// - bash: * or bash: nil → run_shell_command (allow all shell commands) +// - edit: {} → replace, write_file (file write tools) +// +// Read-only file system tools are always included as they are essential for +// agentic workflows: glob, grep_search, list_directory, read_file, read_many_files. +// +// See: https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/file-system.md +// See: https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/shell.md +func computeGeminiToolsCore(tools map[string]any) []string { + // Always include essential read-only file system tools + toolsCore := []string{ + "glob", + "grep_search", + "list_directory", + "read_file", + "read_many_files", + } + + if tools == nil { + return toolsCore + } + + // Map bash neutral tool to run_shell_command + if bashConfig, hasBash := tools["bash"]; hasBash { + bashCommands, ok := bashConfig.([]any) + if !ok || len(bashCommands) == 0 { + // bash with no specific commands - allow all shell commands + geminiToolsLog.Print("bash (no specific commands) → run_shell_command") + toolsCore = append(toolsCore, "run_shell_command") + } else { + // Check for wildcard (* or :*) + hasWildcard := false + for _, cmd := range bashCommands { + if cmdStr, ok := cmd.(string); ok && (cmdStr == "*" || cmdStr == ":*") { + hasWildcard = true + break + } + } + if hasWildcard { + geminiToolsLog.Print("bash wildcard → run_shell_command") + toolsCore = append(toolsCore, "run_shell_command") + } else { + // Add an entry for each specific command: run_shell_command(cmd) + for _, cmd := range bashCommands { + if cmdStr, ok := cmd.(string); ok { + entry := fmt.Sprintf("run_shell_command(%s)", cmdStr) + geminiToolsLog.Printf("bash %q → %s", cmdStr, entry) + toolsCore = append(toolsCore, entry) + } + } + } + } + } + + // Map edit neutral tool to write_file and replace (Gemini's file write tools) + if _, hasEdit := tools["edit"]; hasEdit { + geminiToolsLog.Print("edit → replace, write_file") + toolsCore = append(toolsCore, "replace") + toolsCore = append(toolsCore, "write_file") + } + + sort.Strings(toolsCore) + return toolsCore +} + +// generateGeminiSettingsStep creates a GitHub Actions step that writes the +// Gemini CLI project settings file (.gemini/settings.json) before execution. +// +// This step: +// 1. Sets context.includeDirectories to ["/tmp/"] so that Gemini CLI file system +// tools (write_file, replace) can access files in /tmp/ including +// /tmp/gh-aw/cache-memory/ and other agent working directories. +// 2. Sets tools.core to the list of built-in tools derived from the workflow's +// neutral tool configuration (bash → run_shell_command, edit → write_file/replace). +// 3. Merges the above settings with any existing .gemini/settings.json, which +// may have been written by convert_gateway_config_gemini.sh with MCP server +// configuration. The merge preserves the MCP server config while adding +// the context and tools settings. +func (e *GeminiEngine) generateGeminiSettingsStep(workflowData *WorkflowData) GitHubActionStep { + geminiToolsLog.Printf("Generating Gemini settings step for: %s", workflowData.Name) + + tools := workflowData.Tools + if tools == nil { + tools = make(map[string]any) + } + + // Compute tools.core from neutral tool configuration + toolsCore := computeGeminiToolsCore(tools) + geminiToolsLog.Printf("tools.core entries: %d", len(toolsCore)) + + // Build the settings JSON object + config := map[string]any{ + "context": map[string]any{ + "includeDirectories": []string{"/tmp/"}, + }, + "tools": map[string]any{ + "core": toolsCore, + }, + } + + configJSON, err := json.Marshal(config) + if err != nil { + geminiToolsLog.Printf("ERROR: Failed to marshal Gemini settings: %v", err) + configJSON = []byte(`{"context":{"includeDirectories":["/tmp/"]},"tools":{"core":[]}}`) + } + + // Escape any single quotes in the JSON. The JSON we generate only contains tool + // names like "run_shell_command(grep)" and paths like "/tmp/", so in practice no + // single quotes will appear. The shell escape pattern '"'"' works by: ending the + // single-quoted string ('), adding a double-quoted single quote ("'"), and reopening + // the single-quoted string ('). + jsonStr := strings.ReplaceAll(string(configJSON), "'", `'"'"'`) + + // Generate a shell script that: + // - Creates the .gemini directory if needed + // - Merges settings into an existing settings.json (from MCP gateway setup), or + // - Creates a new settings.json when no MCP servers are configured + // + // jq merge: '$existing * $base' means the RIGHT operand ($base) overrides the LEFT + // operand ($existing) for conflicting keys. Non-conflicting keys from $existing + // (e.g. mcpServers written by convert_gateway_config_gemini.sh) are preserved. + command := fmt.Sprintf(`mkdir -p "$GITHUB_WORKSPACE/.gemini" +SETTINGS="$GITHUB_WORKSPACE/.gemini/settings.json" +BASE_CONFIG='%s' +if [ -f "$SETTINGS" ]; then + MERGED=$(jq -n --argjson base "$BASE_CONFIG" --argjson existing "$(cat "$SETTINGS")" '$existing * $base') + echo "$MERGED" > "$SETTINGS" +else + echo "$BASE_CONFIG" > "$SETTINGS" +fi`, jsonStr) + + stepLines := []string{ + " - name: Write Gemini settings", + } + stepLines = FormatStepWithCommandAndEnv(stepLines, command, nil) + return GitHubActionStep(stepLines) +}