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
25 changes: 25 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The schema description says the safe-outputs.environment override (and top-level propagation) applies to the “safe-outputs job”, but the implementation applies it to all safe-output jobs (including conclusion, pre_activation, upload_assets, and custom safe-jobs). Please update the description to match the implemented behavior so users aren’t surprised by environment protections/secret scoping across those jobs.

Suggested change
"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.",
"description": "Override the GitHub deployment environment for all safe-output jobs (including the main safe-outputs job, conclusion, pre_activation, upload_assets, and any custom safe-output jobs). 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 across all safe-output jobs.",

Copilot uses AI. Check for mistakes.
"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/"
Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/compiler_pre_activation_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions pkg/workflow/compiler_safe_outputs_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -338,10 +338,17 @@ 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 := resolveSafeOutputsEnvironment(data)

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,
Expand Down Expand Up @@ -432,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 {
Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/compiler_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new SafeOutputsConfig.Environment field/comment says it overrides the environment for the “safe-outputs job”, but this PR applies the resolved environment to multiple safe-output jobs (safe_outputs, conclusion, pre_activation, buildSafeOutputJob jobs, and custom safe-jobs). Please update the field comment to reflect that it overrides the deployment environment for all safe-output jobs (or adjust behavior to match the comment).

Suggested change
Environment string `yaml:"environment,omitempty"` // Override the GitHub deployment environment for the safe-outputs job (defaults to the top-level environment: field)
Environment string `yaml:"environment,omitempty"` // Override the GitHub deployment environment for all safe-output jobs (defaults to the top-level environment: field)

Copilot uses AI. Check for mistakes.
AutoInjectedCreateIssue bool `yaml:"-"` // Internal: true when create-issues was automatically injected by the compiler (not user-configured)
}

Expand Down
135 changes: 135 additions & 0 deletions pkg/workflow/environment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Comment on lines +227 to +233
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test only asserts environment propagation for the safe_outputs job, but the PR also adds environment propagation to other generated safe-output jobs (e.g., conclusion, pre_activation, upload_assets, and custom safe-jobs). Please extend test coverage to assert at least one additional job (conclusion or pre_activation) receives the resolved environment, so future changes don’t regress propagation on those jobs silently.

Copilot uses AI. Check for mistakes.
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)
}
}
})
}
}
1 change: 1 addition & 0 deletions pkg/workflow/notify_comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion pkg/workflow/safe_jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions pkg/workflow/safe_outputs_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +509 to +512
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This log message and nearby comment refer to an environment override “for safe-outputs job”, but the resolved environment is used for all safe-output jobs in this PR. Please adjust wording to avoid misleading operators when reading logs/debugging.

Suggested change
// 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 environment configuration (override for all safe-outputs jobs; falls back to top-level environment)
config.Environment = c.extractTopLevelYAMLSection(outputMap, "environment")
if config.Environment != "" {
safeOutputsConfigLog.Printf("Configured environment override for all safe-outputs jobs: %s", config.Environment)

Copilot uses AI. Check for mistakes.
}

// Handle jobs (safe-jobs must be under safe-outputs)
if jobs, exists := outputMap["jobs"]; exists {
if jobsMap, ok := jobs.(map[string]any); ok {
Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/safe_outputs_jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading