Skip to content

Commit ec3c9db

Browse files
authored
Add concurrency-group support to safe_outputs job (#18993)
1 parent a9e41e2 commit ec3c9db

6 files changed

Lines changed: 82 additions & 1 deletion

File tree

pkg/parser/schemas/main_workflow_schema.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7044,6 +7044,11 @@
70447044
"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.",
70457045
"examples": ["write", "none"]
70467046
},
7047+
"concurrency-group": {
7048+
"type": "string",
7049+
"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.",
7050+
"examples": ["my-workflow-safe-outputs", "safe-outputs-${{ github.repository }}"]
7051+
},
70477052
"runs-on": {
70487053
"type": "string",
70497054
"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/"

pkg/workflow/compiler.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,13 @@ func (c *Compiler) validateWorkflowData(workflowData *WorkflowData, markdownPath
200200
}
201201
}
202202

203+
// Validate safe-outputs concurrency group expression
204+
if workflowData.SafeOutputs != nil && workflowData.SafeOutputs.ConcurrencyGroup != "" {
205+
if err := validateConcurrencyGroupExpression(workflowData.SafeOutputs.ConcurrencyGroup); err != nil {
206+
return formatCompilerError(markdownPath, "error", "safe-outputs.concurrency-group validation failed: "+err.Error(), err)
207+
}
208+
}
209+
203210
// Emit warning for sandbox.agent: false (disables agent sandbox firewall)
204211
if isAgentSandboxDisabled(workflowData) {
205212
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."))

pkg/workflow/compiler_safe_outputs_job.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,12 +334,20 @@ func (c *Compiler) buildConsolidatedSafeOutputsJob(data *WorkflowData, mainJobNa
334334
// Build job-level environment variables that are common to all safe output steps
335335
jobEnv := c.buildJobLevelSafeOutputEnvVars(data, workflowID)
336336

337+
// Build concurrency config for the safe-outputs job if a concurrency-group is configured
338+
var concurrency string
339+
if data.SafeOutputs.ConcurrencyGroup != "" {
340+
concurrency = c.indentYAMLLines(fmt.Sprintf("concurrency:\n group: %q\n cancel-in-progress: false", data.SafeOutputs.ConcurrencyGroup), " ")
341+
consolidatedSafeOutputsJobLog.Printf("Configuring safe_outputs job concurrency group: %s", data.SafeOutputs.ConcurrencyGroup)
342+
}
343+
337344
job := &Job{
338345
Name: "safe_outputs",
339346
If: jobCondition.Render(),
340347
RunsOn: c.formatSafeOutputsRunsOn(data.SafeOutputs),
341348
Permissions: permissions.RenderToYAML(),
342349
TimeoutMinutes: 15, // Slightly longer timeout for consolidated job with multiple steps
350+
Concurrency: concurrency,
343351
Env: jobEnv,
344352
Steps: steps,
345353
Outputs: outputs,

pkg/workflow/compiler_safe_outputs_job_test.go

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,59 @@ func TestBuildConsolidatedSafeOutputsJob(t *testing.T) {
161161
}
162162
}
163163

164-
// TestBuildJobLevelSafeOutputEnvVars tests job-level environment variable generation
164+
// TestBuildConsolidatedSafeOutputsJobConcurrencyGroup tests that the concurrency-group field
165+
// is correctly applied to the safe_outputs job
166+
func TestBuildConsolidatedSafeOutputsJobConcurrencyGroup(t *testing.T) {
167+
tests := []struct {
168+
name string
169+
concurrencyGroup string
170+
expectConcurrency bool
171+
}{
172+
{
173+
name: "no concurrency group",
174+
concurrencyGroup: "",
175+
expectConcurrency: false,
176+
},
177+
{
178+
name: "simple concurrency group",
179+
concurrencyGroup: "my-safe-outputs",
180+
expectConcurrency: true,
181+
},
182+
{
183+
name: "concurrency group with expression",
184+
concurrencyGroup: "safe-outputs-${{ github.repository }}",
185+
expectConcurrency: true,
186+
},
187+
}
188+
189+
for _, tt := range tests {
190+
t.Run(tt.name, func(t *testing.T) {
191+
compiler := NewCompiler()
192+
compiler.jobManager = NewJobManager()
193+
194+
workflowData := &WorkflowData{
195+
Name: "Test Workflow",
196+
SafeOutputs: &SafeOutputsConfig{
197+
CreateIssues: &CreateIssuesConfig{TitlePrefix: "[Test] "},
198+
ConcurrencyGroup: tt.concurrencyGroup,
199+
},
200+
}
201+
202+
job, _, err := compiler.buildConsolidatedSafeOutputsJob(workflowData, string(constants.AgentJobName), "test-workflow.md")
203+
require.NoError(t, err, "Should build job without error")
204+
require.NotNil(t, job, "Job should not be nil")
205+
206+
if tt.expectConcurrency {
207+
assert.NotEmpty(t, job.Concurrency, "Job should have concurrency set")
208+
assert.Contains(t, job.Concurrency, tt.concurrencyGroup, "Concurrency should contain the group value")
209+
assert.Contains(t, job.Concurrency, "cancel-in-progress: false", "Concurrency should have cancel-in-progress: false")
210+
} else {
211+
assert.Empty(t, job.Concurrency, "Job should have no concurrency set")
212+
}
213+
})
214+
}
215+
}
216+
165217
func TestBuildJobLevelSafeOutputEnvVars(t *testing.T) {
166218
tests := []struct {
167219
name string

pkg/workflow/compiler_types.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,7 @@ type SafeOutputsConfig struct {
493493
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.
494494
Steps []any `yaml:"steps,omitempty"` // User-provided steps injected after setup/checkout and before safe-output code
495495
IDToken *string `yaml:"id-token,omitempty"` // Override id-token permission: "write" to force-add, "none" to disable auto-detection
496+
ConcurrencyGroup string `yaml:"concurrency-group,omitempty"` // Concurrency group for the safe-outputs job (cancel-in-progress is always false)
496497
AutoInjectedCreateIssue bool `yaml:"-"` // Internal: true when create-issues was automatically injected by the compiler (not user-configured)
497498
}
498499

pkg/workflow/safe_outputs_config.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,14 @@ func (c *Compiler) extractSafeOutputsConfig(frontmatter map[string]any) *SafeOut
481481
}
482482
}
483483

484+
// Handle concurrency-group configuration
485+
if concurrencyGroup, exists := outputMap["concurrency-group"]; exists {
486+
if concurrencyGroupStr, ok := concurrencyGroup.(string); ok && concurrencyGroupStr != "" {
487+
config.ConcurrencyGroup = concurrencyGroupStr
488+
safeOutputsConfigLog.Printf("Configured concurrency-group for safe-outputs job: %s", concurrencyGroupStr)
489+
}
490+
}
491+
484492
// Handle jobs (safe-jobs must be under safe-outputs)
485493
if jobs, exists := outputMap["jobs"]; exists {
486494
if jobsMap, ok := jobs.(map[string]any); ok {

0 commit comments

Comments
 (0)