diff --git a/.github/workflows/stale-repo-identifier.lock.yml b/.github/workflows/stale-repo-identifier.lock.yml index 5ced41da33..0a8ca3b099 100644 --- a/.github/workflows/stale-repo-identifier.lock.yml +++ b/.github/workflows/stale-repo-identifier.lock.yml @@ -51,9 +51,6 @@ concurrency: run-name: "Stale Repository Identifier" -env: - ORGANIZATION: ${{ github.event.inputs.organization || 'github' }} - jobs: activation: runs-on: ubuntu-slim @@ -279,6 +276,7 @@ jobs: GH_AW_SAFE_OUTPUTS_CONFIG_PATH: /opt/gh-aw/safeoutputs/config.json GH_AW_SAFE_OUTPUTS_TOOLS_PATH: /opt/gh-aw/safeoutputs/tools.json GH_AW_WORKFLOW_ID_SANITIZED: stalerepoidentifier + ORGANIZATION: ${{ github.event.inputs.organization || 'github' }} outputs: checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} has_patch: ${{ steps.collect_output.outputs.has_patch }} diff --git a/docs/src/content/docs/reference/environment-variables.md b/docs/src/content/docs/reference/environment-variables.md index 30557adf32..a75dc63ae9 100644 --- a/docs/src/content/docs/reference/environment-variables.md +++ b/docs/src/content/docs/reference/environment-variables.md @@ -13,7 +13,7 @@ GitHub Agentic Workflows supports environment variables in 13 distinct contexts: | Scope | Syntax | Context | Typical Use | |-------|--------|---------|-------------| -| **Workflow-level** | `env:` | All jobs | Shared configuration | +| **Frontmatter `env:`** | `env:` | Agent job only | Agent job configuration | | **Job-level** | `jobs..env` | All steps in job | Job-specific config | | **Step-level** | `steps[*].env` | Single step | Step-specific config | | **Engine** | `engine.env` | AI engine | Engine secrets, timeouts | @@ -29,7 +29,7 @@ GitHub Agentic Workflows supports environment variables in 13 distinct contexts: ### Example Configurations -**Workflow-level shared configuration:** +**Frontmatter `env:` (agent job only):** ```yaml wrap --- env: @@ -38,6 +38,9 @@ env: --- ``` +> [!NOTE] +> The frontmatter `env:` section applies **only to the agent job**, not to all jobs in the workflow. To set environment variables for custom jobs, use `jobs..env`. To set environment variables for safe-output jobs, use `safe-outputs.env` or `safe-outputs.jobs..env`. + **Job-specific overrides:** ```yaml wrap --- @@ -85,13 +88,16 @@ Environment variables follow a **most-specific-wins** model, consistent with Git 1. **Step-level** (`steps[*].env`, `githubActionsStep.env`) 2. **Job-level** (`jobs..env`) -3. **Workflow-level** (`env:`) +3. **Frontmatter `env:`** (applies to agent job only) + +> [!NOTE] +> The frontmatter `env:` section is **not** workflow-level. It applies only to the agent job. Custom jobs must define their own environment variables using `jobs..env`. ### Safe Outputs Precedence 1. **Job-specific** (`safe-outputs.jobs..env`) 2. **Global** (`safe-outputs.env`) -3. **Workflow-level** (`env:`) +3. **Frontmatter `env:`** (if the safe-output job inherits from agent job) ### Context-Specific Scopes @@ -102,33 +108,47 @@ These scopes are independent and operate in different contexts: `engine.env`, `c ```yaml wrap --- env: - API_KEY: default-key + API_KEY: agent-key # Applied to agent job only DEBUG: "false" jobs: test: env: - API_KEY: test-key # Overrides workflow-level + API_KEY: test-key # Custom job defines its own env EXTRA: "value" steps: - run: | - # API_KEY = "test-key" (job-level override) - # DEBUG = "false" (workflow-level inherited) + # In 'test' job: + # API_KEY = "test-key" (job-level) # EXTRA = "value" (job-level) + # DEBUG is NOT available (frontmatter env not inherited) + +# In agent job: +# API_KEY = "agent-key" (from frontmatter env) +# DEBUG = "false" (from frontmatter env) --- ``` ## Common Patterns -**Shared configuration with job overrides:** +**Agent job configuration:** ```yaml wrap --- env: - NODE_ENV: production + NODE_ENV: production # Applied to agent job only +--- +``` + +**Custom jobs define their own env:** +```yaml wrap +--- +env: + AGENT_VAR: value # Agent job only + jobs: test: env: - NODE_ENV: test # Override for testing + NODE_ENV: test # Test job environment --- ``` @@ -191,41 +211,50 @@ During compilation, AWF extracts environment variables from frontmatter, preserv **Generated lock file structure:** ```yaml -env: - SHARED_VAR: value +# No workflow-level env section jobs: agent: env: - GH_AW_SAFE_OUTPUTS: /opt/gh-aw/safeoutputs/outputs.jsonl + # Frontmatter env variables merged with system env CUSTOM_VAR: ${{ secrets.CUSTOM_SECRET }} + GH_AW_SAFE_OUTPUTS: /opt/gh-aw/safeoutputs/outputs.jsonl + GH_AW_WORKFLOW_ID_SANITIZED: myworkflow steps: - name: Execute env: STEP_VAR: value ``` +> [!NOTE] +> The frontmatter `env:` variables are merged into the agent job's `env:` section alongside system-generated variables like `GH_AW_SAFE_OUTPUTS` and `GH_AW_WORKFLOW_ID_SANITIZED`. + ## Debugging Environment Variables -**View all available variables:** +**View all available variables in agent job:** ```yaml wrap -jobs: - debug: - steps: - - run: env | sort +--- +env: + TEST_VAR: agent-value +--- + +# Add this to your workflow markdown to debug ``` -**Test precedence:** +The agent job will have access to `TEST_VAR` along with system variables. + +**Custom jobs define their own env:** ```yaml wrap --- env: - TEST_VAR: workflow + AGENT_VAR: agent-value # Only in agent job + jobs: - test: + debug: env: - TEST_VAR: job + DEBUG_VAR: custom-value # Only in debug job steps: - - run: echo "TEST_VAR is $TEST_VAR" # Outputs: "job" + - run: env | sort --- ``` diff --git a/docs/src/content/docs/reference/frontmatter.md b/docs/src/content/docs/reference/frontmatter.md index 30897156f9..d7e7331922 100644 --- a/docs/src/content/docs/reference/frontmatter.md +++ b/docs/src/content/docs/reference/frontmatter.md @@ -449,7 +449,7 @@ Automatically generates concurrency policies for the agent job. See [Concurrency ## Environment Variables (`env:`) -Standard GitHub Actions `env:` syntax for workflow-level environment variables: +Define environment variables scoped to the agent job using standard GitHub Actions `env:` syntax: ```yaml wrap env: @@ -457,7 +457,11 @@ env: SECRET_VAR: ${{ secrets.MY_SECRET }} ``` -Environment variables can be defined at multiple scopes (workflow, job, step, engine, safe-outputs, etc.) with clear precedence rules. See [Environment Variables](/gh-aw/reference/environment-variables/) for complete documentation on all 13 env scopes and precedence order. +**Scope**: Environment variables defined in the frontmatter `env:` section are applied **only to the agent job**, not to all jobs in the workflow. This provides better isolation and follows best practices for environment variable scoping. + +For custom jobs, define environment variables directly in the job configuration using `jobs..env`. For safe-output jobs, use `safe-outputs.env` or `safe-outputs.jobs..env`. + +Environment variables can be defined at multiple scopes (job, step, engine, safe-outputs, etc.) with clear precedence rules. See [Environment Variables](/gh-aw/reference/environment-variables/) for complete documentation on all env scopes and precedence order. ## Secrets (`secrets:`) diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 455d13da75..62d70812b9 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -1876,7 +1876,7 @@ }, "env": { "$comment": "See environment variable precedence documentation: https://github.github.com/gh-aw/reference/environment-variables/", - "description": "Environment variables for the workflow", + "description": "Environment variables scoped to the agent job (not workflow-level)", "oneOf": [ { "type": "object", @@ -2513,7 +2513,7 @@ ] }, "plugins": { - "description": "\u26a0\ufe0f EXPERIMENTAL: Plugin configuration for installing plugins before workflow execution. Supports array format (list of repos/plugin configs) and object format (repos + custom token). Note: Plugin support is experimental and may change in future releases.", + "description": "⚠️ EXPERIMENTAL: Plugin configuration for installing plugins before workflow execution. Supports array format (list of repos/plugin configs) and object format (repos + custom token). Note: Plugin support is experimental and may change in future releases.", "examples": [ ["github/copilot-plugin", "acme/custom-tools"], [ @@ -2710,7 +2710,7 @@ [ { "name": "Verify Post-Steps Execution", - "run": "echo \"\u2705 Post-steps are executing correctly\"\necho \"This step runs after the AI agent completes\"\n" + "run": "echo \"✅ Post-steps are executing correctly\"\necho \"This step runs after the AI agent completes\"\n" }, { "name": "Upload Test Results", @@ -5099,7 +5099,7 @@ "oneOf": [ { "type": "object", - "description": "Configuration for resolving review threads on pull requests. Resolution is scoped to the triggering PR only \u2014 threads on other PRs cannot be resolved.", + "description": "Configuration for resolving review threads on pull requests. Resolution is scoped to the triggering PR only — threads on other PRs cannot be resolved.", "properties": { "max": { "type": "integer", @@ -6238,8 +6238,8 @@ }, "staged-title": { "type": "string", - "description": "Custom title template for staged mode preview. Available placeholders: {operation}. Example: '\ud83c\udfad Preview: {operation}'", - "examples": ["\ud83c\udfad Preview: {operation}", "## Staged Mode: {operation}"] + "description": "Custom title template for staged mode preview. Available placeholders: {operation}. Example: '🎭 Preview: {operation}'", + "examples": ["🎭 Preview: {operation}", "## Staged Mode: {operation}"] }, "staged-description": { "type": "string", @@ -6253,18 +6253,18 @@ }, "run-success": { "type": "string", - "description": "Custom message template for successful workflow completion. Available placeholders: {workflow_name}, {run_url}. Default: '\u2705 Agentic [{workflow_name}]({run_url}) completed successfully.'", - "examples": ["\u2705 Agentic [{workflow_name}]({run_url}) completed successfully.", "\u2705 [{workflow_name}]({run_url}) finished."] + "description": "Custom message template for successful workflow completion. Available placeholders: {workflow_name}, {run_url}. Default: '✅ Agentic [{workflow_name}]({run_url}) completed successfully.'", + "examples": ["✅ Agentic [{workflow_name}]({run_url}) completed successfully.", "✅ [{workflow_name}]({run_url}) finished."] }, "run-failure": { "type": "string", - "description": "Custom message template for failed workflow. Available placeholders: {workflow_name}, {run_url}, {status}. Default: '\u274c Agentic [{workflow_name}]({run_url}) {status} and wasn't able to produce a result.'", - "examples": ["\u274c Agentic [{workflow_name}]({run_url}) {status} and wasn't able to produce a result.", "\u274c [{workflow_name}]({run_url}) {status}."] + "description": "Custom message template for failed workflow. Available placeholders: {workflow_name}, {run_url}, {status}. Default: '❌ Agentic [{workflow_name}]({run_url}) {status} and wasn't able to produce a result.'", + "examples": ["❌ Agentic [{workflow_name}]({run_url}) {status} and wasn't able to produce a result.", "❌ [{workflow_name}]({run_url}) {status}."] }, "detection-failure": { "type": "string", - "description": "Custom message template for detection job failure. Available placeholders: {workflow_name}, {run_url}. Default: '\u26a0\ufe0f Security scanning failed for [{workflow_name}]({run_url}). Review the logs for details.'", - "examples": ["\u26a0\ufe0f Security scanning failed for [{workflow_name}]({run_url}). Review the logs for details.", "\u26a0\ufe0f Detection job failed in [{workflow_name}]({run_url})."] + "description": "Custom message template for detection job failure. Available placeholders: {workflow_name}, {run_url}. Default: '⚠️ Security scanning failed for [{workflow_name}]({run_url}). Review the logs for details.'", + "examples": ["⚠️ Security scanning failed for [{workflow_name}]({run_url}). Review the logs for details.", "⚠️ Detection job failed in [{workflow_name}]({run_url})."] }, "append-only-comments": { "type": "boolean", @@ -6350,12 +6350,12 @@ "additionalProperties": false }, "roles": { - "description": "Repository access roles required to trigger agentic workflows. Defaults to ['admin', 'maintainer', 'write'] for security. Use 'all' to allow any authenticated user (\u26a0\ufe0f security consideration).", + "description": "Repository access roles required to trigger agentic workflows. Defaults to ['admin', 'maintainer', 'write'] for security. Use 'all' to allow any authenticated user (⚠️ security consideration).", "oneOf": [ { "type": "string", "enum": ["all"], - "description": "Allow any authenticated user to trigger the workflow (\u26a0\ufe0f disables permission checking entirely - use with caution)" + "description": "Allow any authenticated user to trigger the workflow (⚠️ disables permission checking entirely - use with caution)" }, { "type": "array", diff --git a/pkg/workflow/compiler_activation_jobs.go b/pkg/workflow/compiler_activation_jobs.go index f127e20cdf..ede2e5b6d0 100644 --- a/pkg/workflow/compiler_activation_jobs.go +++ b/pkg/workflow/compiler_activation_jobs.go @@ -894,6 +894,19 @@ func (c *Compiler) buildMainJob(data *WorkflowData, activationJobCreated bool) ( env["GH_AW_WORKFLOW_ID_SANITIZED"] = sanitizedID } + // Add frontmatter env variables to the agent job + // These are user-defined environment variables that should only be applied to the agent job + // Reserved variable names are validated at compile time in compiler_orchestrator_workflow.go + if len(data.EnvMap) > 0 { + compilerActivationJobsLog.Printf("Adding %d frontmatter env variables to agent job", len(data.EnvMap)) + if env == nil { + env = make(map[string]string) + } + for key, value := range data.EnvMap { + env[key] = value + } + } + // Generate agent concurrency configuration agentConcurrency := GenerateJobConcurrencyConfig(data) diff --git a/pkg/workflow/compiler_orchestrator_workflow.go b/pkg/workflow/compiler_orchestrator_workflow.go index 8ccab3c550..3a82a0474a 100644 --- a/pkg/workflow/compiler_orchestrator_workflow.go +++ b/pkg/workflow/compiler_orchestrator_workflow.go @@ -64,7 +64,9 @@ func (c *Compiler) ParseWorkflowFile(markdownPath string) (*WorkflowData, error) workflowData.ActionPinWarnings = c.actionPinWarnings // Extract YAML configuration sections from frontmatter - c.extractYAMLSections(result.Frontmatter, workflowData) + if err := c.extractYAMLSections(result.Frontmatter, workflowData); err != nil { + return nil, fmt.Errorf("failed to extract YAML sections: %w", err) + } // Merge features from imports if len(engineSetup.importsResult.MergedFeatures) > 0 { @@ -162,7 +164,7 @@ func (c *Compiler) buildInitialWorkflowData( } // extractYAMLSections extracts YAML configuration sections from frontmatter -func (c *Compiler) extractYAMLSections(frontmatter map[string]any, workflowData *WorkflowData) { +func (c *Compiler) extractYAMLSections(frontmatter map[string]any, workflowData *WorkflowData) error { orchestratorWorkflowLog.Print("Extracting YAML sections from frontmatter") workflowData.On = c.extractTopLevelYAMLSection(frontmatter, "on") @@ -171,6 +173,28 @@ func (c *Compiler) extractYAMLSections(frontmatter map[string]any, workflowData workflowData.Concurrency = c.extractTopLevelYAMLSection(frontmatter, "concurrency") workflowData.RunName = c.extractTopLevelYAMLSection(frontmatter, "run-name") workflowData.Env = c.extractTopLevelYAMLSection(frontmatter, "env") + + // Extract env as map for agent job-level rendering + // This allows env variables to be scoped to the agent job instead of globally + if envValue, exists := frontmatter["env"]; exists { + if envMap, ok := envValue.(map[string]any); ok { + workflowData.EnvMap = make(map[string]string) + for key, value := range envMap { + // Validate that env variable names don't use reserved prefixes + if strings.HasPrefix(key, "GH_AW_") { + return fmt.Errorf("env variable %q uses reserved prefix 'GH_AW_' which is reserved for system use", key) + } + if key == "DEFAULT_BRANCH" { + return fmt.Errorf("env variable 'DEFAULT_BRANCH' is reserved for system use") + } + + // Convert all value types to strings (handles strings, numbers, booleans, etc.) + // This matches GitHub Actions behavior where env values are always strings + workflowData.EnvMap[key] = fmt.Sprintf("%v", value) + } + } + } + workflowData.Features = c.extractFeatures(frontmatter) workflowData.If = c.extractIfCondition(frontmatter) @@ -181,6 +205,8 @@ func (c *Compiler) extractYAMLSections(frontmatter map[string]any, workflowData workflowData.Environment = c.extractTopLevelYAMLSection(frontmatter, "environment") workflowData.Container = c.extractTopLevelYAMLSection(frontmatter, "container") workflowData.Cache = c.extractTopLevelYAMLSection(frontmatter, "cache") + + return nil } // processAndMergeSteps handles the merging of imported steps with main workflow steps diff --git a/pkg/workflow/compiler_orchestrator_workflow_test.go b/pkg/workflow/compiler_orchestrator_workflow_test.go index 3724f5446f..aae6d55293 100644 --- a/pkg/workflow/compiler_orchestrator_workflow_test.go +++ b/pkg/workflow/compiler_orchestrator_workflow_test.go @@ -156,7 +156,8 @@ func TestExtractYAMLSections_AllSections(t *testing.T) { }, } - compiler.extractYAMLSections(frontmatter, workflowData) + err := compiler.extractYAMLSections(frontmatter, workflowData) + require.NoError(t, err) // Verify all sections were extracted assert.NotEmpty(t, workflowData.On) @@ -195,7 +196,8 @@ func TestExtractYAMLSections_MissingSections(t *testing.T) { // Empty frontmatter frontmatter := map[string]any{} - compiler.extractYAMLSections(frontmatter, workflowData) + err := compiler.extractYAMLSections(frontmatter, workflowData) + require.NoError(t, err) // All fields should be empty strings when not present assert.Empty(t, workflowData.On) @@ -1295,7 +1297,8 @@ func TestExtractYAMLSections_PartialSections(t *testing.T) { // Missing: network, concurrency, run-name, env, features, if, runs-on, environment, container, cache } - compiler.extractYAMLSections(frontmatter, workflowData) + err := compiler.extractYAMLSections(frontmatter, workflowData) + require.NoError(t, err) // Verify present sections were extracted assert.NotEmpty(t, workflowData.On) diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index 013fbb89f0..d23e9fce41 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -396,7 +396,8 @@ type WorkflowData struct { Network string // top-level network permissions configuration Concurrency string // workflow-level concurrency configuration RunName string - Env string + Env string // YAML string representation (for backward compatibility, not rendered globally) + EnvMap map[string]string // Environment variables as map (for agent job rendering) If string TimeoutMinutes string CustomSteps string diff --git a/pkg/workflow/compiler_yaml.go b/pkg/workflow/compiler_yaml.go index 4901a2dce9..c7bf2086be 100644 --- a/pkg/workflow/compiler_yaml.go +++ b/pkg/workflow/compiler_yaml.go @@ -142,10 +142,8 @@ func (c *Compiler) generateWorkflowBody(yaml *strings.Builder, data *WorkflowDat yaml.WriteString(data.Concurrency + "\n\n") yaml.WriteString(data.RunName + "\n\n") - // Add env section if present - if data.Env != "" { - yaml.WriteString(data.Env + "\n\n") - } + // Note: env variables from frontmatter are now applied at the agent job level, not globally + // See compiler_activation_jobs.go buildMainJob() for agent job env configuration // Add cache comment if cache configuration was provided if data.Cache != "" { diff --git a/pkg/workflow/compiler_yaml_helpers_test.go b/pkg/workflow/compiler_yaml_helpers_test.go index 15efcf1874..786c77e9cd 100644 --- a/pkg/workflow/compiler_yaml_helpers_test.go +++ b/pkg/workflow/compiler_yaml_helpers_test.go @@ -281,16 +281,18 @@ func TestGenerateWorkflowBody(t *testing.T) { }, }, { - name: "workflow with env", + name: "workflow with env (no longer rendered globally)", data: &WorkflowData{ Name: "Test Workflow", On: "on:\n push:", - Env: "env:\n FOO: bar", + Env: "env:\n FOO: bar", // Legacy field, not rendered globally + EnvMap: map[string]string{ // Job-level env + "FOO": "bar", + }, }, expectInStr: []string{ `name: "Test Workflow"`, - "env:", - "FOO: bar", + // Note: env is no longer rendered globally, only at job level }, }, { diff --git a/pkg/workflow/env_scoping_test.go b/pkg/workflow/env_scoping_test.go new file mode 100644 index 0000000000..80b249428d --- /dev/null +++ b/pkg/workflow/env_scoping_test.go @@ -0,0 +1,354 @@ +//go:build !integration + +package workflow + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestEnvScopingToAgentJob verifies that env variables from frontmatter +// are scoped to the agent job, not applied globally +func TestEnvScopingToAgentJob(t *testing.T) { + frontmatter := map[string]any{ + "name": "Test Env Scoping", + "on": "workflow_dispatch", + "engine": "copilot", + "env": map[string]any{ + "TEST_VAR": "test_value", + "ANOTHER_VAR": "another_value", + }, + } + + compiler := NewCompiler() + + // Initialize workflow data + workflowData := &WorkflowData{ + Name: "Test Env Scoping", + On: "on:\n workflow_dispatch:", + AI: "copilot", + EngineConfig: &EngineConfig{ + ID: "copilot", + }, + MarkdownContent: "# Test content", + } + + // Extract env map from frontmatter + err := compiler.extractYAMLSections(frontmatter, workflowData) + require.NoError(t, err, "extractYAMLSections should succeed") + + // Verify EnvMap was populated + assert.NotNil(t, workflowData.EnvMap, "EnvMap should be populated from frontmatter") + assert.Len(t, workflowData.EnvMap, 2, "EnvMap should have 2 entries") + assert.Equal(t, "test_value", workflowData.EnvMap["TEST_VAR"]) + assert.Equal(t, "another_value", workflowData.EnvMap["ANOTHER_VAR"]) + + // Build the main job + job, err := compiler.buildMainJob(workflowData, false) + require.NoError(t, err, "buildMainJob should succeed") + + // Verify env variables are in the job + assert.NotNil(t, job.Env, "Job should have env variables") + assert.Contains(t, job.Env, "TEST_VAR", "Job env should contain TEST_VAR") + assert.Contains(t, job.Env, "ANOTHER_VAR", "Job env should contain ANOTHER_VAR") + assert.Equal(t, "test_value", job.Env["TEST_VAR"]) + assert.Equal(t, "another_value", job.Env["ANOTHER_VAR"]) + + // Render the job to YAML + jobManager := NewJobManager() + err = jobManager.AddJob(job) + require.NoError(t, err, "AddJob should succeed") + + yamlOutput := jobManager.RenderToYAML() + + // Verify env is at job level, not workflow level + assert.Contains(t, yamlOutput, " env:\n", "Job should have env section") + assert.Contains(t, yamlOutput, " TEST_VAR: test_value", "Job env should contain TEST_VAR") + assert.Contains(t, yamlOutput, " ANOTHER_VAR: another_value", "Job env should contain ANOTHER_VAR") +} + +// TestGlobalEnvNotRendered verifies that the global env section is not rendered +// in the workflow YAML output +func TestGlobalEnvNotRendered(t *testing.T) { + workflowData := &WorkflowData{ + Name: "Test Workflow", + On: "on:\n push:", + Env: "env:\n FOO: bar", // Legacy field, should not be rendered globally + EnvMap: map[string]string{ + "FOO": "bar", + }, + } + + compiler := NewCompiler() + var yamlBuilder strings.Builder + + compiler.generateWorkflowBody(&yamlBuilder, workflowData) + yamlOutput := yamlBuilder.String() + + // Verify global env is NOT in the output + // The output should have permissions, concurrency, run-name, but NOT env at the top level + assert.NotContains(t, yamlOutput, "env:\n FOO:", "Global env section should not be rendered") +} + +// TestEnvMergedWithSafeOutputsEnv verifies that frontmatter env variables +// are merged with safe-outputs env variables at the job level +func TestEnvMergedWithSafeOutputsEnv(t *testing.T) { + frontmatter := map[string]any{ + "name": "Test Env Merging", + "on": "workflow_dispatch", + "engine": "copilot", + "env": map[string]any{ + "CUSTOM_VAR": "custom_value", + }, + "safe-outputs": map[string]any{ + "create-issue": nil, + }, + } + + compiler := NewCompiler() + + // Initialize workflow data + workflowData := &WorkflowData{ + Name: "Test Env Merging", + On: "on:\n workflow_dispatch:", + AI: "copilot", + EngineConfig: &EngineConfig{ + ID: "copilot", + }, + MarkdownContent: "# Test content", + SafeOutputs: compiler.extractSafeOutputsConfig(frontmatter), + } + + // Extract env map from frontmatter + err := compiler.extractYAMLSections(frontmatter, workflowData) + require.NoError(t, err, "extractYAMLSections should succeed") + + // Build the main job + job, err := compiler.buildMainJob(workflowData, false) + require.NoError(t, err, "buildMainJob should succeed") + + // Verify both frontmatter env and safe-outputs env are present + assert.NotNil(t, job.Env, "Job should have env variables") + + // Frontmatter env + assert.Contains(t, job.Env, "CUSTOM_VAR", "Job env should contain custom env from frontmatter") + assert.Equal(t, "custom_value", job.Env["CUSTOM_VAR"]) + + // Safe-outputs env (GH_AW_SAFE_OUTPUTS, etc.) + assert.Contains(t, job.Env, "GH_AW_SAFE_OUTPUTS", "Job env should contain GH_AW_SAFE_OUTPUTS") + assert.Contains(t, job.Env, "GH_AW_SAFE_OUTPUTS_CONFIG_PATH", "Job env should contain config path") +} + +// TestEnvNonStringValues verifies that non-string env values are converted to strings +func TestEnvNonStringValues(t *testing.T) { + frontmatter := map[string]any{ + "name": "Test Non-String Env", + "on": "workflow_dispatch", + "engine": "copilot", + "env": map[string]any{ + "DEBUG_MODE": true, // boolean + "PORT": 3000, // number + "MAX_RETRIES": 5, // number + "STRING_VAR": "test", // string + }, + } + + compiler := NewCompiler() + + workflowData := &WorkflowData{ + Name: "Test Non-String Env", + On: "on:\n workflow_dispatch:", + AI: "copilot", + EngineConfig: &EngineConfig{ + ID: "copilot", + }, + MarkdownContent: "# Test content", + } + + // Extract env map from frontmatter + err := compiler.extractYAMLSections(frontmatter, workflowData) + require.NoError(t, err, "extractYAMLSections should succeed") + + // Verify all types were converted to strings + assert.NotNil(t, workflowData.EnvMap, "EnvMap should be populated") + assert.Equal(t, "true", workflowData.EnvMap["DEBUG_MODE"], "Boolean should be converted to string") + assert.Equal(t, "3000", workflowData.EnvMap["PORT"], "Number should be converted to string") + assert.Equal(t, "5", workflowData.EnvMap["MAX_RETRIES"], "Number should be converted to string") + assert.Equal(t, "test", workflowData.EnvMap["STRING_VAR"], "String should remain unchanged") + + // Build the main job + job, err := compiler.buildMainJob(workflowData, false) + require.NoError(t, err, "buildMainJob should succeed") + + // Verify all converted values are in the job + assert.Equal(t, "true", job.Env["DEBUG_MODE"]) + assert.Equal(t, "3000", job.Env["PORT"]) + assert.Equal(t, "5", job.Env["MAX_RETRIES"]) + assert.Equal(t, "test", job.Env["STRING_VAR"]) +} + +// TestEnvReservedNamesRejection verifies that reserved system variable names +// are rejected at compile time with clear error messages +func TestEnvReservedNamesRejection(t *testing.T) { + tests := []struct { + name string + envVars map[string]any + shouldError bool + errorContains string + }{ + { + name: "GH_AW_ prefix is rejected", + envVars: map[string]any{ + "CUSTOM_VAR": "allowed", + "GH_AW_SAFE_OUTPUTS": "should_fail", + }, + shouldError: true, + errorContains: "GH_AW_SAFE_OUTPUTS", + }, + { + name: "DEFAULT_BRANCH is rejected", + envVars: map[string]any{ + "CUSTOM_VAR": "allowed", + "DEFAULT_BRANCH": "should_fail", + }, + shouldError: true, + errorContains: "DEFAULT_BRANCH", + }, + { + name: "Any GH_AW_ prefix is rejected", + envVars: map[string]any{ + "GH_AW_CUSTOM": "should_fail", + }, + shouldError: true, + errorContains: "GH_AW_", + }, + { + name: "Non-reserved variables are allowed", + envVars: map[string]any{ + "CUSTOM_VAR": "allowed", + "MY_API_KEY": "allowed", + "NODE_ENV": "production", + }, + shouldError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + frontmatter := map[string]any{ + "name": "Test Reserved Names", + "on": "workflow_dispatch", + "engine": "copilot", + "env": tt.envVars, + } + + compiler := NewCompiler() + workflowData := &WorkflowData{ + Name: "Test Reserved Names", + On: "on:\n workflow_dispatch:", + AI: "copilot", + EngineConfig: &EngineConfig{ + ID: "copilot", + }, + MarkdownContent: "# Test content", + } + + // Extract env map from frontmatter - this should validate + err := compiler.extractYAMLSections(frontmatter, workflowData) + + if tt.shouldError { + assert.Error(t, err, "Should fail for reserved variable names") + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains, "Error should mention the problematic variable") + } + } else { + assert.NoError(t, err, "Should succeed for non-reserved variables") + assert.NotNil(t, workflowData.EnvMap, "EnvMap should be populated") + } + }) + } +} + +// TestEnvVariableOrdering verifies that env variables are rendered in stable alphabetical order +func TestEnvVariableOrdering(t *testing.T) { + frontmatter := map[string]any{ + "name": "Test Env Ordering", + "on": "workflow_dispatch", + "engine": "copilot", + "env": map[string]any{ + "ZEBRA": "last", + "ALPHA": "first", + "MIDDLE": "middle", + "BETA": "second", + }, + } + + compiler := NewCompiler() + + workflowData := &WorkflowData{ + Name: "Test Env Ordering", + On: "on:\n workflow_dispatch:", + AI: "copilot", + EngineConfig: &EngineConfig{ + ID: "copilot", + }, + MarkdownContent: "# Test content", + WorkflowID: "test-workflow", + } + + // Extract env map from frontmatter + err := compiler.extractYAMLSections(frontmatter, workflowData) + require.NoError(t, err, "extractYAMLSections should succeed") + + // Build the main job + job, err := compiler.buildMainJob(workflowData, false) + require.NoError(t, err, "buildMainJob should succeed") + + // Render to YAML + jobManager := NewJobManager() + err = jobManager.AddJob(job) + require.NoError(t, err, "AddJob should succeed") + + yamlOutput := jobManager.RenderToYAML() + + // Extract the env section to verify ordering + lines := strings.Split(yamlOutput, "\n") + var envLines []string + inEnvSection := false + for _, line := range lines { + if strings.Contains(line, " env:") { + inEnvSection = true + continue + } + if inEnvSection { + if strings.HasPrefix(line, " ") && strings.Contains(line, ":") { + envLines = append(envLines, line) + } else if !strings.HasPrefix(line, " ") { + break + } + } + } + + // Verify we have env lines + require.Greater(t, len(envLines), 0, "Should have env variables in YAML output") + + // Verify alphabetical ordering + // Expected order: ALPHA, BETA, GH_AW_WORKFLOW_ID_SANITIZED, MIDDLE, ZEBRA + assert.Contains(t, envLines[0], "ALPHA:", "First env var should be ALPHA (alphabetically first user var)") + assert.Contains(t, envLines[1], "BETA:", "Second env var should be BETA") + assert.Contains(t, envLines[2], "GH_AW_WORKFLOW_ID_SANITIZED:", "Third should be GH_AW_WORKFLOW_ID_SANITIZED") + assert.Contains(t, envLines[3], "MIDDLE:", "Fourth env var should be MIDDLE") + assert.Contains(t, envLines[4], "ZEBRA:", "Fifth env var should be ZEBRA") + + // Verify stable ordering by compiling multiple times + for i := 0; i < 5; i++ { + jobManager2 := NewJobManager() + err = jobManager2.AddJob(job) + require.NoError(t, err) + yamlOutput2 := jobManager2.RenderToYAML() + assert.Equal(t, yamlOutput, yamlOutput2, "YAML output should be identical across multiple renderings (stable ordering)") + } +} diff --git a/pkg/workflow/testdata/env-scoping.lock.yml b/pkg/workflow/testdata/env-scoping.lock.yml new file mode 100644 index 0000000000..1dc3b1c70e --- /dev/null +++ b/pkg/workflow/testdata/env-scoping.lock.yml @@ -0,0 +1,527 @@ +# +# ___ _ _ +# / _ \ | | (_) +# | |_| | __ _ ___ _ __ | |_ _ ___ +# | _ |/ _` |/ _ \ '_ \| __| |/ __| +# | | | | (_| | __/ | | | |_| | (__ +# \_| |_/\__, |\___|_| |_|\__|_|\___| +# __/ | +# _ _ |___/ +# | | | | / _| | +# | | | | ___ _ __ _ __| |_| | _____ ____ +# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| +# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ +# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ +# +# This file was automatically generated by gh-aw. DO NOT EDIT. +# +# To update this file, edit the corresponding .md file and run: +# gh aw compile +# Not all edits will cause changes to this file. +# +# For more information: https://github.github.com/gh-aw/introduction/overview/ +# +# +# frontmatter-hash: 860c3d5cf2af4055bfe03b9441fcb00e684b5583f6b0d7df6b13df19550c6912 + +name: "Test Env Scoping" +"on": workflow_dispatch + +permissions: {} + +concurrency: + group: "gh-aw-${{ github.workflow }}" + +run-name: "Test Env Scoping" + +jobs: + activation: + needs: pre_activation + if: needs.pre_activation.outputs.activated == 'true' + runs-on: ubuntu-slim + permissions: + contents: read + outputs: + comment_id: "" + comment_repo: "" + steps: + - name: Checkout actions folder + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: | + actions + persist-credentials: false + - name: Setup Scripts + uses: ./actions/setup + with: + destination: /opt/gh-aw/actions + - name: Checkout .github and .agents folders + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: | + .github + .agents + fetch-depth: 1 + persist-credentials: false + - name: Check workflow file timestamps + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_WORKFLOW_FILE: "env-scoping.lock.yml" + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/check_workflow_timestamp_api.cjs'); + await main(); + - name: Create prompt with built-in context + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + run: | + bash /opt/gh-aw/actions/create_prompt_first.sh + cat << 'GH_AW_PROMPT_EOF' > "$GH_AW_PROMPT" + + GH_AW_PROMPT_EOF + cat "/opt/gh-aw/prompts/xpia.md" >> "$GH_AW_PROMPT" + cat "/opt/gh-aw/prompts/temp_folder_prompt.md" >> "$GH_AW_PROMPT" + cat "/opt/gh-aw/prompts/markdown.md" >> "$GH_AW_PROMPT" + cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" + + The following GitHub context information is available for this workflow: + {{#if __GH_AW_GITHUB_ACTOR__ }} + - **actor**: __GH_AW_GITHUB_ACTOR__ + {{/if}} + {{#if __GH_AW_GITHUB_REPOSITORY__ }} + - **repository**: __GH_AW_GITHUB_REPOSITORY__ + {{/if}} + {{#if __GH_AW_GITHUB_WORKSPACE__ }} + - **workspace**: __GH_AW_GITHUB_WORKSPACE__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ }} + - **issue-number**: #__GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ }} + - **discussion-number**: #__GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ }} + - **pull-request-number**: #__GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_COMMENT_ID__ }} + - **comment-id**: __GH_AW_GITHUB_EVENT_COMMENT_ID__ + {{/if}} + {{#if __GH_AW_GITHUB_RUN_ID__ }} + - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ + {{/if}} + + + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" + + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" + {{#runtime-import env-scoping.md}} + GH_AW_PROMPT_EOF + - name: Interpolate variables and render templates + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/interpolate_prompt.cjs'); + await main(); + - name: Substitute placeholders + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + + const substitutePlaceholders = require('/opt/gh-aw/actions/substitute_placeholders.cjs'); + + // Call the substitution function + return await substitutePlaceholders({ + file: process.env.GH_AW_PROMPT, + substitutions: { + GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, + GH_AW_GITHUB_EVENT_COMMENT_ID: process.env.GH_AW_GITHUB_EVENT_COMMENT_ID, + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: process.env.GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER, + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: process.env.GH_AW_GITHUB_EVENT_ISSUE_NUMBER, + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER, + GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, + GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, + GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE + } + }); + - name: Validate prompt placeholders + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + run: bash /opt/gh-aw/actions/validate_prompt_placeholders.sh + - name: Print prompt + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + run: bash /opt/gh-aw/actions/print_prompt_summary.sh + - name: Upload prompt artifact + if: success() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: prompt + path: /tmp/gh-aw/aw-prompts/prompt.txt + retention-days: 1 + + agent: + needs: activation + runs-on: ubuntu-latest + permissions: + contents: read + concurrency: + group: "gh-aw-copilot-${{ github.workflow }}" + env: + ANOTHER_VAR: another_value + GH_AW_WORKFLOW_ID_SANITIZED: envscoping + TEST_VAR: test_value + outputs: + checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} + model: ${{ steps.generate_aw_info.outputs.model }} + secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} + steps: + - name: Checkout actions folder + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: | + actions + persist-credentials: false + - name: Setup Scripts + uses: ./actions/setup + with: + destination: /opt/gh-aw/actions + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Create gh-aw temp directory + run: bash /opt/gh-aw/actions/create_gh_aw_tmp_dir.sh + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Checkout PR branch + id: checkout-pr + if: | + github.event.pull_request + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/checkout_pr_branch.cjs'); + await main(); + - name: Generate agentic run info + id: generate_aw_info + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const fs = require('fs'); + + const awInfo = { + engine_id: "copilot", + engine_name: "GitHub Copilot CLI", + model: process.env.GH_AW_MODEL_AGENT_COPILOT || "", + version: "", + agent_version: "0.0.410", + workflow_name: "Test Env Scoping", + experimental: false, + supports_tools_allowlist: true, + run_id: context.runId, + run_number: context.runNumber, + run_attempt: process.env.GITHUB_RUN_ATTEMPT, + repository: context.repo.owner + '/' + context.repo.repo, + ref: context.ref, + sha: context.sha, + actor: context.actor, + event_name: context.eventName, + staged: false, + allowed_domains: ["defaults"], + firewall_enabled: true, + awf_version: "v0.19.1", + awmg_version: "v0.1.4", + steps: { + firewall: "squid" + }, + created_at: new Date().toISOString() + }; + + // Write to /tmp/gh-aw directory to avoid inclusion in PR + const tmpPath = '/tmp/gh-aw/aw_info.json'; + fs.writeFileSync(tmpPath, JSON.stringify(awInfo, null, 2)); + console.log('Generated aw_info.json at:', tmpPath); + console.log(JSON.stringify(awInfo, null, 2)); + + // Set model as output for reuse in other steps/jobs + core.setOutput('model', awInfo.model); + - name: Validate COPILOT_GITHUB_TOKEN secret + id: validate-secret + run: /opt/gh-aw/actions/validate_multi_secret.sh COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default + env: + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + - name: Install GitHub Copilot CLI + run: /opt/gh-aw/actions/install_copilot_cli.sh 0.0.410 + - name: Install awf binary + run: bash /opt/gh-aw/actions/install_awf_binary.sh v0.19.1 + - name: Determine automatic lockdown mode for GitHub MCP Server + id: determine-automatic-lockdown + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + with: + script: | + const determineAutomaticLockdown = require('/opt/gh-aw/actions/determine_automatic_lockdown.cjs'); + await determineAutomaticLockdown(github, context, core); + - name: Download container images + run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.19.1 ghcr.io/github/gh-aw-firewall/squid:0.19.1 ghcr.io/github/gh-aw-mcpg:v0.1.4 ghcr.io/github/github-mcp-server:v0.30.3 + - name: Start MCP Gateway + id: start-mcp-gateway + env: + GITHUB_MCP_LOCKDOWN: ${{ steps.determine-automatic-lockdown.outputs.lockdown == 'true' && '1' || '0' }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + run: | + set -eo pipefail + mkdir -p /tmp/gh-aw/mcp-config + + # Export gateway environment variables for MCP config and gateway script + export MCP_GATEWAY_PORT="80" + export MCP_GATEWAY_DOMAIN="host.docker.internal" + MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${MCP_GATEWAY_API_KEY}" + export MCP_GATEWAY_API_KEY + export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" + mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" + export DEBUG="*" + + export GH_AW_ENGINE="copilot" + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_LOCKDOWN -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.4' + + mkdir -p /home/runner/.copilot + cat << GH_AW_MCP_CONFIG_EOF | bash /opt/gh-aw/actions/start_mcp_gateway.sh + { + "mcpServers": { + "github": { + "type": "stdio", + "container": "ghcr.io/github/github-mcp-server:v0.30.3", + "env": { + "GITHUB_LOCKDOWN_MODE": "$GITHUB_MCP_LOCKDOWN", + "GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}", + "GITHUB_READ_ONLY": "1", + "GITHUB_TOOLSETS": "context,repos,issues,pull_requests" + } + } + }, + "gateway": { + "port": $MCP_GATEWAY_PORT, + "domain": "${MCP_GATEWAY_DOMAIN}", + "apiKey": "${MCP_GATEWAY_API_KEY}", + "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" + } + } + GH_AW_MCP_CONFIG_EOF + - name: Generate workflow overview + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { generateWorkflowOverview } = require('/opt/gh-aw/actions/generate_workflow_overview.cjs'); + await generateWorkflowOverview(core); + - name: Download prompt artifact + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: prompt + path: /tmp/gh-aw/aw-prompts + - name: Clean git credentials + run: bash /opt/gh-aw/actions/clean_git_credentials.sh + - name: Execute GitHub Copilot CLI + id: agentic_execution + # Copilot CLI tool arguments (sorted): + timeout-minutes: 20 + run: | + set -o pipefail + sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.19.1 --skip-pull \ + -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-all-tools --allow-all-paths --share /tmp/gh-aw/sandbox/agent/logs/conversation.md --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"${GH_AW_MODEL_DETECTION_COPILOT:+ --model "$GH_AW_MODEL_DETECTION_COPILOT"}' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + env: + COPILOT_AGENT_RUNNER_TYPE: STANDALONE + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + GH_AW_MCP_CONFIG: /home/runner/.copilot/mcp-config.json + GH_AW_MODEL_DETECTION_COPILOT: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || '' }} + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} + GITHUB_WORKSPACE: ${{ github.workspace }} + XDG_CONFIG_HOME: /home/runner + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Copy Copilot session state files to logs + if: always() + continue-on-error: true + run: | + # Copy Copilot session state files to logs folder for artifact collection + # This ensures they are in /tmp/gh-aw/ where secret redaction can scan them + SESSION_STATE_DIR="$HOME/.copilot/session-state" + LOGS_DIR="/tmp/gh-aw/sandbox/agent/logs" + + if [ -d "$SESSION_STATE_DIR" ]; then + echo "Copying Copilot session state files from $SESSION_STATE_DIR to $LOGS_DIR" + mkdir -p "$LOGS_DIR" + cp -v "$SESSION_STATE_DIR"/*.jsonl "$LOGS_DIR/" 2>/dev/null || true + echo "Session state files copied successfully" + else + echo "No session-state directory found at $SESSION_STATE_DIR" + fi + - name: Stop MCP Gateway + if: always() + continue-on-error: true + env: + MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} + MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} + GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }} + run: | + bash /opt/gh-aw/actions/stop_mcp_gateway.sh "$GATEWAY_PID" + - name: Redact secrets in logs + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/redact_secrets.cjs'); + await main(); + env: + GH_AW_SECRET_NAMES: 'COPILOT_GITHUB_TOKEN,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN' + SECRET_COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Upload engine output files + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: agent_outputs + path: | + /tmp/gh-aw/sandbox/agent/logs/ + /tmp/gh-aw/redacted-urls.log + if-no-files-found: ignore + - name: Parse agent logs for step summary + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/ + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/parse_copilot_log.cjs'); + await main(); + - name: Parse MCP Gateway logs for step summary + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/parse_mcp_gateway_log.cjs'); + await main(); + - name: Print firewall logs + if: always() + continue-on-error: true + env: + AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs + run: | + # Fix permissions on firewall logs so they can be uploaded as artifacts + # AWF runs with sudo, creating files owned by root + sudo chmod -R a+r /tmp/gh-aw/sandbox/firewall/logs 2>/dev/null || true + # Only run awf logs summary if awf command exists (it may not be installed if workflow failed before install step) + if command -v awf &> /dev/null; then + awf logs summary | tee -a "$GITHUB_STEP_SUMMARY" + else + echo 'AWF binary not installed, skipping firewall log summary' + fi + - name: Upload agent artifacts + if: always() + continue-on-error: true + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: agent-artifacts + path: | + /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/aw_info.json + /tmp/gh-aw/mcp-logs/ + /tmp/gh-aw/sandbox/firewall/logs/ + /tmp/gh-aw/agent-stdio.log + /tmp/gh-aw/agent/ + if-no-files-found: ignore + + pre_activation: + runs-on: ubuntu-slim + permissions: + contents: read + outputs: + activated: ${{ steps.check_membership.outputs.is_team_member == 'true' }} + steps: + - name: Checkout actions folder + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: | + actions + persist-credentials: false + - name: Setup Scripts + uses: ./actions/setup + with: + destination: /opt/gh-aw/actions + - name: Check team membership for workflow + id: check_membership + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_REQUIRED_ROLES: admin,maintainer,write + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/check_membership.cjs'); + await main(); + diff --git a/pkg/workflow/testdata/env-scoping.md b/pkg/workflow/testdata/env-scoping.md new file mode 100644 index 0000000000..566ce08ee3 --- /dev/null +++ b/pkg/workflow/testdata/env-scoping.md @@ -0,0 +1,12 @@ +--- +name: Test Env Scoping +on: workflow_dispatch +engine: copilot +env: + TEST_VAR: "test_value" + ANOTHER_VAR: "another_value" +--- + +# Test Workflow + +This workflow tests that env variables are scoped to the agent job only.