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
2 changes: 1 addition & 1 deletion .github/workflows/ai-moderator.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion .github/workflows/smoke-copilot-arm.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion .github/workflows/workflow-generator.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pkg/workflow/compiler_unlock_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions pkg/workflow/safe_outputs_config_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
145 changes: 145 additions & 0 deletions pkg/workflow/safe_outputs_runs_on_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
}
10 changes: 9 additions & 1 deletion pkg/workflow/threat_detection.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
90 changes: 88 additions & 2 deletions pkg/workflow/threat_detection_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
})
}
}
Expand Down Expand Up @@ -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{},
},
Expand All @@ -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{
Expand Down Expand Up @@ -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
Expand Down
Loading