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
29 changes: 20 additions & 9 deletions pkg/workflow/mcp_setup_generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -229,20 +229,28 @@ func generateSafeOutputsSetup(c *Compiler, yaml *strings.Builder, safeOutputConf
yaml.WriteString(" - name: Generate Safe Outputs Config\n")
configSecrets := ExtractSecretsFromValue(safeOutputConfig)
configContextVars := ExtractGitHubContextExpressionsFromValue(safeOutputConfig)
hasEnvVars := len(configSecrets) > 0 || len(configContextVars) > 0
configWorkflowInputs := ExtractWorkflowInputExpressionsFromValue(safeOutputConfig)
hasEnvVars := len(configSecrets) > 0 || len(configContextVars) > 0 || len(configWorkflowInputs) > 0
if hasEnvVars {
yaml.WriteString(" env:\n")
envKeys := make([]string, 0, len(configSecrets)+len(configContextVars))
envValues := make(map[string]string, len(configSecrets)+len(configContextVars))
envKeys := make([]string, 0, len(configSecrets)+len(configContextVars)+len(configWorkflowInputs))
envValues := make(map[string]string, len(configSecrets)+len(configContextVars)+len(configWorkflowInputs))
// addEnvValue deduplicates envKeys while allowing later sources to override
// the value in envValues for duplicate keys.
addEnvValue := func(key, value string) {
if _, exists := envValues[key]; !exists {
envKeys = append(envKeys, key)
}
envValues[key] = value
}
for k, v := range configWorkflowInputs {
addEnvValue(k, v)
}
for k, v := range configContextVars {
envKeys = append(envKeys, k)
envValues[k] = v
addEnvValue(k, v)
}
for k, v := range configSecrets {
if _, exists := envValues[k]; !exists {
envKeys = append(envKeys, k)
}
envValues[k] = v
addEnvValue(k, v)
}
sort.Strings(envKeys)
for _, varName := range envKeys {
Expand All @@ -267,6 +275,9 @@ func generateSafeOutputsSetup(c *Compiler, yaml *strings.Builder, safeOutputConf
for varName, ctxExpr := range configContextVars {
sanitizedConfig = strings.ReplaceAll(sanitizedConfig, ctxExpr, "${"+varName+"}")
}
for varName, inputExpr := range configWorkflowInputs {
sanitizedConfig = strings.ReplaceAll(sanitizedConfig, inputExpr, "${"+varName+"}")
}
yaml.WriteString(" cat > \"${RUNNER_TEMP}/gh-aw/safeoutputs/config.json\" << " + delimiter + "\n")
yaml.WriteString(" " + sanitizedConfig + "\n")
yaml.WriteString(" " + delimiter + "\n")
Expand Down
76 changes: 76 additions & 0 deletions pkg/workflow/safe_outputs_dynamic_allowed_repos_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
//go:build !integration

package workflow

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

"github.com/github/gh-aw/pkg/stringutil"
"github.com/github/gh-aw/pkg/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestSafeOutputsConfigUsesWorkflowInputEnvVarsForDynamicAllowedRepos(t *testing.T) {
tmpDir := testutil.TempDir(t, "safe-outputs-dynamic-allowed-repos")
mdFile := filepath.Join(tmpDir, "dynamic-safe-outputs.md")

content := `---
name: Dynamic Safe Outputs
on:
workflow_dispatch:
inputs:
target_repo:
required: true
type: string
base_branch:
required: true
type: string
engine: copilot
safe-outputs:
create-pull-request:
allowed-repos:
- ${{ inputs.target_repo }}
allowed-base-branches:
- ${{ inputs.base_branch }}
---

Test workflow
`

err := os.WriteFile(mdFile, []byte(content), 0600)
require.NoError(t, err, "Failed to write test workflow markdown")

compiler := NewCompiler()
err = compiler.CompileWorkflow(mdFile)
require.NoError(t, err, "Failed to compile workflow")

lockFile := stringutil.MarkdownToLockFile(mdFile)
compiledBytes, err := os.ReadFile(lockFile)
require.NoError(t, err, "Failed to read compiled workflow")
compiled := string(compiledBytes)

assert.Contains(t, compiled, "GH_AW_INPUT_TARGET_REPO: ${{ inputs.target_repo }}",
"Generate Safe Outputs Config step should map inputs.target_repo to an env var")
assert.Contains(t, compiled, "GH_AW_INPUT_BASE_BRANCH: ${{ inputs.base_branch }}",
"Generate Safe Outputs Config step should map inputs.base_branch to an env var")
assert.Contains(t, compiled, `"allowed_repos":"${GH_AW_INPUT_TARGET_REPO}"`,
"config.json payload should use shell env var for allowed_repos")
assert.Contains(t, compiled, `"allowed_base_branches":"${GH_AW_INPUT_BASE_BRANCH}"`,
"config.json payload should use shell env var for allowed_base_branches")

quotedHeredocPattern := regexp.MustCompile(`cat > "\$\{RUNNER_TEMP\}/gh-aw/safeoutputs/config\.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_[0-9a-f]{16}_EOF'`)
assert.False(t, quotedHeredocPattern.MatchString(compiled),
"Safe outputs config heredoc should not be single-quoted when using dynamic input expressions")

unquotedHeredocPattern := regexp.MustCompile(`cat > "\$\{RUNNER_TEMP\}/gh-aw/safeoutputs/config\.json" << GH_AW_SAFE_OUTPUTS_CONFIG_[0-9a-f]{16}_EOF`)
assert.True(t, unquotedHeredocPattern.MatchString(compiled),
"Safe outputs config heredoc should be unquoted when dynamic input expressions are present")
normalizedCompiled := strings.ReplaceAll(compiled, `\"`, `"`)
assert.NotContains(t, normalizedCompiled, `"allowed_repos":["${{ inputs.target_repo }}"]`,
"config.json payload should not keep unresolved workflow input expression")
}
54 changes: 54 additions & 0 deletions pkg/workflow/secret_extraction.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,16 @@ func ExtractEnvExpressionsFromValue(value string) map[string]string {
// accepted later if they are present in gitHubContextEnvVarMap.
var gitHubContextExprPattern = regexp.MustCompile(`\$\{\{\s*github\.([a-z][a-z0-9_.]*)\s*\}\}`)

// workflowInputDotExprPattern matches simple ${{ inputs.NAME }} expressions.
// NAME must start with a letter or underscore, followed by alphanumeric
// characters, underscores, or dashes.
var workflowInputDotExprPattern = regexp.MustCompile(`\$\{\{\s*inputs\.([a-zA-Z_][a-zA-Z0-9_-]*)\s*\}\}`)

// workflowInputBracketExprPattern matches bracket-notation input expressions:
// ${{ inputs['NAME'] }} and ${{ inputs["NAME"] }}. NAME must start with a
// letter or underscore, followed by alphanumeric characters, underscores, or dashes.
var workflowInputBracketExprPattern = regexp.MustCompile(`\$\{\{\s*inputs\[\s*['"]([a-zA-Z_][a-zA-Z0-9_-]*)['"]\s*\]\s*\}\}`)

// gitHubContextEnvVarMap maps common github.* context properties to their corresponding
// GitHub Actions runner environment variables (always available on all runners).
// See: https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables
Expand Down Expand Up @@ -258,6 +268,50 @@ func ExtractGitHubContextExpressionsFromValue(value string) map[string]string {
return result
}

// ExtractWorkflowInputExpressionsFromValue extracts simple workflow input expressions from a
// string value and maps them to deterministic environment variable names.
// Returns a map of env var name -> full expression.
//
// Examples:
// - "${{ inputs.target_repo }}" -> {"GH_AW_INPUT_TARGET_REPO": "${{ inputs.target_repo }}"}
// - "${{ inputs['base-branch'] }}" -> {"GH_AW_INPUT_BASE_BRANCH": "${{ inputs['base-branch'] }}"}
func ExtractWorkflowInputExpressionsFromValue(value string) map[string]string {
result := make(map[string]string)

processMatch := func(match []string) {
if len(match) < 2 {
return
}
inputName := match[1]
fullExpr := match[0]
envVar := formatInputNameAsEnvVar(inputName)
result[envVar] = fullExpr
secretLog.Printf("Extracted workflow input expression: %s -> %s", fullExpr, envVar)
}

for _, match := range workflowInputDotExprPattern.FindAllStringSubmatch(value, -1) {
processMatch(match)
}

for _, match := range workflowInputBracketExprPattern.FindAllStringSubmatch(value, -1) {
processMatch(match)
}

return result
}

func formatInputNameAsEnvVar(inputName string) string {
normalized := strings.Map(func(r rune) rune {
switch {
case (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
return r
default:
return '_'
}
}, inputName)
return "GH_AW_INPUT_" + strings.ToUpper(normalized)
}

// ReplaceTemplateExpressionsWithEnvVars replaces all template expressions with environment variable references
// Handles: secrets.*, env.*, and github.workspace
// Examples:
Expand Down
70 changes: 70 additions & 0 deletions pkg/workflow/secret_extraction_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -451,3 +451,73 @@ func TestExtractGitHubContextExpressionsFromValue(t *testing.T) {
})
}
}

func TestExtractWorkflowInputExpressionsFromValue(t *testing.T) {
tests := []struct {
name string
value string
expected map[string]string
}{
{
name: "single input expression",
value: `"repo":"${{ inputs.target_repo }}"`,
expected: map[string]string{
"GH_AW_INPUT_TARGET_REPO": "${{ inputs.target_repo }}",
},
},
{
name: "multiple input expressions with bracket dash and underscore",
value: `"repo":"${{ inputs['target-repo'] }}","base":"${{ inputs.base_branch }}"`,
expected: map[string]string{
"GH_AW_INPUT_TARGET_REPO": "${{ inputs['target-repo'] }}",
"GH_AW_INPUT_BASE_BRANCH": "${{ inputs.base_branch }}",
},
},
{
name: "dot notation with dash remains supported",
value: `"repo":"${{ inputs.target-repo }}"`,
expected: map[string]string{
"GH_AW_INPUT_TARGET_REPO": "${{ inputs.target-repo }}",
},
},
{
name: "double-quote bracket notation",
value: `"base":"${{ inputs["base-branch"] }}"`,
expected: map[string]string{
"GH_AW_INPUT_BASE_BRANCH": `${{ inputs["base-branch"] }}`,
},
},
{
name: "no input expressions",
value: `"repo":"${{ github.repository }}"`,
expected: map[string]string{},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ExtractWorkflowInputExpressionsFromValue(tt.value)
assert.Equal(t, tt.expected, result, "Should extract expected workflow input expressions")
})
}
}

func TestFormatInputNameAsEnvVar(t *testing.T) {
tests := []struct {
name string
inputName string
expected string
}{
{name: "underscore", inputName: "target_repo", expected: "GH_AW_INPUT_TARGET_REPO"},
{name: "dash", inputName: "base-branch", expected: "GH_AW_INPUT_BASE_BRANCH"},
{name: "consecutive separators", inputName: "my--input__name", expected: "GH_AW_INPUT_MY__INPUT__NAME"},
{name: "mixed case and numeric", inputName: "Repo2Name", expected: "GH_AW_INPUT_REPO2NAME"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
actual := formatInputNameAsEnvVar(tt.inputName)
assert.Equal(t, tt.expected, actual, "Input name should be converted to the expected env var")
})
}
}
Loading