diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index dee5868af64..e49d686fe57 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -7044,6 +7044,11 @@ "description": "Override the id-token permission for the safe-outputs job. Use 'write' to force-enable the id-token: write permission (required for OIDC authentication with cloud providers). Use 'none' to suppress automatic detection and prevent adding id-token: write even when vault/OIDC actions are detected in steps. By default, the compiler auto-detects known OIDC/vault actions (aws-actions/configure-aws-credentials, azure/login, google-github-actions/auth, hashicorp/vault-action, cyberark/conjur-action) and adds id-token: write automatically.", "examples": ["write", "none"] }, + "concurrency-group": { + "type": "string", + "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 }}"] + }, "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.go b/pkg/workflow/compiler.go index e4500cb1970..e64db99dd09 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -200,6 +200,13 @@ func (c *Compiler) validateWorkflowData(workflowData *WorkflowData, markdownPath } } + // Validate safe-outputs concurrency group expression + if workflowData.SafeOutputs != nil && workflowData.SafeOutputs.ConcurrencyGroup != "" { + if err := validateConcurrencyGroupExpression(workflowData.SafeOutputs.ConcurrencyGroup); err != nil { + return formatCompilerError(markdownPath, "error", "safe-outputs.concurrency-group validation failed: "+err.Error(), err) + } + } + // Emit warning for sandbox.agent: false (disables agent sandbox firewall) if isAgentSandboxDisabled(workflowData) { fmt.Fprintln(os.Stderr, console.FormatWarningMessage("⚠️ WARNING: Agent sandbox disabled (sandbox.agent: false). This removes firewall protection. The AI agent will have direct network access without firewall filtering. The MCP gateway remains enabled. Only use this for testing or in controlled environments where you trust the AI agent completely.")) diff --git a/pkg/workflow/compiler_safe_outputs_job.go b/pkg/workflow/compiler_safe_outputs_job.go index e7f874af230..e02c2fdfff6 100644 --- a/pkg/workflow/compiler_safe_outputs_job.go +++ b/pkg/workflow/compiler_safe_outputs_job.go @@ -334,12 +334,20 @@ func (c *Compiler) buildConsolidatedSafeOutputsJob(data *WorkflowData, mainJobNa // Build job-level environment variables that are common to all safe output steps jobEnv := c.buildJobLevelSafeOutputEnvVars(data, workflowID) + // Build concurrency config for the safe-outputs job if a concurrency-group is configured + var concurrency string + if data.SafeOutputs.ConcurrencyGroup != "" { + concurrency = c.indentYAMLLines(fmt.Sprintf("concurrency:\n group: %q\n cancel-in-progress: false", data.SafeOutputs.ConcurrencyGroup), " ") + consolidatedSafeOutputsJobLog.Printf("Configuring safe_outputs job concurrency group: %s", data.SafeOutputs.ConcurrencyGroup) + } + job := &Job{ Name: "safe_outputs", If: jobCondition.Render(), RunsOn: c.formatSafeOutputsRunsOn(data.SafeOutputs), Permissions: permissions.RenderToYAML(), TimeoutMinutes: 15, // Slightly longer timeout for consolidated job with multiple steps + Concurrency: concurrency, Env: jobEnv, Steps: steps, Outputs: outputs, diff --git a/pkg/workflow/compiler_safe_outputs_job_test.go b/pkg/workflow/compiler_safe_outputs_job_test.go index 0a327833b84..3f6539d8481 100644 --- a/pkg/workflow/compiler_safe_outputs_job_test.go +++ b/pkg/workflow/compiler_safe_outputs_job_test.go @@ -161,7 +161,59 @@ func TestBuildConsolidatedSafeOutputsJob(t *testing.T) { } } -// TestBuildJobLevelSafeOutputEnvVars tests job-level environment variable generation +// TestBuildConsolidatedSafeOutputsJobConcurrencyGroup tests that the concurrency-group field +// is correctly applied to the safe_outputs job +func TestBuildConsolidatedSafeOutputsJobConcurrencyGroup(t *testing.T) { + tests := []struct { + name string + concurrencyGroup string + expectConcurrency bool + }{ + { + name: "no concurrency group", + concurrencyGroup: "", + expectConcurrency: false, + }, + { + name: "simple concurrency group", + concurrencyGroup: "my-safe-outputs", + expectConcurrency: true, + }, + { + name: "concurrency group with expression", + concurrencyGroup: "safe-outputs-${{ github.repository }}", + expectConcurrency: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + compiler := NewCompiler() + compiler.jobManager = NewJobManager() + + workflowData := &WorkflowData{ + Name: "Test Workflow", + SafeOutputs: &SafeOutputsConfig{ + CreateIssues: &CreateIssuesConfig{TitlePrefix: "[Test] "}, + ConcurrencyGroup: tt.concurrencyGroup, + }, + } + + job, _, err := compiler.buildConsolidatedSafeOutputsJob(workflowData, string(constants.AgentJobName), "test-workflow.md") + require.NoError(t, err, "Should build job without error") + require.NotNil(t, job, "Job should not be nil") + + if tt.expectConcurrency { + assert.NotEmpty(t, job.Concurrency, "Job should have concurrency set") + assert.Contains(t, job.Concurrency, tt.concurrencyGroup, "Concurrency should contain the group value") + assert.Contains(t, job.Concurrency, "cancel-in-progress: false", "Concurrency should have cancel-in-progress: false") + } else { + assert.Empty(t, job.Concurrency, "Job should have no concurrency set") + } + }) + } +} + func TestBuildJobLevelSafeOutputEnvVars(t *testing.T) { tests := []struct { name string diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index f96fae0b86d..0a326fad25e 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -493,6 +493,7 @@ type SafeOutputsConfig struct { MaxBotMentions *string `yaml:"max-bot-mentions,omitempty"` // Maximum bot trigger references (e.g. 'fixes #123') allowed before filtering. Default: 10. Supports integer or GitHub Actions expression. 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) AutoInjectedCreateIssue bool `yaml:"-"` // Internal: true when create-issues was automatically injected by the compiler (not user-configured) } diff --git a/pkg/workflow/safe_outputs_config.go b/pkg/workflow/safe_outputs_config.go index 6f555b644e4..1d7dad82bb3 100644 --- a/pkg/workflow/safe_outputs_config.go +++ b/pkg/workflow/safe_outputs_config.go @@ -481,6 +481,14 @@ func (c *Compiler) extractSafeOutputsConfig(frontmatter map[string]any) *SafeOut } } + // Handle concurrency-group configuration + if concurrencyGroup, exists := outputMap["concurrency-group"]; exists { + if concurrencyGroupStr, ok := concurrencyGroup.(string); ok && concurrencyGroupStr != "" { + config.ConcurrencyGroup = concurrencyGroupStr + safeOutputsConfigLog.Printf("Configured concurrency-group for safe-outputs job: %s", concurrencyGroupStr) + } + } + // Handle jobs (safe-jobs must be under safe-outputs) if jobs, exists := outputMap["jobs"]; exists { if jobsMap, ok := jobs.(map[string]any); ok {