diff --git a/.github/workflows/ai-moderator.lock.yml b/.github/workflows/ai-moderator.lock.yml index bf355f9fea..0e07f755f4 100644 --- a/.github/workflows/ai-moderator.lock.yml +++ b/.github/workflows/ai-moderator.lock.yml @@ -1129,7 +1129,7 @@ jobs: - activation - agent if: always() - runs-on: ubuntu-latest + runs-on: ubuntu-slim permissions: contents: read issues: write diff --git a/.github/workflows/smoke-copilot-arm.lock.yml b/.github/workflows/smoke-copilot-arm.lock.yml index 5b0f068311..d872e96259 100644 --- a/.github/workflows/smoke-copilot-arm.lock.yml +++ b/.github/workflows/smoke-copilot-arm.lock.yml @@ -1977,7 +1977,7 @@ jobs: detection: needs: agent if: needs.agent.outputs.output_types != '' || needs.agent.outputs.has_patch == 'true' - runs-on: ubuntu-latest + runs-on: ubuntu-24.04-arm permissions: contents: read timeout-minutes: 10 diff --git a/.github/workflows/workflow-generator.lock.yml b/.github/workflows/workflow-generator.lock.yml index cc4adb3b41..5cc3e0362b 100644 --- a/.github/workflows/workflow-generator.lock.yml +++ b/.github/workflows/workflow-generator.lock.yml @@ -1313,7 +1313,7 @@ jobs: - agent - detection if: always() - runs-on: ubuntu-latest + runs-on: ubuntu-slim permissions: contents: read issues: write diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index d46a72dbb7..f720ab7e21 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -6611,6 +6611,10 @@ "items": { "$ref": "#/$defs/githubActionsStep" } + }, + "runs-on": { + "type": "string", + "description": "Runner specification for the detection job. Overrides agent.runs-on for the detection job only. Defaults to agent.runs-on." } }, "additionalProperties": false diff --git a/pkg/workflow/compiler_unlock_job.go b/pkg/workflow/compiler_unlock_job.go index f1446dec7a..a8f27c7755 100644 --- a/pkg/workflow/compiler_unlock_job.go +++ b/pkg/workflow/compiler_unlock_job.go @@ -100,7 +100,7 @@ func (c *Compiler) buildUnlockJob(data *WorkflowData, threatDetectionEnabled boo Name: "unlock", Needs: needs, If: alwaysFunc.Render(), - RunsOn: data.RunsOn, + RunsOn: c.formatSafeOutputsRunsOn(data.SafeOutputs), Permissions: permissions, Steps: steps, TimeoutMinutes: 5, // Short timeout - unlock is a quick operation diff --git a/pkg/workflow/safe_outputs_config_helpers.go b/pkg/workflow/safe_outputs_config_helpers.go index 47305d412c..035a6f1490 100644 --- a/pkg/workflow/safe_outputs_config_helpers.go +++ b/pkg/workflow/safe_outputs_config_helpers.go @@ -121,6 +121,16 @@ func (c *Compiler) formatSafeOutputsRunsOn(safeOutputs *SafeOutputsConfig) strin return "runs-on: " + safeOutputs.RunsOn } +// formatDetectionRunsOn resolves the runner for the detection job using the following priority: +// 1. safe-outputs.detection.runs-on (detection-specific override) +// 2. agentRunsOn (the agent job's runner, passed by the caller) +func (c *Compiler) formatDetectionRunsOn(safeOutputs *SafeOutputsConfig, agentRunsOn string) string { + if safeOutputs != nil && safeOutputs.ThreatDetection != nil && safeOutputs.ThreatDetection.RunsOn != "" { + return "runs-on: " + safeOutputs.ThreatDetection.RunsOn + } + return agentRunsOn +} + // builtinSafeOutputFields contains the struct field names for the built-in safe output types // that are excluded from the "non-builtin" check. These are: noop, missing-data, missing-tool. var builtinSafeOutputFields = map[string]bool{ diff --git a/pkg/workflow/safe_outputs_runs_on_test.go b/pkg/workflow/safe_outputs_runs_on_test.go index d9bcdee427..14724db676 100644 --- a/pkg/workflow/safe_outputs_runs_on_test.go +++ b/pkg/workflow/safe_outputs_runs_on_test.go @@ -184,3 +184,148 @@ func TestFormatSafeOutputsRunsOnEdgeCases(t *testing.T) { }) } } + +func TestUnlockJobUsesRunsOn(t *testing.T) { + frontmatter := `--- +on: + issues: + types: [opened] + lock-for-agent: true +safe-outputs: + create-issue: + title-prefix: "[ai] " + runs-on: self-hosted +--- + +# Test Workflow + +This is a test workflow.` + + tmpDir := testutil.TempDir(t, "workflow-unlock-runs-on-test") + + testFile := filepath.Join(tmpDir, "test.md") + if err := os.WriteFile(testFile, []byte(frontmatter), 0644); err != nil { + t.Fatal(err) + } + + compiler := NewCompiler() + if err := compiler.CompileWorkflow(testFile); err != nil { + t.Fatalf("Failed to compile workflow: %v", err) + } + + lockFile := filepath.Join(tmpDir, "test.lock.yml") + yamlContent, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + + yamlStr := string(yamlContent) + + // Verify the unlock job uses the safe-outputs runs-on value + expectedRunsOn := "runs-on: self-hosted" + unlockJobPattern := "\n unlock:" + unlockStart := strings.Index(yamlStr, unlockJobPattern) + if unlockStart == -1 { + t.Fatal("Expected unlock job to be present in compiled YAML") + } + + unlockSection := yamlStr[unlockStart : unlockStart+500] + defaultRunsOn := "runs-on: " + constants.DefaultActivationJobRunnerImage + if strings.Contains(unlockSection, defaultRunsOn) { + t.Errorf("Unlock job uses default %q instead of safe-outputs runner.\nUnlock section:\n%s", defaultRunsOn, unlockSection) + } + if !strings.Contains(unlockSection, expectedRunsOn) { + t.Errorf("Unlock job does not use expected %q.\nUnlock section:\n%s", expectedRunsOn, unlockSection) + } +} + +func TestDetectionJobRunsOnResolution(t *testing.T) { + tests := []struct { + name string + frontmatter string + expectedRunsOn string + }{ + { + name: "detection uses agent runs-on by default (not safe-outputs runs-on)", + frontmatter: `--- +on: push +safe-outputs: + create-issue: + title-prefix: "[ai] " + runs-on: self-hosted +--- + +# Test Workflow + +This is a test workflow.`, + expectedRunsOn: "runs-on: ubuntu-latest", + }, + { + name: "detection runs-on overrides agent runs-on", + frontmatter: `--- +on: push +safe-outputs: + create-issue: + title-prefix: "[ai] " + runs-on: self-hosted + threat-detection: + runs-on: detection-runner +--- + +# Test Workflow + +This is a test workflow.`, + expectedRunsOn: "runs-on: detection-runner", + }, + { + name: "detection falls back to agent runs-on (ubuntu-latest) when no runs-on configured", + frontmatter: `--- +on: push +safe-outputs: + create-issue: + title-prefix: "[ai] " +--- + +# Test Workflow + +This is a test workflow.`, + expectedRunsOn: "runs-on: ubuntu-latest", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := testutil.TempDir(t, "workflow-detection-runs-on-test") + + testFile := filepath.Join(tmpDir, "test.md") + if err := os.WriteFile(testFile, []byte(tt.frontmatter), 0644); err != nil { + t.Fatal(err) + } + + compiler := NewCompiler() + if err := compiler.CompileWorkflow(testFile); err != nil { + t.Fatalf("Failed to compile workflow: %v", err) + } + + lockFile := filepath.Join(tmpDir, "test.lock.yml") + yamlContent, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + + yamlStr := string(yamlContent) + + // Verify the detection job uses the expected runs-on value + detectionJobPattern := "\n detection:" + detectionStart := strings.Index(yamlStr, detectionJobPattern) + if detectionStart == -1 { + t.Fatal("Expected detection job to be present in compiled YAML") + } + + detectionSection := yamlStr[detectionStart : detectionStart+500] + if !strings.Contains(detectionSection, tt.expectedRunsOn) { + t.Errorf("Detection job does not use expected %q.\nDetection section:\n%s", tt.expectedRunsOn, detectionSection) + } + }) + } +} diff --git a/pkg/workflow/threat_detection.go b/pkg/workflow/threat_detection.go index 7475725e1e..ecdafff0d1 100644 --- a/pkg/workflow/threat_detection.go +++ b/pkg/workflow/threat_detection.go @@ -16,6 +16,7 @@ type ThreatDetectionConfig struct { Steps []any `yaml:"steps,omitempty"` // Array of extra job steps EngineConfig *EngineConfig `yaml:"engine-config,omitempty"` // Extended engine configuration for threat detection EngineDisabled bool `yaml:"-"` // Internal flag: true when engine is explicitly set to false + RunsOn string `yaml:"runs-on,omitempty"` // Runner override for the detection job } // parseThreatDetectionConfig handles threat-detection configuration @@ -64,6 +65,13 @@ func (c *Compiler) parseThreatDetectionConfig(outputMap map[string]any) *ThreatD } } + // Parse runs-on field + if runOn, exists := configMap["runs-on"]; exists { + if runOnStr, ok := runOn.(string); ok { + threatConfig.RunsOn = runOnStr + } + } + // Parse engine field (supports string, object, and boolean false formats) if engine, exists := configMap["engine"]; exists { // Handle boolean false to disable AI engine @@ -141,7 +149,7 @@ func (c *Compiler) buildThreatDetectionJob(data *WorkflowData, mainJobName strin job := &Job{ Name: string(constants.DetectionJobName), If: condition.Render(), - RunsOn: "runs-on: ubuntu-latest", + RunsOn: c.formatDetectionRunsOn(data.SafeOutputs, data.RunsOn), Permissions: permissions, Concurrency: c.indentYAMLLines(agentConcurrency, " "), TimeoutMinutes: 10, diff --git a/pkg/workflow/threat_detection_test.go b/pkg/workflow/threat_detection_test.go index 459b7719c8..f02c894257 100644 --- a/pkg/workflow/threat_detection_test.go +++ b/pkg/workflow/threat_detection_test.go @@ -111,6 +111,17 @@ func TestParseThreatDetectionConfig(t *testing.T) { }, }, }, + { + name: "object with runs-on override", + outputMap: map[string]any{ + "threat-detection": map[string]any{ + "runs-on": "self-hosted", + }, + }, + expectedConfig: &ThreatDetectionConfig{ + RunsOn: "self-hosted", + }, + }, } for _, tt := range tests { @@ -134,6 +145,74 @@ func TestParseThreatDetectionConfig(t *testing.T) { if len(result.Steps) != len(tt.expectedConfig.Steps) { t.Errorf("Expected %d steps, got %d", len(tt.expectedConfig.Steps), len(result.Steps)) } + + if result.RunsOn != tt.expectedConfig.RunsOn { + t.Errorf("Expected RunsOn %q, got %q", tt.expectedConfig.RunsOn, result.RunsOn) + } + }) + } +} + +func TestFormatDetectionRunsOn(t *testing.T) { + compiler := NewCompiler() + + tests := []struct { + name string + safeOutputs *SafeOutputsConfig + agentRunsOn string + expectedRunsOn string + }{ + { + name: "nil safe outputs uses agent runs-on", + safeOutputs: nil, + agentRunsOn: "runs-on: ubuntu-latest", + expectedRunsOn: "runs-on: ubuntu-latest", + }, + { + name: "detection runs-on takes priority over agent runs-on", + safeOutputs: &SafeOutputsConfig{ + RunsOn: "self-hosted", + ThreatDetection: &ThreatDetectionConfig{ + RunsOn: "detection-runner", + }, + }, + agentRunsOn: "runs-on: ubuntu-latest", + expectedRunsOn: "runs-on: detection-runner", + }, + { + name: "falls back to agent runs-on when detection runs-on is empty", + safeOutputs: &SafeOutputsConfig{ + RunsOn: "self-hosted", + ThreatDetection: &ThreatDetectionConfig{}, + }, + agentRunsOn: "runs-on: my-agent-runner", + expectedRunsOn: "runs-on: my-agent-runner", + }, + { + name: "falls back to agent runs-on when both detection and safe-outputs runs-on are empty", + safeOutputs: &SafeOutputsConfig{ + ThreatDetection: &ThreatDetectionConfig{}, + }, + agentRunsOn: "runs-on: ubuntu-latest", + expectedRunsOn: "runs-on: ubuntu-latest", + }, + { + name: "nil threat detection uses agent runs-on", + safeOutputs: &SafeOutputsConfig{ + RunsOn: "windows-latest", + ThreatDetection: nil, + }, + agentRunsOn: "runs-on: my-agent-runner", + expectedRunsOn: "runs-on: my-agent-runner", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := compiler.formatDetectionRunsOn(tt.safeOutputs, tt.agentRunsOn) + if result != tt.expectedRunsOn { + t.Errorf("Expected runs-on %q, got %q", tt.expectedRunsOn, result) + } }) } } @@ -162,6 +241,7 @@ func TestBuildThreatDetectionJob(t *testing.T) { { name: "threat detection enabled should create job", data: &WorkflowData{ + RunsOn: "runs-on: ubuntu-latest", SafeOutputs: &SafeOutputsConfig{ ThreatDetection: &ThreatDetectionConfig{}, }, @@ -173,6 +253,7 @@ func TestBuildThreatDetectionJob(t *testing.T) { { name: "threat detection with custom steps should create job", data: &WorkflowData{ + RunsOn: "runs-on: ubuntu-latest", SafeOutputs: &SafeOutputsConfig{ ThreatDetection: &ThreatDetectionConfig{ Steps: []any{ @@ -222,8 +303,13 @@ func TestBuildThreatDetectionJob(t *testing.T) { if job.Name != string(constants.DetectionJobName) { t.Errorf("Expected job name 'detection', got %q", job.Name) } - if job.RunsOn != "runs-on: ubuntu-latest" { - t.Errorf("Expected ubuntu-latest runner, got %q", job.RunsOn) + // Detection job uses formatDetectionRunsOn: safe-outputs.detection.runs-on > agent.runs-on + expectedRunsOn := tt.data.RunsOn + if tt.data.SafeOutputs != nil && tt.data.SafeOutputs.ThreatDetection != nil && tt.data.SafeOutputs.ThreatDetection.RunsOn != "" { + expectedRunsOn = "runs-on: " + tt.data.SafeOutputs.ThreatDetection.RunsOn + } + if job.RunsOn != expectedRunsOn { + t.Errorf("Expected %q runner, got %q", expectedRunsOn, job.RunsOn) } // In dev mode (default), detection job should have contents: read permission for checkout // In release mode, it should have empty permissions