From 3360523bee7427ca2f8080cf34942fa5ae58d68a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:16:35 +0000 Subject: [PATCH 1/5] Initial plan From 5318b39e8c693f94abef04ad8724eba2556cdc21 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:27:07 +0000 Subject: [PATCH 2/5] Type SafeJobConfig.RunsOn as RunsOnValue instead of any Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/repo_config.go | 28 +++++++++++ pkg/workflow/safe_jobs.go | 33 ++++++------- pkg/workflow/safe_jobs_test.go | 60 +++++++++++++++++++----- pkg/workflow/safe_outputs_import_test.go | 4 +- 4 files changed, 91 insertions(+), 34 deletions(-) diff --git a/pkg/workflow/repo_config.go b/pkg/workflow/repo_config.go index 4063e06a11a..eaface0453d 100644 --- a/pkg/workflow/repo_config.go +++ b/pkg/workflow/repo_config.go @@ -87,6 +87,34 @@ func (r *RunsOnValue) UnmarshalJSON(data []byte) error { return nil } +// toRunsOnValue converts a YAML-decoded runs-on value (a string or a list of +// strings) into a RunsOnValue. Values with an unsupported shape, and non-string +// list entries, are ignored. +func toRunsOnValue(value any) RunsOnValue { + switch v := value.(type) { + case string: + return RunsOnValue{v} + case []any: + labels := make(RunsOnValue, 0, len(v)) + for _, item := range v { + if itemStr, ok := item.(string); ok { + labels = append(labels, itemStr) + } + } + if len(labels) == 0 { + return nil + } + return labels + case []string: + if len(v) == 0 { + return nil + } + return RunsOnValue(v) + default: + return nil + } +} + // MaintenanceConfig holds maintenance-workflow-specific settings from aw.json. type MaintenanceCompileConfig struct { // CreatePullRequestGitHubToken is the secret name used by the compile-workflows diff --git a/pkg/workflow/safe_jobs.go b/pkg/workflow/safe_jobs.go index cd24453d1d9..d82759d6357 100644 --- a/pkg/workflow/safe_jobs.go +++ b/pkg/workflow/safe_jobs.go @@ -18,7 +18,7 @@ type SafeJobConfig struct { // Standard GitHub Actions job properties Name string `yaml:"name,omitempty"` Description string `yaml:"description,omitempty"` - RunsOn any `yaml:"runs-on,omitempty"` + RunsOn RunsOnValue `yaml:"runs-on,omitempty"` If string `yaml:"if,omitempty"` Needs []string `yaml:"needs,omitempty"` Steps []any `yaml:"steps,omitempty"` @@ -68,9 +68,9 @@ func (c *Compiler) parseSafeJobsConfig(jobsMap map[string]any) map[string]*SafeJ // Parse runs-on (also accept "runner" as alias) if runsOn, exists := jobConfig["runs-on"]; exists { - safeJob.RunsOn = runsOn + safeJob.RunsOn = toRunsOnValue(runsOn) } else if runner, exists := jobConfig["runner"]; exists { - safeJob.RunsOn = runner + safeJob.RunsOn = toRunsOnValue(runner) } // Parse if condition @@ -232,23 +232,18 @@ func (c *Compiler) buildSafeJobs(data *WorkflowData, threatDetectionEnabled bool job.Needs = append(job.Needs, jobConfig.Needs...) // Set runs-on - if jobConfig.RunsOn != nil { - if runsOnStr, ok := jobConfig.RunsOn.(string); ok { - job.RunsOn = "runs-on: " + runsOnStr - } else if runsOnList, ok := jobConfig.RunsOn.([]any); ok { - // Handle array format - var runsOnItems []string - for _, item := range runsOnList { - if itemStr, ok := item.(string); ok { - runsOnItems = append(runsOnItems, " - "+itemStr) - } - } - if len(runsOnItems) > 0 { - job.RunsOn = "runs-on:\n" + strings.Join(runsOnItems, "\n") - } - } - } else { + switch len(jobConfig.RunsOn) { + case 0: job.RunsOn = "runs-on: ubuntu-latest" // Default + case 1: + job.RunsOn = "runs-on: " + jobConfig.RunsOn[0] + default: + // Handle array format + runsOnItems := make([]string, 0, len(jobConfig.RunsOn)) + for _, item := range jobConfig.RunsOn { + runsOnItems = append(runsOnItems, " - "+item) + } + job.RunsOn = "runs-on:\n" + strings.Join(runsOnItems, "\n") } // Set if condition - combine safe output type check with user-provided condition diff --git a/pkg/workflow/safe_jobs_test.go b/pkg/workflow/safe_jobs_test.go index 0762a2ef1fc..b1c9ea1abc2 100644 --- a/pkg/workflow/safe_jobs_test.go +++ b/pkg/workflow/safe_jobs_test.go @@ -70,7 +70,7 @@ func TestParseSafeJobsConfig(t *testing.T) { } // Test runs-on - if deployJob.RunsOn != "ubuntu-latest" { + if len(deployJob.RunsOn) != 1 || deployJob.RunsOn[0] != "ubuntu-latest" { t.Errorf("Expected runs-on to be 'ubuntu-latest', got %v", deployJob.RunsOn) } @@ -232,7 +232,7 @@ func TestBuildSafeJobs(t *testing.T) { SafeOutputs: &SafeOutputsConfig{ Jobs: map[string]*SafeJobConfig{ "deploy": { - RunsOn: "ubuntu-latest", + RunsOn: RunsOnValue{"ubuntu-latest"}, If: "github.event.issue.number", Env: map[string]string{ "DEPLOY_ENV": "production", @@ -366,6 +366,40 @@ safe-outputs: require.Less(t, alphaIdx, zebraIdx, "conclusion job should list safe-jobs in deterministic sorted order") } +func TestParseAndBuildSafeJobsRunsOnList(t *testing.T) { + c := NewCompiler() + + safeJobs := c.parseSafeJobsConfig(map[string]any{ + "deploy": map[string]any{ + "runs-on": []any{"self-hosted", "linux"}, + "steps": []any{ + map[string]any{"run": "echo 'Deploying'"}, + }, + }, + }) + + require.Equal(t, RunsOnValue{"self-hosted", "linux"}, safeJobs["deploy"].RunsOn) + + workflowData := &WorkflowData{ + Name: "test-workflow", + SafeOutputs: &SafeOutputsConfig{Jobs: safeJobs}, + } + + _, err := c.buildSafeJobs(workflowData, false) + require.NoError(t, err) + + jobs := c.jobManager.GetAllJobs() + require.Len(t, jobs, 1) + + var job *Job + for _, j := range jobs { + job = j + break + } + + require.Equal(t, "runs-on:\n - self-hosted\n - linux", job.RunsOn) +} + func TestBuildSafeJobsWithNoConfiguration(t *testing.T) { c := NewCompiler() @@ -403,7 +437,7 @@ func TestBuildSafeJobsWithoutCustomIfCondition(t *testing.T) { SafeOutputs: &SafeOutputsConfig{ Jobs: map[string]*SafeJobConfig{ "notify": { - RunsOn: "ubuntu-latest", + RunsOn: RunsOnValue{"ubuntu-latest"}, // No custom 'if' condition Inputs: map[string]*InputDefinition{ "message": { @@ -454,7 +488,7 @@ func TestBuildSafeJobsWithDashesInName(t *testing.T) { SafeOutputs: &SafeOutputsConfig{ Jobs: map[string]*SafeJobConfig{ "send-notification": { - RunsOn: "ubuntu-latest", + RunsOn: RunsOnValue{"ubuntu-latest"}, Steps: []any{ map[string]any{ "name": "Send notification", @@ -578,21 +612,21 @@ func TestExtractSafeJobsFromFrontmatter(t *testing.T) { t.Error("Expected 'deploy' job to exist") } - if deployJob.RunsOn != "ubuntu-latest" { - t.Errorf("Expected runs-on to be 'ubuntu-latest', got '%s'", deployJob.RunsOn) + if len(deployJob.RunsOn) != 1 || deployJob.RunsOn[0] != "ubuntu-latest" { + t.Errorf("Expected runs-on to be 'ubuntu-latest', got '%v'", deployJob.RunsOn) } } func TestMergeSafeJobs(t *testing.T) { base := map[string]*SafeJobConfig{ "deploy": { - RunsOn: "ubuntu-latest", + RunsOn: RunsOnValue{"ubuntu-latest"}, }, } additional := map[string]*SafeJobConfig{ "test": { - RunsOn: "ubuntu-latest", + RunsOn: RunsOnValue{"ubuntu-latest"}, }, } @@ -609,7 +643,7 @@ func TestMergeSafeJobs(t *testing.T) { // Test conflict detection conflicting := map[string]*SafeJobConfig{ "deploy": { - RunsOn: "windows-latest", + RunsOn: RunsOnValue{"windows-latest"}, }, } @@ -631,7 +665,7 @@ func TestMergeSafeJobsFromIncludedConfigs(t *testing.T) { topSafeJobs := map[string]*SafeJobConfig{ "deploy": { Name: "Deploy Application", - RunsOn: "ubuntu-latest", + RunsOn: RunsOnValue{"ubuntu-latest"}, }, } @@ -682,8 +716,8 @@ func TestMergeSafeJobsFromIncludedConfigs(t *testing.T) { t.Error("Expected 'test' job from includes to exist") } - if testJob.RunsOn != "ubuntu-latest" { - t.Errorf("Expected test job runs-on to be 'ubuntu-latest', got '%s'", testJob.RunsOn) + if len(testJob.RunsOn) != 1 || testJob.RunsOn[0] != "ubuntu-latest" { + t.Errorf("Expected test job runs-on to be 'ubuntu-latest', got '%v'", testJob.RunsOn) } notifyJob, exists := result["notify"] @@ -727,7 +761,7 @@ func TestBuildSafeJobsEnvExpressionHoisting(t *testing.T) { SafeOutputs: &SafeOutputsConfig{ Jobs: map[string]*SafeJobConfig{ "publish": { - RunsOn: "ubuntu-latest", + RunsOn: RunsOnValue{"ubuntu-latest"}, Env: map[string]string{ "GH_TOKEN": "${{ github.token }}", "STATIC_VAR": "literal-value", diff --git a/pkg/workflow/safe_outputs_import_test.go b/pkg/workflow/safe_outputs_import_test.go index 924362d8750..eba75476d9b 100644 --- a/pkg/workflow/safe_outputs_import_test.go +++ b/pkg/workflow/safe_outputs_import_test.go @@ -1010,7 +1010,7 @@ This workflow imports safe-jobs from a shared workflow. // Verify job configuration job := workflowData.SafeOutputs.Jobs["my-custom-job"] assert.Equal(t, "My Custom Job", job.Name, "Job name should match") - assert.Equal(t, "ubuntu-latest", job.RunsOn, "Job runs-on should match") + assert.Equal(t, RunsOnValue{"ubuntu-latest"}, job.RunsOn, "Job runs-on should match") assert.Len(t, job.Steps, 1, "Job should have 1 step") assert.Contains(t, job.Permissions, "contents", "Job should have contents permission") assert.Contains(t, job.Permissions, "issues", "Job should have issues permission") @@ -1397,7 +1397,7 @@ func TestMergeSafeOutputsJobsNotMerged(t *testing.T) { Jobs: map[string]*SafeJobConfig{ "existing-job": { Name: "Existing Job", - RunsOn: "ubuntu-latest", + RunsOn: RunsOnValue{"ubuntu-latest"}, }, }, } From b1a99bfc0bcc1cf88f2fc7644855f7a4c981444d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:29:15 +0000 Subject: [PATCH 3/5] Clarify expected runs-on block indentation in test Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/safe_jobs_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/workflow/safe_jobs_test.go b/pkg/workflow/safe_jobs_test.go index b1c9ea1abc2..cbe14a02b6c 100644 --- a/pkg/workflow/safe_jobs_test.go +++ b/pkg/workflow/safe_jobs_test.go @@ -397,6 +397,7 @@ func TestParseAndBuildSafeJobsRunsOnList(t *testing.T) { break } + // Multiple labels are rendered as a YAML block sequence indented to the job level require.Equal(t, "runs-on:\n - self-hosted\n - linux", job.RunsOn) } From 54e6e0aa7c4c5cd95cfea4c09ee9dc0ce78185e6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:44:54 +0000 Subject: [PATCH 4/5] Use FormatRunsOn for safe-jobs and refresh SafeJobConfig docs Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- pkg/workflow/README.md | 2 +- pkg/workflow/safe_jobs.go | 17 +++-------------- pkg/workflow/safe_jobs_test.go | 4 ++-- 3 files changed, 6 insertions(+), 17 deletions(-) diff --git a/pkg/workflow/README.md b/pkg/workflow/README.md index 30c157f5bdd..200a34b2d96 100644 --- a/pkg/workflow/README.md +++ b/pkg/workflow/README.md @@ -849,7 +849,7 @@ This appendix is generated from the current non-test Go source files in this pac | `repo_config.go` | `MaintenanceConfig` | `type MaintenanceConfig struct { // RunsOn is the runner label or labels used for all jobs in agentics-maintenance.yml. RunsOn RunsOnValue `json:"runs_on,omitempty"` // ActionFailureIssueExpires configures expiration (in hours) for action // failure issues opened by the conclusion job. Defaults to 168 (7 days). ActionFailureIssueExpires int `json:"action_failure_issue_expires,omitempty"` // LabelTriggers controls all label-triggered jobs (disable_agentic_workflow, // label_apply_safe_outputs, etc.). // The value is treated as an opt-in flag: only true enables the jobs. // nil (omitted) or false both disable label-triggered jobs. // To opt in, set label_triggers: true in aw.json. LabelTriggers *bool `json:"label_triggers,omitempty"` // DisabledJobs lists maintenance job IDs that should be omitted from generated // agentics-maintenance workflows. DisabledJobs []string `json:"disabled_jobs,omitempty"` // Compile controls compile-workflows maintenance job behavior. Compile *MaintenanceCompileConfig `json:"compile,omitempty"` }` | Exported type declared in `repo_config.go`. | | `repository_features_validation_wasm.go` | `RepositoryFeatures` | `type RepositoryFeatures struct { HasDiscussions bool HasIssues bool }` | Exported type declared in `repository_features_validation_wasm.go`. | | `runtime_definitions.go` | `RuntimeRequirement` | `type RuntimeRequirement struct { Runtime *Runtime Version string // Empty string means use default ExtraFields map[string]any // Additional 'with' fields from user's setup step (e.g., cache settings) GoModFile string // Path to go.mod file for Go runtime (Go-specific) IfCondition string // Optional GitHub Actions if condition Cooldown bool // If false, disables default dependency cooldown behavior for installs associated with this runtime }` | RuntimeRequirement represents a detected runtime requirement | -| `safe_jobs.go` | `SafeJobConfig` | `type SafeJobConfig struct { // Standard GitHub Actions job properties Name string `yaml:"name,omitempty"` Description string `yaml:"description,omitempty"` RunsOn any `yaml:"runs-on,omitempty"` If string `yaml:"if,omitempty"` Needs []string `yaml:"needs,omitempty"` Steps []any `yaml:"steps,omitempty"` Env map[string]string `yaml:"env,omitempty"` Permissions map[string]string `yaml:"permissions,omitempty"` // Additional safe-job specific properties Inputs map[string]*InputDefinition `yaml:"inputs,omitempty"` GitHubToken string `yaml:"github-token,omitempty"` Output string `yaml:"output,omitempty"` Max int `yaml:"max,omitempty"` // Maximum number of times this output type may be emitted per run (default: 1) }` | SafeJobConfig defines a safe job configuration with GitHub Actions job properties | +| `safe_jobs.go` | `SafeJobConfig` | `type SafeJobConfig struct { // Standard GitHub Actions job properties Name string `yaml:"name,omitempty"` Description string `yaml:"description,omitempty"` RunsOn RunsOnValue `yaml:"runs-on,omitempty"` If string `yaml:"if,omitempty"` Needs []string `yaml:"needs,omitempty"` Steps []any `yaml:"steps,omitempty"` Env map[string]string `yaml:"env,omitempty"` Permissions map[string]string `yaml:"permissions,omitempty"` // Additional safe-job specific properties Inputs map[string]*InputDefinition `yaml:"inputs,omitempty"` GitHubToken string `yaml:"github-token,omitempty"` Output string `yaml:"output,omitempty"` Max int `yaml:"max,omitempty"` // Maximum number of times this output type may be emitted per run (default: 1) }` | SafeJobConfig defines a safe job configuration with GitHub Actions job properties | | `safe_outputs_actions.go` | `SafeOutputActionConfig` | `type SafeOutputActionConfig struct { Uses string `yaml:"uses"` Description string `yaml:"description,omitempty"` // optional override of the action's description Env map[string]string `yaml:"env,omitempty"` // additional environment variables for the injected step // Computed at compile time (not from frontmatter): ResolvedRef string `yaml:"-"` // Pinned action reference (e.g., "owner/repo@sha # v1") Inputs map[string]*ActionYAMLInput `yaml:"-"` // Inputs parsed from action.yml ActionDescription string `yaml:"-"` // Description from action.yml }` | SafeOutputActionConfig holds configuration for a single custom safe output action. | | `safe_outputs_app_config.go` | `GitHubAppConfig` | `type GitHubAppConfig struct { AppID string `yaml:"client-id,omitempty"` // GitHub App client ID (or legacy app ID) (e.g., "${{ vars.APP_ID }}") PrivateKey string `yaml:"private-key,omitempty"` // GitHub App private key (e.g., "${{ secrets.APP_PRIVATE_KEY }}") IgnoreIfMissing bool `yaml:"ignore-if-missing,omitempty"` // If true, skip token minting when client-id/private-key resolve empty Owner string `yaml:"owner,omitempty"` // Optional: owner of the GitHub App installation (defaults to checkout.repository owner when derivable, otherwise current repository owner) Repositories []string `yaml:"repositories,omitempty"` // Optional: comma or newline-separated list of repositories to grant access to Permissions map[string]string `yaml:"permissions,omitempty"` // Optional: extra permission-* fields to merge into the minted token (nested wins over job-level) }` | GitHubAppConfig holds configuration for GitHub App-based token minting | | `safe_outputs_config_runtime.go` | `SafeOutputStepConfig` | `type SafeOutputStepConfig struct { StepName string // Human-readable step name (e.g., "Create Issue") StepID string // Step ID for referencing outputs (e.g., "create_issue") Script string // JavaScript script to execute (for inline mode) ScriptName string // Name of the script in the registry (for file mode) CustomEnvVars []string // Environment variables specific to this step Condition ConditionNode // Step-level condition (if clause) Token string // GitHub token for this step UseCopilotRequestsToken bool // Whether to use Copilot requests token preference chain UseCopilotCodingAgentToken bool // Whether to use Copilot coding agent token preference chain PreSteps []string // Optional steps to run before the script step PostSteps []string // Optional steps to run after the script step Outputs map[string]string // Outputs from this step ContinueOnError bool // Whether to continue the job even if this step fails (continue-on-error: true) }` | SafeOutputStepConfig holds configuration for building a single safe output step within the consolidated safe-outputs job | diff --git a/pkg/workflow/safe_jobs.go b/pkg/workflow/safe_jobs.go index d82759d6357..685b015d5de 100644 --- a/pkg/workflow/safe_jobs.go +++ b/pkg/workflow/safe_jobs.go @@ -231,20 +231,9 @@ func (c *Compiler) buildSafeJobs(data *WorkflowData, threatDetectionEnabled bool // Add any additional dependencies from the config job.Needs = append(job.Needs, jobConfig.Needs...) - // Set runs-on - switch len(jobConfig.RunsOn) { - case 0: - job.RunsOn = "runs-on: ubuntu-latest" // Default - case 1: - job.RunsOn = "runs-on: " + jobConfig.RunsOn[0] - default: - // Handle array format - runsOnItems := make([]string, 0, len(jobConfig.RunsOn)) - for _, item := range jobConfig.RunsOn { - runsOnItems = append(runsOnItems, " - "+item) - } - job.RunsOn = "runs-on:\n" + strings.Join(runsOnItems, "\n") - } + // Set runs-on. + // FormatRunsOn handles defaulting and YAML-safe rendering. + job.RunsOn = "runs-on: " + FormatRunsOn(jobConfig.RunsOn, "ubuntu-latest") // Set if condition - combine safe output type check with user-provided condition // Custom safe jobs should only run if the agent output contains the job name (tool call) diff --git a/pkg/workflow/safe_jobs_test.go b/pkg/workflow/safe_jobs_test.go index cbe14a02b6c..f12b79f5d2e 100644 --- a/pkg/workflow/safe_jobs_test.go +++ b/pkg/workflow/safe_jobs_test.go @@ -397,8 +397,8 @@ func TestParseAndBuildSafeJobsRunsOnList(t *testing.T) { break } - // Multiple labels are rendered as a YAML block sequence indented to the job level - require.Equal(t, "runs-on:\n - self-hosted\n - linux", job.RunsOn) + // Multiple labels are rendered as a YAML flow sequence. + require.Equal(t, `runs-on: ["self-hosted","linux"]`, job.RunsOn) } func TestBuildSafeJobsWithNoConfiguration(t *testing.T) { From 85bd454c72e52fffd402497c1dbe6fe3a5d9356c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:11:39 +0000 Subject: [PATCH 5/5] Preserve safe-job runs-on arrays from config Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/repo_config.go | 9 ++++++++ pkg/workflow/safe_jobs.go | 19 ++++++++++++++-- pkg/workflow/safe_jobs_test.go | 40 ++++++++++++++++++++++++++++++++-- 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/pkg/workflow/repo_config.go b/pkg/workflow/repo_config.go index eaface0453d..bc7d08639be 100644 --- a/pkg/workflow/repo_config.go +++ b/pkg/workflow/repo_config.go @@ -115,6 +115,15 @@ func toRunsOnValue(value any) RunsOnValue { } } +func isRunsOnArrayValue(value any) bool { + switch value.(type) { + case []any, []string: + return true + default: + return false + } +} + // MaintenanceConfig holds maintenance-workflow-specific settings from aw.json. type MaintenanceCompileConfig struct { // CreatePullRequestGitHubToken is the secret name used by the compile-workflows diff --git a/pkg/workflow/safe_jobs.go b/pkg/workflow/safe_jobs.go index 685b015d5de..15c5bbeff84 100644 --- a/pkg/workflow/safe_jobs.go +++ b/pkg/workflow/safe_jobs.go @@ -31,6 +31,7 @@ type SafeJobConfig struct { GitHubToken string `yaml:"github-token,omitempty"` Output string `yaml:"output,omitempty"` Max int `yaml:"max,omitempty"` // Maximum number of times this output type may be emitted per run (default: 1) + runsOnArray bool `yaml:"-"` } // parseSafeJobsConfig parses safe-jobs configuration from a jobs map. @@ -69,8 +70,10 @@ func (c *Compiler) parseSafeJobsConfig(jobsMap map[string]any) map[string]*SafeJ // Parse runs-on (also accept "runner" as alias) if runsOn, exists := jobConfig["runs-on"]; exists { safeJob.RunsOn = toRunsOnValue(runsOn) + safeJob.runsOnArray = isRunsOnArrayValue(runsOn) } else if runner, exists := jobConfig["runner"]; exists { safeJob.RunsOn = toRunsOnValue(runner) + safeJob.runsOnArray = isRunsOnArrayValue(runner) } // Parse if condition @@ -231,9 +234,21 @@ func (c *Compiler) buildSafeJobs(data *WorkflowData, threatDetectionEnabled bool // Add any additional dependencies from the config job.Needs = append(job.Needs, jobConfig.Needs...) + const defaultRunsOn = "ubuntu-latest" + // Set runs-on. - // FormatRunsOn handles defaulting and YAML-safe rendering. - job.RunsOn = "runs-on: " + FormatRunsOn(jobConfig.RunsOn, "ubuntu-latest") + // Preserve list-shaped input from safe-outputs.jobs as a YAML array. + if jobConfig.runsOnArray && len(jobConfig.RunsOn) > 0 { + // Keep []string{""} semantically unset, matching FormatRunsOn behavior. + if len(jobConfig.RunsOn) == 1 && jobConfig.RunsOn[0] == "" { + job.RunsOn = "runs-on: " + defaultRunsOn + } else { + job.RunsOn = c.indentYAMLLines(renderRunsOnSnippet([]string(jobConfig.RunsOn)), " ") + } + } else { + // FormatRunsOn handles defaulting and YAML-safe rendering. + job.RunsOn = "runs-on: " + FormatRunsOn(jobConfig.RunsOn, defaultRunsOn) + } // Set if condition - combine safe output type check with user-provided condition // Custom safe jobs should only run if the agent output contains the job name (tool call) diff --git a/pkg/workflow/safe_jobs_test.go b/pkg/workflow/safe_jobs_test.go index f12b79f5d2e..07259978c1d 100644 --- a/pkg/workflow/safe_jobs_test.go +++ b/pkg/workflow/safe_jobs_test.go @@ -379,6 +379,7 @@ func TestParseAndBuildSafeJobsRunsOnList(t *testing.T) { }) require.Equal(t, RunsOnValue{"self-hosted", "linux"}, safeJobs["deploy"].RunsOn) + require.True(t, safeJobs["deploy"].runsOnArray) workflowData := &WorkflowData{ Name: "test-workflow", @@ -397,8 +398,43 @@ func TestParseAndBuildSafeJobsRunsOnList(t *testing.T) { break } - // Multiple labels are rendered as a YAML flow sequence. - require.Equal(t, `runs-on: ["self-hosted","linux"]`, job.RunsOn) + // Multiple labels parsed as a list are rendered as a YAML list. + require.Equal(t, "runs-on:\n - self-hosted\n - linux", job.RunsOn) +} + +func TestParseAndBuildSafeJobsSingleRunsOnList(t *testing.T) { + c := NewCompiler() + + safeJobs := c.parseSafeJobsConfig(map[string]any{ + "deploy": map[string]any{ + "runs-on": []any{"self-hosted"}, + "steps": []any{ + map[string]any{"run": "echo 'Deploying'"}, + }, + }, + }) + + require.Equal(t, RunsOnValue{"self-hosted"}, safeJobs["deploy"].RunsOn) + require.True(t, safeJobs["deploy"].runsOnArray) + + workflowData := &WorkflowData{ + Name: "test-workflow", + SafeOutputs: &SafeOutputsConfig{Jobs: safeJobs}, + } + + _, err := c.buildSafeJobs(workflowData, false) + require.NoError(t, err) + + jobs := c.jobManager.GetAllJobs() + require.Len(t, jobs, 1) + + var job *Job + for _, j := range jobs { + job = j + break + } + + require.Equal(t, "runs-on:\n - self-hosted", job.RunsOn) } func TestBuildSafeJobsWithNoConfiguration(t *testing.T) {