Skip to content

Commit f22d53c

Browse files
authored
Allow runs-on-slim to use runner label arrays (#38965)
1 parent ddb3c44 commit f22d53c

19 files changed

Lines changed: 272 additions & 20 deletions

.github/aw/workflow-constraints.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ When a requested feature increases risk:
6969

7070
When a workflow targets a self-hosted runner (any `runs-on` value other than GitHub-hosted labels such as `ubuntu-latest`, `ubuntu-slim`, `windows-latest`, or `macos-latest`), keep the generated workflow compatible with self-hosted constraints:
7171

72-
- Set `runs-on` explicitly (it is not inherited from imports) to the runner the user's setup provides; `runs-on` accepts a string, array, or runner-group object. Framework/generated jobs (activation, safe-outputs, unlock, etc.) default to the hosted `ubuntu-slim`, so also set `runs-on-slim` to route them to the self-hosted runner, otherwise they try to run on a hosted runner. `runs-on-slim` takes a single string label, so give it a self-hosted label the runner answers to (it cannot mirror an array or object value).
72+
- Set `runs-on` explicitly (it is not inherited from imports) to the runner the user's setup provides; `runs-on` accepts a string, array, or runner-group object. Framework/generated jobs (activation, safe-outputs, unlock, etc.) default to the hosted `ubuntu-slim`, so also set `runs-on-slim` to route them to the self-hosted runner, otherwise they try to run on a hosted runner. `runs-on-slim` accepts the same string, array, or runner-group object forms as `runs-on`.
7373
- Write transient state, tool downloads, and intermediate outputs under `$RUNNER_TEMP`, not `/tmp`, which can persist across jobs on shared runners.
7474
- The agent job's own steps run as the runner user, not root — don't write steps that assume root (for example, installing to system-wide paths). Separately, the egress firewall needs host-level privileges (sudo) on the runner; if the host cannot provide that, the firewall can be disabled, which removes egress filtering. Surface that trade-off to the user rather than encoding it in the workflow.
7575
- Declare every outbound domain the workflow contacts in `network.allowed` (keep `defaults` for the core GitHub/Copilot/registry endpoints). When the egress firewall is enabled (the default once network permissions are set), any domain that is not allow-listed is blocked.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# ADR-38965: Reuse the runs-on schema and rendering pipeline for runs-on-slim
2+
3+
**Date**: 2026-06-13
4+
**Status**: Accepted
5+
6+
## Context
7+
8+
`runs-on-slim` selects the runner for all framework/generated jobs (activation, safe-outputs, unlock, APM, etc.), while `runs-on` selects the runner for the main agent job. `runs-on` already accepts the full set of GitHub Actions runner forms — a plain string label, an array of labels, or a `{ group, labels }` runner-group object — but `runs-on-slim` was schema-validated and parsed as a string only. Self-hosted users who select runners by label array or runner group therefore could not route framework jobs to the same runner they use for `runs-on`, and any such value failed to compile. The two fields configure the same concept (runner selection) and should accept the same syntax.
9+
10+
## Decision
11+
12+
We will treat `runs-on-slim` as the same kind of value as `runs-on` rather than as a distinct string field. Concretely: the JSON schema entry for `runs-on-slim` now `$ref`s the shared `#/$defs/github_actions_runs_on` definition; the in-memory `FrontmatterConfig.RunsOnSlim` field changes from `string` to `any` and is validated through the existing `validateRunsOnValue`; and `WorkflowData.RunsOnSlim` holds a **rendered `runs-on:` YAML snippet** (produced by the same extraction path as `runs-on`) instead of a bare label. Downstream consumers re-indent that snippet for the framework job context via helpers (`formatRunsOnSnippetForInlineValue`, `indentYAMLLines`). This guarantees parity with `runs-on` and avoids a second, drift-prone validation/rendering path.
13+
14+
## Alternatives Considered
15+
16+
### Alternative 1: Keep `runs-on-slim` as a string and document the limitation
17+
Leave the type as `string` and tell users that `runs-on-slim` cannot mirror an array or runner-group value. Rejected because it permanently blocks legitimate self-hosted configurations and forces an inconsistent mental model where two runner-selection fields accept different syntax.
18+
19+
### Alternative 2: Add a separate schema and parser for `runs-on-slim`'s array/object forms
20+
Duplicate the array/runner-group validation and YAML-rendering logic specifically for `runs-on-slim`. Rejected because it duplicates non-trivial logic already maintained for `runs-on`, inviting divergence over time as one path gains features or fixes the other misses.
21+
22+
## Consequences
23+
24+
### Positive
25+
- `runs-on-slim` reaches full parity with `runs-on`, accepting string, label-array, and `{ group, labels }` forms.
26+
- Validation and rendering reuse the existing shared `runs-on` schema and code paths, so future changes apply to both fields automatically.
27+
- Self-hosted setups that select runners by label array or runner group can now route framework jobs correctly.
28+
29+
### Negative
30+
- The internal contract of `RunsOnSlim` changes: `FrontmatterConfig.RunsOnSlim` becomes `any` and `WorkflowData.RunsOnSlim` now carries a rendered `runs-on:` snippet rather than a bare label, requiring every consumer (serialization, framework-job formatting, central slash-command resolution) to be updated and re-tested.
31+
- Indentation handling adds complexity: snippets must be re-indented for differing YAML contexts, introducing helper functions whose correctness depends on the exact upstream rendering format.
32+
33+
### Neutral
34+
- Existing tests were updated to expect rendered `runs-on:` snippets instead of bare labels, and new tests cover the array and runner-group forms.
35+
- Reference docs, self-hosted-runner guidance, workflow constraints, and editor autocomplete metadata were updated to describe the expanded syntax.
36+
37+
---

docs/public/editor/autocomplete-data.json

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -484,9 +484,21 @@
484484
"array": true
485485
},
486486
"runs-on-slim": {
487-
"type": "string",
487+
"type": "string|array|object",
488488
"desc": "Runner for all framework/generated jobs (activation, pre-activation, safe-outputs, unlock, APM, etc.).",
489-
"leaf": true
489+
"children": {
490+
"group": {
491+
"type": "string",
492+
"desc": "Runner group name for self-hosted runners or GitHub-hosted runner groups",
493+
"leaf": true
494+
},
495+
"labels": {
496+
"type": "array",
497+
"desc": "List of runner labels for self-hosted runners or GitHub-hosted runner selection",
498+
"array": true
499+
}
500+
},
501+
"array": true
490502
},
491503
"timeout-minutes": {
492504
"type": "integer|string",

docs/src/content/docs/reference/frontmatter.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ runs-on-slim: ubuntu-slim # Defaults to ubuntu-slim (framework jobs o
195195
timeout-minutes: 30 # Defaults to 20 minutes
196196
```
197197

198-
`runs-on` applies to the main agent job only. `runs-on-slim` applies to all framework/generated jobs (activation, safe-outputs, unlock, etc.) and defaults to `ubuntu-slim`. `safe-outputs.runs-on` takes precedence over `runs-on-slim` for safe-output jobs specifically.
198+
`runs-on` applies to the main agent job only. `runs-on-slim` applies to all framework/generated jobs (activation, safe-outputs, unlock, etc.), accepts the same string, array, or runner-group object forms as `runs-on`, and defaults to `ubuntu-slim`. `safe-outputs.runs-on` takes precedence over `runs-on-slim` for safe-output jobs specifically.
199199

200200
`timeout-minutes` accepts an integer or a GitHub Actions expression string (e.g. `${{ inputs.timeout }}`), letting a reusable `workflow_call` workflow parameterize its own timeout from caller inputs. It applies to the workflow being compiled, **not** to plain caller jobs that invoke a reusable workflow with job-level `uses:` — GitHub rejects `timeout-minutes` there.
201201

docs/src/content/docs/reference/self-hosted-runners.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ safe-outputs:
113113

114114
> [!NOTE]
115115
> `runs-on` controls only the main agent job. `runs-on-slim` controls all framework/generated jobs. `safe-outputs.runs-on` still takes precedence over `runs-on-slim` for safe-output jobs specifically.
116+
> `runs-on-slim` accepts the same string, array, or runner-group object forms as `runs-on`.
116117
117118
## Configuring the maintenance workflow runner
118119

pkg/parser/schema_location_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,33 @@ func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_AcceptsJobRunsOnAr
415415
}
416416
}
417417

418+
func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_AcceptsRunsOnSlimArrayForm(t *testing.T) {
419+
frontmatter := map[string]any{
420+
"on": "workflow_dispatch",
421+
"runs-on-slim": []any{"self-hosted", "linux"},
422+
}
423+
424+
err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(frontmatter, "/test/workflow.md")
425+
if err != nil {
426+
t.Fatalf("ValidateMainWorkflowFrontmatterWithSchemaAndLocation() unexpected error = %v", err)
427+
}
428+
}
429+
430+
func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_AcceptsRunsOnSlimObjectForm(t *testing.T) {
431+
frontmatter := map[string]any{
432+
"on": "workflow_dispatch",
433+
"runs-on-slim": map[string]any{
434+
"group": "arc-custom",
435+
"labels": []any{"ubuntu2404", "x64"},
436+
},
437+
}
438+
439+
err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(frontmatter, "/test/workflow.md")
440+
if err != nil {
441+
t.Fatalf("ValidateMainWorkflowFrontmatterWithSchemaAndLocation() unexpected error = %v", err)
442+
}
443+
}
444+
418445
func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_AcceptsAllowedBaseBranchesInCreatePullRequest(t *testing.T) {
419446
frontmatter := map[string]any{
420447
"on": map[string]any{

pkg/parser/schemas/main_workflow_schema.json

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2634,9 +2634,18 @@
26342634
]
26352635
},
26362636
"runs-on-slim": {
2637-
"type": "string",
2638-
"description": "Runner for all framework/generated jobs (activation, pre-activation, safe-outputs, unlock, APM, etc.). Provides a compile-stable override for generated job runners without requiring a safe-outputs section. Overridden by safe-outputs.runs-on when both are set. Defaults to 'ubuntu-slim'. Use this when your infrastructure does not provide the default runner or when you need consistent runner selection across all jobs.",
2639-
"examples": ["self-hosted", "ubuntu-latest", "ubuntu-22.04"]
2637+
"$ref": "#/$defs/github_actions_runs_on",
2638+
"description": "Runner for all framework/generated jobs (activation, pre-activation, safe-outputs, unlock, APM, etc.). Provides a compile-stable override for generated job runners without requiring a safe-outputs section. Supports the same string, array, and runner-group object forms as runs-on. Overridden by safe-outputs.runs-on when both are set. Defaults to 'ubuntu-slim'. Use this when your infrastructure does not provide the default runner or when you need consistent runner selection across all jobs.",
2639+
"examples": [
2640+
"self-hosted",
2641+
"ubuntu-latest",
2642+
"ubuntu-22.04",
2643+
["self-hosted", "ubuntu2404", "x64", "host"],
2644+
{
2645+
"group": "larger-runners",
2646+
"labels": ["ubuntu-latest-8-cores"]
2647+
}
2648+
]
26402649
},
26412650
"timeout-minutes": {
26422651
"$ref": "#/$defs/templatable_integer",

pkg/workflow/central_slash_command_workflow.go

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -487,7 +487,7 @@ func resolveCentralSlashRunsOn(workflowDataList []*WorkflowData) string {
487487
if wd.SafeOutputs != nil && strings.TrimSpace(wd.SafeOutputs.RunsOn) != "" {
488488
resolved = strings.TrimSpace(wd.SafeOutputs.RunsOn)
489489
} else if strings.TrimSpace(wd.RunsOnSlim) != "" {
490-
resolved = strings.TrimSpace(wd.RunsOnSlim)
490+
resolved = formatRunsOnSnippetForInlineValue(wd.RunsOnSlim)
491491
}
492492
counts[resolved]++
493493
}
@@ -503,6 +503,29 @@ func resolveCentralSlashRunsOn(workflowDataList []*WorkflowData) string {
503503
return best
504504
}
505505

506+
func formatRunsOnSnippetForInlineValue(runsOn string) string {
507+
runsOn = strings.TrimSpace(runsOn)
508+
if !strings.HasPrefix(runsOn, "runs-on:") {
509+
return runsOn
510+
}
511+
512+
value := strings.TrimPrefix(runsOn, "runs-on:")
513+
if !strings.HasPrefix(value, "\n") {
514+
return strings.TrimSpace(value)
515+
}
516+
517+
value = strings.TrimPrefix(value, "\n")
518+
lines := strings.Split(value, "\n")
519+
for i, line := range lines {
520+
// The 2-space strip matches DefaultMarshalOptions map indentation.
521+
// The 6-space re-indent aligns with the central slash command template,
522+
// where runs-on: lives at 4-space job-level indent (4 + 2 = 6).
523+
line = strings.TrimPrefix(line, " ")
524+
lines[i] = " " + line
525+
}
526+
return "\n" + strings.Join(lines, "\n")
527+
}
528+
506529
func writeCentralSlashEventsYAML(b *strings.Builder, mergedEvents map[string]map[string]bool) {
507530
eventOrder := []string{
508531
"issues",

pkg/workflow/central_slash_command_workflow_test.go

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@ func TestGenerateCentralSlashCommandWorkflow_UsesCentralizedRunsOnResolution(t *
299299
Command: []string{"one"},
300300
CommandEvents: []string{"issue_comment"},
301301
CommandCentralized: true,
302-
RunsOnSlim: "ubuntu-latest",
302+
RunsOnSlim: "runs-on: ubuntu-latest",
303303
},
304304
{
305305
WorkflowID: "two",
@@ -327,6 +327,41 @@ func TestGenerateCentralSlashCommandWorkflow_UsesCentralizedRunsOnResolution(t *
327327
require.Contains(t, string(content), "runs-on: self-hosted")
328328
}
329329

330+
func TestFormatRunsOnSnippetForInlineValue(t *testing.T) {
331+
tests := []struct {
332+
name string
333+
runsOn string
334+
want string
335+
}{
336+
{
337+
name: "plain label",
338+
runsOn: "ubuntu-latest",
339+
want: "ubuntu-latest",
340+
},
341+
{
342+
name: "rendered string snippet",
343+
runsOn: "runs-on: self-hosted",
344+
want: "self-hosted",
345+
},
346+
{
347+
name: "rendered array snippet",
348+
runsOn: "runs-on:\n- self-hosted\n- linux",
349+
want: "\n - self-hosted\n - linux",
350+
},
351+
{
352+
name: "rendered object snippet",
353+
runsOn: "runs-on:\n group: runner-group\n labels:\n - linux",
354+
want: "\n group: runner-group\n labels:\n - linux",
355+
},
356+
}
357+
358+
for _, tt := range tests {
359+
t.Run(tt.name, func(t *testing.T) {
360+
require.Equal(t, tt.want, formatRunsOnSnippetForInlineValue(tt.runsOn))
361+
})
362+
}
363+
}
364+
330365
func TestBuildCommandsHeaderMetadata_UsesReleaseVersionOnlyForReleaseBuilds(t *testing.T) {
331366
originalVersion := compilerVersion
332367
originalIsRelease := isReleaseBuild

pkg/workflow/compiler_orchestrator_workflow_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,45 @@ func TestExtractYAMLSections_MissingSections(t *testing.T) {
212212
assert.Empty(t, workflowData.Cache)
213213
}
214214

215+
func TestExtractYAMLSections_EmptyRunsOnSlimTreatedAsUnset(t *testing.T) {
216+
compiler := NewCompiler()
217+
218+
tests := []struct {
219+
name string
220+
value any
221+
}{
222+
{
223+
name: "empty string",
224+
value: "",
225+
},
226+
{
227+
name: "empty array",
228+
value: []any{},
229+
},
230+
{
231+
name: "empty object",
232+
value: map[string]any{},
233+
},
234+
{
235+
name: "object with empty group and labels",
236+
value: map[string]any{"group": "", "labels": []any{}},
237+
},
238+
}
239+
240+
for _, tt := range tests {
241+
t.Run(tt.name, func(t *testing.T) {
242+
workflowData := &WorkflowData{}
243+
frontmatter := map[string]any{
244+
"runs-on-slim": tt.value,
245+
}
246+
247+
compiler.extractYAMLSections(frontmatter, workflowData)
248+
249+
assert.Empty(t, workflowData.RunsOnSlim)
250+
})
251+
}
252+
}
253+
215254
func TestValidateWorkflowEngineSettings_PreservesLegacyErrorOrder(t *testing.T) {
216255
compiler := NewCompiler()
217256
compiler.strictMode = true

0 commit comments

Comments
 (0)