From 018eccde7be2522218ee7ccffa7d3043d029ae44 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:51:57 +0000 Subject: [PATCH 1/3] Initial plan From a91fa2c82acc3734c03c15c36f3e42a016397d51 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:16:25 +0000 Subject: [PATCH 2/3] fix: propagate environment field to safe_outputs job (#fix-safe-outputs-environment-field) Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/parser/schemas/main_workflow_schema.json | 25 ++++ pkg/workflow/compiler_safe_outputs_job.go | 10 ++ pkg/workflow/compiler_types.go | 1 + pkg/workflow/environment_test.go | 135 +++++++++++++++++++ pkg/workflow/safe_outputs_config.go | 6 + 5 files changed, 177 insertions(+) diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index bd0ed6b7e6d..19b85767c11 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -7224,6 +7224,31 @@ "description": "Concurrency group for the safe-outputs job. When set, the safe-outputs job will use this concurrency group with cancel-in-progress: false. Supports GitHub Actions expressions.", "examples": ["my-workflow-safe-outputs", "safe-outputs-${{ github.repository }}"] }, + "environment": { + "description": "Override the GitHub deployment environment for the safe-outputs job. When set, this environment is used instead of the top-level environment: field. When not set, the top-level environment: field is propagated automatically so that environment-scoped secrets are accessible in the safe-outputs job.", + "oneOf": [ + { + "type": "string", + "description": "Environment name as a string" + }, + { + "type": "object", + "description": "Environment object with name and optional URL", + "properties": { + "name": { + "type": "string", + "description": "The name of the environment configured in the repo" + }, + "url": { + "type": "string", + "description": "A deployment URL" + } + }, + "required": ["name"], + "additionalProperties": false + } + ] + }, "runs-on": { "type": "string", "description": "Runner specification for all safe-outputs jobs (activation, create-issue, add-comment, etc.). Single runner label (e.g., 'ubuntu-slim', 'ubuntu-latest', 'windows-latest', 'self-hosted'). Defaults to 'ubuntu-slim'. See https://github.blog/changelog/2025-10-28-1-vcpu-linux-runner-now-available-in-github-actions-in-public-preview/" diff --git a/pkg/workflow/compiler_safe_outputs_job.go b/pkg/workflow/compiler_safe_outputs_job.go index 7073021b739..ed1a6bf4327 100644 --- a/pkg/workflow/compiler_safe_outputs_job.go +++ b/pkg/workflow/compiler_safe_outputs_job.go @@ -338,10 +338,20 @@ func (c *Compiler) buildConsolidatedSafeOutputsJob(data *WorkflowData, mainJobNa consolidatedSafeOutputsJobLog.Printf("Configuring safe_outputs job concurrency group: %s", data.SafeOutputs.ConcurrencyGroup) } + // Determine the environment for the safe-outputs job. + // If safe-outputs.environment is explicitly set, use that override. + // Otherwise, propagate the top-level environment: field so that environment-scoped + // secrets (e.g. for GitHub App token minting) are accessible in this job. + safeOutputsEnvironment := data.SafeOutputs.Environment + if safeOutputsEnvironment == "" { + safeOutputsEnvironment = data.Environment + } + job := &Job{ Name: "safe_outputs", If: jobCondition.Render(), RunsOn: c.formatSafeOutputsRunsOn(data.SafeOutputs), + Environment: c.indentYAMLLines(safeOutputsEnvironment, " "), Permissions: permissions.RenderToYAML(), TimeoutMinutes: 15, // Slightly longer timeout for consolidated job with multiple steps Concurrency: concurrency, diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index 3b5cbd9a228..4185f8e8933 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -501,6 +501,7 @@ type SafeOutputsConfig struct { Steps []any `yaml:"steps,omitempty"` // User-provided steps injected after setup/checkout and before safe-output code IDToken *string `yaml:"id-token,omitempty"` // Override id-token permission: "write" to force-add, "none" to disable auto-detection ConcurrencyGroup string `yaml:"concurrency-group,omitempty"` // Concurrency group for the safe-outputs job (cancel-in-progress is always false) + Environment string `yaml:"environment,omitempty"` // Override the GitHub deployment environment for the safe-outputs job (defaults to the top-level environment: field) AutoInjectedCreateIssue bool `yaml:"-"` // Internal: true when create-issues was automatically injected by the compiler (not user-configured) } diff --git a/pkg/workflow/environment_test.go b/pkg/workflow/environment_test.go index 097227b2dc4..b16fc6d9b58 100644 --- a/pkg/workflow/environment_test.go +++ b/pkg/workflow/environment_test.go @@ -4,10 +4,13 @@ package workflow import ( "os" + "path/filepath" "strings" "testing" "github.com/github/gh-aw/pkg/constants" + "github.com/github/gh-aw/pkg/stringutil" + "github.com/github/gh-aw/pkg/testutil" ) func TestEnvironmentSupport(t *testing.T) { @@ -220,3 +223,135 @@ This is a test.` t.Errorf("Expected properly indented environment section, but got:\n%s", yamlContent) } } + +// TestSafeOutputsEnvironmentPropagation verifies that the top-level environment: field is +// propagated to the safe_outputs job so that environment-scoped secrets are accessible. +func TestSafeOutputsEnvironmentPropagation(t *testing.T) { + tests := []struct { + name string + frontmatter string + expectEnvInSafe bool + expectedEnvValue string + }{ + { + name: "top-level environment propagated to safe_outputs job", + frontmatter: `--- +on: + issues: + types: [opened] +environment: production +safe-outputs: + add-comment: {} +--- + +# Test Workflow + +This is a test.`, + expectEnvInSafe: true, + expectedEnvValue: "environment: production", + }, + { + name: "safe-outputs environment overrides top-level environment", + frontmatter: `--- +on: + issues: + types: [opened] +environment: production +safe-outputs: + environment: staging + add-comment: {} +--- + +# Test Workflow + +This is a test.`, + expectEnvInSafe: true, + expectedEnvValue: "environment: staging", + }, + { + name: "no environment means safe_outputs has no environment", + frontmatter: `--- +on: + issues: + types: [opened] +safe-outputs: + add-comment: {} +--- + +# Test Workflow + +This is a test.`, + expectEnvInSafe: false, + expectedEnvValue: "", + }, + { + name: "safe-outputs-only environment when no top-level environment", + frontmatter: `--- +on: + issues: + types: [opened] +safe-outputs: + environment: dev + add-comment: {} +--- + +# Test Workflow + +This is a test.`, + expectEnvInSafe: true, + expectedEnvValue: "environment: dev", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := testutil.TempDir(t, "safe-outputs-env-test") + workflowFile := filepath.Join(tmpDir, "test.md") + if err := os.WriteFile(workflowFile, []byte(tt.frontmatter), 0644); err != nil { + t.Fatalf("Failed to write workflow file: %v", err) + } + + compiler := NewCompiler() + if err := compiler.CompileWorkflow(workflowFile); err != nil { + t.Fatalf("CompileWorkflow() error: %v", err) + } + + lockFile := stringutil.MarkdownToLockFile(workflowFile) + lockContent, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + yamlStr := string(lockContent) + + // Find the safe_outputs job section + safeOutputsIdx := strings.Index(yamlStr, " safe_outputs:\n") + if safeOutputsIdx == -1 { + t.Fatal("safe_outputs job not found in generated YAML") + } + + // Find the next top-level job after safe_outputs (indented by 2 spaces) + nextJobIdx := len(yamlStr) + lines := strings.Split(yamlStr[safeOutputsIdx+len(" safe_outputs:\n"):], "\n") + offset := safeOutputsIdx + len(" safe_outputs:\n") + for _, line := range lines { + if line != "" && !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, " #") { + nextJobIdx = offset + break + } + offset += len(line) + 1 + } + + safeOutputsSection := yamlStr[safeOutputsIdx:nextJobIdx] + + if tt.expectEnvInSafe { + if !strings.Contains(safeOutputsSection, tt.expectedEnvValue) { + t.Errorf("Expected safe_outputs job to contain %q, but got:\n%s", tt.expectedEnvValue, safeOutputsSection) + } + } else { + if strings.Contains(safeOutputsSection, "environment:") { + t.Errorf("Expected safe_outputs job to have no environment field, but found one in:\n%s", safeOutputsSection) + } + } + }) + } +} diff --git a/pkg/workflow/safe_outputs_config.go b/pkg/workflow/safe_outputs_config.go index 03c79c69d3e..9a1d6b8deba 100644 --- a/pkg/workflow/safe_outputs_config.go +++ b/pkg/workflow/safe_outputs_config.go @@ -506,6 +506,12 @@ func (c *Compiler) extractSafeOutputsConfig(frontmatter map[string]any) *SafeOut } } + // Handle environment configuration (override for safe-outputs job; falls back to top-level environment) + config.Environment = c.extractTopLevelYAMLSection(outputMap, "environment") + if config.Environment != "" { + safeOutputsConfigLog.Printf("Configured environment override for safe-outputs job: %s", config.Environment) + } + // Handle jobs (safe-jobs must be under safe-outputs) if jobs, exists := outputMap["jobs"]; exists { if jobsMap, ok := jobs.(map[string]any); ok { From 597a0c9f5179a4467d8e3b061f9837eb1903e83f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:31:26 +0000 Subject: [PATCH 3/3] fix: propagate environment field to all safe-output jobs (conclusion, pre_activation, safe-jobs, upload_assets) Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/compiler_pre_activation_job.go | 1 + pkg/workflow/compiler_safe_outputs_job.go | 16 ++++++++++++---- pkg/workflow/notify_comment.go | 1 + pkg/workflow/safe_jobs.go | 3 ++- pkg/workflow/safe_outputs_jobs.go | 1 + 5 files changed, 17 insertions(+), 5 deletions(-) diff --git a/pkg/workflow/compiler_pre_activation_job.go b/pkg/workflow/compiler_pre_activation_job.go index 334ee2509f6..0d42c7a5885 100644 --- a/pkg/workflow/compiler_pre_activation_job.go +++ b/pkg/workflow/compiler_pre_activation_job.go @@ -311,6 +311,7 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec Name: string(constants.PreActivationJobName), If: jobIfCondition, RunsOn: c.formatSafeOutputsRunsOn(data.SafeOutputs), + Environment: c.indentYAMLLines(resolveSafeOutputsEnvironment(data), " "), Permissions: permissions, Steps: steps, Outputs: outputs, diff --git a/pkg/workflow/compiler_safe_outputs_job.go b/pkg/workflow/compiler_safe_outputs_job.go index ed1a6bf4327..323edbc3b1f 100644 --- a/pkg/workflow/compiler_safe_outputs_job.go +++ b/pkg/workflow/compiler_safe_outputs_job.go @@ -342,10 +342,7 @@ func (c *Compiler) buildConsolidatedSafeOutputsJob(data *WorkflowData, mainJobNa // If safe-outputs.environment is explicitly set, use that override. // Otherwise, propagate the top-level environment: field so that environment-scoped // secrets (e.g. for GitHub App token minting) are accessible in this job. - safeOutputsEnvironment := data.SafeOutputs.Environment - if safeOutputsEnvironment == "" { - safeOutputsEnvironment = data.Environment - } + safeOutputsEnvironment := resolveSafeOutputsEnvironment(data) job := &Job{ Name: "safe_outputs", @@ -442,6 +439,17 @@ func (c *Compiler) buildJobLevelSafeOutputEnvVars(data *WorkflowData, workflowID return envVars } +// resolveSafeOutputsEnvironment resolves the effective GitHub deployment environment for +// safe-output jobs. If safe-outputs.environment is explicitly set, it takes precedence. +// Otherwise the top-level environment: field is propagated so that environment-scoped +// secrets are accessible in all safe-output jobs. +func resolveSafeOutputsEnvironment(data *WorkflowData) string { + if data.SafeOutputs != nil && data.SafeOutputs.Environment != "" { + return data.SafeOutputs.Environment + } + return data.Environment +} + // buildDetectionSuccessCondition builds the condition to check if detection passed. // Detection runs inline in the agent job and outputs detection_success. func buildDetectionSuccessCondition() ConditionNode { diff --git a/pkg/workflow/notify_comment.go b/pkg/workflow/notify_comment.go index c205d94adda..a477439f380 100644 --- a/pkg/workflow/notify_comment.go +++ b/pkg/workflow/notify_comment.go @@ -415,6 +415,7 @@ func (c *Compiler) buildConclusionJob(data *WorkflowData, mainJobName string, sa Name: "conclusion", If: condition.Render(), RunsOn: c.formatSafeOutputsRunsOn(data.SafeOutputs), + Environment: c.indentYAMLLines(resolveSafeOutputsEnvironment(data), " "), Permissions: permissions.RenderToYAML(), Concurrency: concurrency, Steps: steps, diff --git a/pkg/workflow/safe_jobs.go b/pkg/workflow/safe_jobs.go index 11f62072c58..de3dbea4846 100644 --- a/pkg/workflow/safe_jobs.go +++ b/pkg/workflow/safe_jobs.go @@ -166,7 +166,8 @@ func (c *Compiler) buildSafeJobs(data *WorkflowData, threatDetectionEnabled bool normalizedJobName := stringutil.NormalizeSafeOutputIdentifier(jobName) job := &Job{ - Name: normalizedJobName, + Name: normalizedJobName, + Environment: c.indentYAMLLines(resolveSafeOutputsEnvironment(data), " "), } // Set custom job name if specified diff --git a/pkg/workflow/safe_outputs_jobs.go b/pkg/workflow/safe_outputs_jobs.go index ee703d15f1b..4250d8dc8b0 100644 --- a/pkg/workflow/safe_outputs_jobs.go +++ b/pkg/workflow/safe_outputs_jobs.go @@ -139,6 +139,7 @@ func (c *Compiler) buildSafeOutputJob(data *WorkflowData, config SafeOutputJobCo Name: config.JobName, If: jobCondition.Render(), RunsOn: c.formatSafeOutputsRunsOn(data.SafeOutputs), + Environment: c.indentYAMLLines(resolveSafeOutputsEnvironment(data), " "), Permissions: config.Permissions.RenderToYAML(), TimeoutMinutes: 10, // 10-minute timeout as required for all safe output jobs Steps: steps,