Summary
The top-level runs-on field accepts a string, an array of labels, or a group/labels object, which is the idiomatic way to target self-hosted runners (e.g. [self-hosted, linux, x64]). However, three sibling runner-selection fields only accept a single string:
runs-on-slim (framework/generated jobs)
safe-outputs.runs-on (all safe-output jobs)
safe-outputs.threat-detection.runs-on (detection job)
This forces self-hosted-runner users to register a single-label alias just to satisfy these fields, even though every other runner field in the project (top-level runs-on, per-job jobs.*.runs-on, custom safe-outputs.jobs.*.runs-on / runner, and aw.json maintenance.runs_on) already supports arrays.
Based on a source-code analysis (below), this is not an intentional restriction — it's an inconsistency. The array-handling machinery already exists in the codebase and is simply not wired up for these three fields.
Analysis
Behavior of runs-on (works — array + object supported)
- Schema:
runs-on references #/$defs/github_actions_runs_on, which is a oneOf of string / array / {group, labels} object. (pkg/parser/schemas/main_workflow_schema.json ~line 2625)
- Parsing preserves the full YAML shape via
extractTopLevelYAMLSection(frontmatter, "runs-on"). (pkg/workflow/workflow_builder.go line 279)
- Stored as
FrontmatterConfig.RunsOn any ("Supports string, array, or object GitHub Actions runner forms"). (pkg/workflow/frontmatter_types.go line 329)
- Validated for all three shapes by
validateRunsOnValue and extractRunnerLabels. (pkg/workflow/runs_on_validation.go)
Behavior of the three affected fields (broken — string only)
1. runs-on-slim
- Schema is
"type": "string". (main_workflow_schema.json ~line 2636)
- Parser only accepts a string and silently drops non-string values:
if v, ok := frontmatter["runs-on-slim"]; ok {
if s, ok := v.(string); ok {
workflowData.RunsOnSlim = s
}
}
- Stored as
WorkflowData.RunsOnSlim string (pkg/workflow/compiler_types.go line 491) and FrontmatterConfig.RunsOnSlim string (frontmatter_types.go line 330).
- Rendered via naive string concatenation:
func (c *Compiler) formatFrameworkJobRunsOn(data *WorkflowData) string {
if data != nil && data.SafeOutputs != nil && data.SafeOutputs.RunsOn != "" {
safeOutputsRuntimeLog.Printf("Framework job runs-on from safe-outputs: %s", data.SafeOutputs.RunsOn)
return "runs-on: " + data.SafeOutputs.RunsOn
}
if data != nil && data.RunsOnSlim != "" {
safeOutputsRuntimeLog.Printf("Framework job runs-on from runs-on-slim: %s", data.RunsOnSlim)
return "runs-on: " + data.RunsOnSlim
}
safeOutputsRuntimeLog.Printf("Framework job runs-on using default: %s", constants.DefaultActivationJobRunnerImage)
return "runs-on: " + constants.DefaultActivationJobRunnerImage
}
2. safe-outputs.runs-on
- Schema is
"type": "string" and the description explicitly says "Single runner label". (main_workflow_schema.json ~line 10299)
- Parser only accepts a string:
// Handle runs-on configuration
if runsOn, exists := outputMap["runs-on"]; exists {
if runsOnStr, ok := runsOn.(string); ok {
config.RunsOn = runsOnStr
}
}
- Stored as
SafeOutputsConfig.RunsOn string (compiler_types.go line 704) and rendered by the same formatFrameworkJobRunsOn above.
3. safe-outputs.threat-detection.runs-on
- Schema is
"type": "string". (main_workflow_schema.json ~line 9825)
- Parser only accepts a string:
// Parse runs-on field
if runOn, exists := configMap["runs-on"]; exists {
if runOnStr, ok := runOn.(string); ok {
threatConfig.RunsOn = runOnStr
}
}
- Stored as
ThreatDetectionConfig.RunsOn string (threat_detection.go line 25) and rendered by string concatenation:
runsOn := "runs-on: ubuntu-latest"
if data.SafeOutputs.ThreatDetection.RunsOn != "" {
runsOn = "runs-on: " + data.SafeOutputs.ThreatDetection.RunsOn
}
There is also a third consumer of RunsOnSlim / SafeOutputs.RunsOn that treats them as strings: resolveCentralSlashRunsOn in pkg/workflow/central_slash_command_workflow.go (~lines 475–490).
The array-handling machinery already exists
The project already solved this exact problem in several places, so the fix can reuse existing patterns:
RunsOnValue (a []string with a string-or-array UnmarshalJSON) plus FormatRunsOn, which serializes 0/1/N labels into a YAML-safe value (single label inline, multi-label as a JSON-encoded flow sequence). Used today by aw.json maintenance.runs_on. (pkg/workflow/repo_config.go lines 51–74 and 254–283)
- Custom safe-jobs already handle both string and
[]any for runs-on / runner. (pkg/workflow/safe_jobs.go lines 191–207)
validateRunsOnValue / extractRunnerLabels already understand string, array, and object shapes. (pkg/workflow/runs_on_validation.go)
Documentation confirms the gap
docs/.../reference/self-hosted-runners.md shows runs-on with string/array/object examples, but only single-label examples for runs-on-slim (runs-on-slim: self-hosted) and threat-detection.runs-on (runs-on: ubuntu-latest). aw.json maintenance.runs_on is documented with both single and multi-label forms — highlighting the inconsistency.
Conclusion
This is a feature gap / inconsistency, not an intended limitation. Users on self-hosted runners that require multiple labels (the standard [self-hosted, linux, x64] pattern) cannot configure framework, safe-output, or threat-detection jobs the same way they configure the main agent job.
Reproduction
---
on: issues
runs-on: [self-hosted, linux, x64] # works
runs-on-slim: [self-hosted, linux, x64] # silently ignored -> falls back to ubuntu-slim
safe-outputs:
create-issue: {}
runs-on: [self-hosted, linux, x64] # silently ignored
threat-detection:
runs-on: [self-hosted, linux, x64] # silently ignored -> falls back to ubuntu-latest
---
Triage this issue.
Compile and inspect the generated .lock.yml: the framework, safe-outputs, and detection jobs do not pick up the array values, while the main agent job does.
Expected behavior
runs-on-slim, safe-outputs.runs-on, and safe-outputs.threat-detection.runs-on should accept the same runner shapes as runs-on — at minimum a string and an array of labels (ideally also the {group, labels} object form for parity), and emit the corresponding runs-on: YAML in the generated jobs.
Implementation Plan
Please implement the following changes. Reuse the existing RunsOnValue + FormatRunsOn helpers (or the extractRunnerLabels / validateRunsOnValue shape handling) so all runner fields behave consistently.
-
Update Schema (pkg/parser/schemas/main_workflow_schema.json):
runs-on-slim (~line 2636): replace "type": "string" with a oneOf of string + array of strings (or reuse #/$defs/github_actions_runs_on for full parity with runs-on). Update the description and examples to include an array such as ["self-hosted", "linux", "x64"].
safe-outputs.runs-on (~line 10299): same change; remove "Single runner label" wording from the description.
safe-outputs.threat-detection.runs-on (~line 9825): same change.
- Mirror these in any duplicated/generated schema or autocomplete data if applicable (e.g.
docs/public/editor/autocomplete-data.json).
-
Update parsing:
pkg/workflow/workflow_builder.go (lines 281–285): accept array (and ideally object) values for runs-on-slim, not just string.
pkg/workflow/safe_outputs_config.go (lines 528–533): accept array/object for safe-outputs.runs-on.
pkg/workflow/threat_detection.go (lines 199–204): accept array/object for threat-detection.runs-on.
-
Update types to carry richer-than-string values (choose the lowest-churn approach):
- Option A (recommended): change the frontmatter-facing types to
RunsOnValue / any and normalize to a YAML string early using FormatRunsOn, keeping the WorkflowData string fields as the already-rendered YAML value.
- Affected:
WorkflowData.RunsOnSlim (compiler_types.go line 491), FrontmatterConfig.RunsOnSlim (frontmatter_types.go line 330), SafeOutputsConfig.RunsOn (compiler_types.go line 704), ThreatDetectionConfig.RunsOn (threat_detection.go line 25).
-
Update rendering so arrays serialize correctly instead of "runs-on: " + string:
pkg/workflow/safe_outputs_runtime.go formatFrameworkJobRunsOn (lines 25–36) — use FormatRunsOn/normalized value for both SafeOutputs.RunsOn and RunsOnSlim.
pkg/workflow/threat_detection.go (lines 966–969) — same for the detection job.
pkg/workflow/central_slash_command_workflow.go resolveCentralSlashRunsOn (~lines 475–490) — handle the non-string values consistently.
-
Validation / safety:
- Ensure the macOS-runner rejection in
validateRunsOn (pkg/workflow/runs_on_validation.go) is also applied to these fields when arrays are allowed, so macos-* can't sneak in via runs-on-slim / safe-outputs.runs-on / threat-detection.runs-on.
- Reuse
validateRunsOnValue for shape validation and produce errors following the project style: [what's wrong]. [what's expected]. [example].
-
Add/extend tests:
pkg/workflow/safe_outputs_runs_on_test.go — extend TestRunsOnSlimField and the safe-outputs runner tests with array (and object) cases; assert the generated YAML contains the flow-sequence form (e.g. runs-on: ["self-hosted","linux","x64"]).
pkg/workflow/threat_detection_test.go — add array/object cases for threat-detection.runs-on.
pkg/workflow/runs_on_validation_test.go — add macOS-in-array rejection cases for the new fields.
- Add a schema-validation test confirming arrays are now accepted for the three fields.
-
Update documentation:
docs/src/content/docs/reference/self-hosted-runners.md — show array examples for runs-on-slim, safe-outputs.runs-on, and threat-detection.runs-on (currently only single-label).
docs/src/content/docs/reference/frontmatter.md / frontmatter-full.md and docs/src/content/docs/reference/safe-outputs.md — update field type/descriptions.
-
Follow project guidelines:
- Run
make fmt after Go changes and make recompile after any workflow markdown changes.
- Run
make agent-finish (build, test, recompile, lint, lint-errors) before completing.
Summary
The top-level
runs-onfield accepts a string, an array of labels, or a group/labels object, which is the idiomatic way to target self-hosted runners (e.g.[self-hosted, linux, x64]). However, three sibling runner-selection fields only accept a single string:runs-on-slim(framework/generated jobs)safe-outputs.runs-on(all safe-output jobs)safe-outputs.threat-detection.runs-on(detection job)This forces self-hosted-runner users to register a single-label alias just to satisfy these fields, even though every other runner field in the project (top-level
runs-on, per-jobjobs.*.runs-on, customsafe-outputs.jobs.*.runs-on/runner, andaw.jsonmaintenance.runs_on) already supports arrays.Based on a source-code analysis (below), this is not an intentional restriction — it's an inconsistency. The array-handling machinery already exists in the codebase and is simply not wired up for these three fields.
Analysis
Behavior of
runs-on(works — array + object supported)runs-onreferences#/$defs/github_actions_runs_on, which is aoneOfof string / array /{group, labels}object. (pkg/parser/schemas/main_workflow_schema.json~line 2625)extractTopLevelYAMLSection(frontmatter, "runs-on"). (pkg/workflow/workflow_builder.goline 279)FrontmatterConfig.RunsOn any("Supports string, array, or object GitHub Actions runner forms"). (pkg/workflow/frontmatter_types.goline 329)validateRunsOnValueandextractRunnerLabels. (pkg/workflow/runs_on_validation.go)Behavior of the three affected fields (broken — string only)
1.
runs-on-slim"type": "string". (main_workflow_schema.json~line 2636)WorkflowData.RunsOnSlim string(pkg/workflow/compiler_types.goline 491) andFrontmatterConfig.RunsOnSlim string(frontmatter_types.goline 330).2.
safe-outputs.runs-on"type": "string"and the description explicitly says "Single runner label". (main_workflow_schema.json~line 10299)SafeOutputsConfig.RunsOn string(compiler_types.goline 704) and rendered by the sameformatFrameworkJobRunsOnabove.3.
safe-outputs.threat-detection.runs-on"type": "string". (main_workflow_schema.json~line 9825)ThreatDetectionConfig.RunsOn string(threat_detection.goline 25) and rendered by string concatenation:There is also a third consumer of
RunsOnSlim/SafeOutputs.RunsOnthat treats them as strings:resolveCentralSlashRunsOninpkg/workflow/central_slash_command_workflow.go(~lines 475–490).The array-handling machinery already exists
The project already solved this exact problem in several places, so the fix can reuse existing patterns:
RunsOnValue(a[]stringwith a string-or-arrayUnmarshalJSON) plusFormatRunsOn, which serializes 0/1/N labels into a YAML-safe value (single label inline, multi-label as a JSON-encoded flow sequence). Used today byaw.jsonmaintenance.runs_on. (pkg/workflow/repo_config.golines 51–74 and 254–283)[]anyforruns-on/runner. (pkg/workflow/safe_jobs.golines 191–207)validateRunsOnValue/extractRunnerLabelsalready understand string, array, and object shapes. (pkg/workflow/runs_on_validation.go)Documentation confirms the gap
docs/.../reference/self-hosted-runners.mdshowsruns-onwith string/array/object examples, but only single-label examples forruns-on-slim(runs-on-slim: self-hosted) andthreat-detection.runs-on(runs-on: ubuntu-latest).aw.json maintenance.runs_onis documented with both single and multi-label forms — highlighting the inconsistency.Conclusion
This is a feature gap / inconsistency, not an intended limitation. Users on self-hosted runners that require multiple labels (the standard
[self-hosted, linux, x64]pattern) cannot configure framework, safe-output, or threat-detection jobs the same way they configure the main agent job.Reproduction
Compile and inspect the generated
.lock.yml: the framework, safe-outputs, and detection jobs do not pick up the array values, while the main agent job does.Expected behavior
runs-on-slim,safe-outputs.runs-on, andsafe-outputs.threat-detection.runs-onshould accept the same runner shapes asruns-on— at minimum a string and an array of labels (ideally also the{group, labels}object form for parity), and emit the correspondingruns-on:YAML in the generated jobs.Implementation Plan
Please implement the following changes. Reuse the existing
RunsOnValue+FormatRunsOnhelpers (or theextractRunnerLabels/validateRunsOnValueshape handling) so all runner fields behave consistently.Update Schema (
pkg/parser/schemas/main_workflow_schema.json):runs-on-slim(~line 2636): replace"type": "string"with aoneOfof string + array of strings (or reuse#/$defs/github_actions_runs_onfor full parity withruns-on). Update the description andexamplesto include an array such as["self-hosted", "linux", "x64"].safe-outputs.runs-on(~line 10299): same change; remove "Single runner label" wording from the description.safe-outputs.threat-detection.runs-on(~line 9825): same change.docs/public/editor/autocomplete-data.json).Update parsing:
pkg/workflow/workflow_builder.go(lines 281–285): accept array (and ideally object) values forruns-on-slim, not juststring.pkg/workflow/safe_outputs_config.go(lines 528–533): accept array/object forsafe-outputs.runs-on.pkg/workflow/threat_detection.go(lines 199–204): accept array/object forthreat-detection.runs-on.Update types to carry richer-than-string values (choose the lowest-churn approach):
RunsOnValue/anyand normalize to a YAML string early usingFormatRunsOn, keeping theWorkflowDatastring fields as the already-rendered YAML value.WorkflowData.RunsOnSlim(compiler_types.goline 491),FrontmatterConfig.RunsOnSlim(frontmatter_types.goline 330),SafeOutputsConfig.RunsOn(compiler_types.goline 704),ThreatDetectionConfig.RunsOn(threat_detection.goline 25).Update rendering so arrays serialize correctly instead of
"runs-on: " + string:pkg/workflow/safe_outputs_runtime.goformatFrameworkJobRunsOn(lines 25–36) — useFormatRunsOn/normalized value for bothSafeOutputs.RunsOnandRunsOnSlim.pkg/workflow/threat_detection.go(lines 966–969) — same for the detection job.pkg/workflow/central_slash_command_workflow.goresolveCentralSlashRunsOn(~lines 475–490) — handle the non-string values consistently.Validation / safety:
validateRunsOn(pkg/workflow/runs_on_validation.go) is also applied to these fields when arrays are allowed, somacos-*can't sneak in viaruns-on-slim/safe-outputs.runs-on/threat-detection.runs-on.validateRunsOnValuefor shape validation and produce errors following the project style: [what's wrong]. [what's expected]. [example].Add/extend tests:
pkg/workflow/safe_outputs_runs_on_test.go— extendTestRunsOnSlimFieldand the safe-outputs runner tests with array (and object) cases; assert the generated YAML contains the flow-sequence form (e.g.runs-on: ["self-hosted","linux","x64"]).pkg/workflow/threat_detection_test.go— add array/object cases forthreat-detection.runs-on.pkg/workflow/runs_on_validation_test.go— add macOS-in-array rejection cases for the new fields.Update documentation:
docs/src/content/docs/reference/self-hosted-runners.md— show array examples forruns-on-slim,safe-outputs.runs-on, andthreat-detection.runs-on(currently only single-label).docs/src/content/docs/reference/frontmatter.md/frontmatter-full.mdanddocs/src/content/docs/reference/safe-outputs.md— update field type/descriptions.Follow project guidelines:
make fmtafter Go changes andmake recompileafter any workflow markdown changes.make agent-finish(build, test, recompile, lint, lint-errors) before completing.