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/aw/workflow-constraints.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ When a requested feature increases risk:

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:

- 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).
- 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`.
- Write transient state, tool downloads, and intermediate outputs under `$RUNNER_TEMP`, not `/tmp`, which can persist across jobs on shared runners.
- 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.
- 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.
Expand Down
37 changes: 37 additions & 0 deletions docs/adr/38965-reuse-runs-on-schema-for-runs-on-slim.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# ADR-38965: Reuse the runs-on schema and rendering pipeline for runs-on-slim

**Date**: 2026-06-13
**Status**: Accepted

## Context

`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.

## Decision

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.

## Alternatives Considered

### Alternative 1: Keep `runs-on-slim` as a string and document the limitation
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.

### Alternative 2: Add a separate schema and parser for `runs-on-slim`'s array/object forms
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.

## Consequences

### Positive
- `runs-on-slim` reaches full parity with `runs-on`, accepting string, label-array, and `{ group, labels }` forms.
- Validation and rendering reuse the existing shared `runs-on` schema and code paths, so future changes apply to both fields automatically.
- Self-hosted setups that select runners by label array or runner group can now route framework jobs correctly.

### Negative
- 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.
- 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.

### Neutral
- Existing tests were updated to expect rendered `runs-on:` snippets instead of bare labels, and new tests cover the array and runner-group forms.
- Reference docs, self-hosted-runner guidance, workflow constraints, and editor autocomplete metadata were updated to describe the expanded syntax.

---
16 changes: 14 additions & 2 deletions docs/public/editor/autocomplete-data.json
Original file line number Diff line number Diff line change
Expand Up @@ -484,9 +484,21 @@
"array": true
},
"runs-on-slim": {
"type": "string",
"type": "string|array|object",
"desc": "Runner for all framework/generated jobs (activation, pre-activation, safe-outputs, unlock, APM, etc.).",
"leaf": true
"children": {
"group": {
"type": "string",
"desc": "Runner group name for self-hosted runners or GitHub-hosted runner groups",
"leaf": true
},
"labels": {
"type": "array",
"desc": "List of runner labels for self-hosted runners or GitHub-hosted runner selection",
"array": true
}
},
"array": true
},
"timeout-minutes": {
"type": "integer|string",
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/reference/frontmatter.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ runs-on-slim: ubuntu-slim # Defaults to ubuntu-slim (framework jobs o
timeout-minutes: 30 # Defaults to 20 minutes
```

`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.
`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.

`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.

Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/reference/self-hosted-runners.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ safe-outputs:

> [!NOTE]
> `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.
> `runs-on-slim` accepts the same string, array, or runner-group object forms as `runs-on`.

## Configuring the maintenance workflow runner

Expand Down
27 changes: 27 additions & 0 deletions pkg/parser/schema_location_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,33 @@ func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_AcceptsJobRunsOnAr
}
}

func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_AcceptsRunsOnSlimArrayForm(t *testing.T) {
frontmatter := map[string]any{
"on": "workflow_dispatch",
"runs-on-slim": []any{"self-hosted", "linux"},
}

err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(frontmatter, "/test/workflow.md")
if err != nil {
t.Fatalf("ValidateMainWorkflowFrontmatterWithSchemaAndLocation() unexpected error = %v", err)
}
}

func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_AcceptsRunsOnSlimObjectForm(t *testing.T) {
frontmatter := map[string]any{
"on": "workflow_dispatch",
"runs-on-slim": map[string]any{
"group": "arc-custom",
"labels": []any{"ubuntu2404", "x64"},
},
}

err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(frontmatter, "/test/workflow.md")
if err != nil {
t.Fatalf("ValidateMainWorkflowFrontmatterWithSchemaAndLocation() unexpected error = %v", err)
}
}

func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_AcceptsAllowedBaseBranchesInCreatePullRequest(t *testing.T) {
frontmatter := map[string]any{
"on": map[string]any{
Expand Down
15 changes: 12 additions & 3 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2634,9 +2634,18 @@
]
},
"runs-on-slim": {
"type": "string",
"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.",
"examples": ["self-hosted", "ubuntu-latest", "ubuntu-22.04"]
"$ref": "#/$defs/github_actions_runs_on",
"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.",
"examples": [
"self-hosted",
"ubuntu-latest",
"ubuntu-22.04",
["self-hosted", "ubuntu2404", "x64", "host"],
{
"group": "larger-runners",
"labels": ["ubuntu-latest-8-cores"]
}
]
},
"timeout-minutes": {
"$ref": "#/$defs/templatable_integer",
Expand Down
25 changes: 24 additions & 1 deletion pkg/workflow/central_slash_command_workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -487,7 +487,7 @@ func resolveCentralSlashRunsOn(workflowDataList []*WorkflowData) string {
if wd.SafeOutputs != nil && strings.TrimSpace(wd.SafeOutputs.RunsOn) != "" {
resolved = strings.TrimSpace(wd.SafeOutputs.RunsOn)
} else if strings.TrimSpace(wd.RunsOnSlim) != "" {
resolved = strings.TrimSpace(wd.RunsOnSlim)
resolved = formatRunsOnSnippetForInlineValue(wd.RunsOnSlim)
}
counts[resolved]++
}
Expand All @@ -503,6 +503,29 @@ func resolveCentralSlashRunsOn(workflowDataList []*WorkflowData) string {
return best
}

func formatRunsOnSnippetForInlineValue(runsOn string) string {
runsOn = strings.TrimSpace(runsOn)
if !strings.HasPrefix(runsOn, "runs-on:") {
return runsOn
}

value := strings.TrimPrefix(runsOn, "runs-on:")
if !strings.HasPrefix(value, "\n") {
return strings.TrimSpace(value)
}

value = strings.TrimPrefix(value, "\n")
lines := strings.Split(value, "\n")
for i, line := range lines {
// The 2-space strip matches DefaultMarshalOptions map indentation.
// The 6-space re-indent aligns with the central slash command template,
// where runs-on: lives at 4-space job-level indent (4 + 2 = 6).
line = strings.TrimPrefix(line, " ")
lines[i] = " " + line
Comment on lines +519 to +524

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fragile magic-number indentation coupling: the " " strip and " " re-indent are silently coupled to two external invariants — DefaultMarshalOptions producing 2-space map-field indent, and runs-on: sitting at exactly 4-space indent in the central slash command template. No comment explains these assumptions, so a future indent-style or template change would corrupt the generated YAML silently.

💡 Suggested fix

Document the coupling explicitly:

// The 2-space strip matches DefaultMarshalOptions map indentation.
// The 6-space re-indent aligns with the central slash command template,
// where runs-on: lives at 4-space job-level indent (4 + 2 = 6).
line = strings.TrimPrefix(line, "  ")
lines[i] = "      " + line

Alternatively, pass the target indent as a parameter so the function is not implicitly tied to one specific template.

}
return "\n" + strings.Join(lines, "\n")
}

func writeCentralSlashEventsYAML(b *strings.Builder, mergedEvents map[string]map[string]bool) {
eventOrder := []string{
"issues",
Expand Down
37 changes: 36 additions & 1 deletion pkg/workflow/central_slash_command_workflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ func TestGenerateCentralSlashCommandWorkflow_UsesCentralizedRunsOnResolution(t *
Command: []string{"one"},
CommandEvents: []string{"issue_comment"},
CommandCentralized: true,
RunsOnSlim: "ubuntu-latest",
RunsOnSlim: "runs-on: ubuntu-latest",
},
{
WorkflowID: "two",
Expand Down Expand Up @@ -327,6 +327,41 @@ func TestGenerateCentralSlashCommandWorkflow_UsesCentralizedRunsOnResolution(t *
require.Contains(t, string(content), "runs-on: self-hosted")
}

func TestFormatRunsOnSnippetForInlineValue(t *testing.T) {
tests := []struct {
name string
runsOn string
want string
}{
{
name: "plain label",
runsOn: "ubuntu-latest",
want: "ubuntu-latest",
},
{
name: "rendered string snippet",
runsOn: "runs-on: self-hosted",
want: "self-hosted",
},
{
name: "rendered array snippet",
runsOn: "runs-on:\n- self-hosted\n- linux",
want: "\n - self-hosted\n - linux",
},
{
name: "rendered object snippet",
runsOn: "runs-on:\n group: runner-group\n labels:\n - linux",
want: "\n group: runner-group\n labels:\n - linux",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, formatRunsOnSnippetForInlineValue(tt.runsOn))
})
}
}

func TestBuildCommandsHeaderMetadata_UsesReleaseVersionOnlyForReleaseBuilds(t *testing.T) {
originalVersion := compilerVersion
originalIsRelease := isReleaseBuild
Expand Down
39 changes: 39 additions & 0 deletions pkg/workflow/compiler_orchestrator_workflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,45 @@ func TestExtractYAMLSections_MissingSections(t *testing.T) {
assert.Empty(t, workflowData.Cache)
}

func TestExtractYAMLSections_EmptyRunsOnSlimTreatedAsUnset(t *testing.T) {
compiler := NewCompiler()

tests := []struct {
name string
value any
}{
{
name: "empty string",
value: "",
},
{
name: "empty array",
value: []any{},
},
{
name: "empty object",
value: map[string]any{},
},
{
name: "object with empty group and labels",
value: map[string]any{"group": "", "labels": []any{}},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
workflowData := &WorkflowData{}
frontmatter := map[string]any{
"runs-on-slim": tt.value,
}

compiler.extractYAMLSections(frontmatter, workflowData)

assert.Empty(t, workflowData.RunsOnSlim)
})
}
}

func TestValidateWorkflowEngineSettings_PreservesLegacyErrorOrder(t *testing.T) {
compiler := NewCompiler()
compiler.strictMode = true
Expand Down
2 changes: 1 addition & 1 deletion pkg/workflow/compiler_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -488,7 +488,7 @@ type WorkflowData struct {
PreAgentSteps string // steps to run immediately before the agent execution step
PostSteps string // steps to run after AI execution
RunsOn string
RunsOnSlim string // runner override for all framework/generated jobs (activation, safe-outputs, unlock, etc.)
RunsOnSlim string // rendered runs-on snippet for framework/generated jobs (activation, safe-outputs, unlock, etc.)
Environment string // environment setting for the main job
Container string // container setting for the main job
Services string // services setting for the main job
Expand Down
3 changes: 3 additions & 0 deletions pkg/workflow/frontmatter_parsing.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ func ParseFrontmatterConfig(frontmatter map[string]any) (*FrontmatterConfig, err
if err := validateRunsOnValue(config.RunsOn); err != nil {
return nil, err
}
if err := validateRunsOnValue(config.RunsOnSlim); err != nil {
return nil, err
}

// Parse typed Runtimes field if runtimes exist
if len(config.Runtimes) > 0 {
Expand Down
2 changes: 1 addition & 1 deletion pkg/workflow/frontmatter_serialization.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ func (fc *FrontmatterConfig) ToMap() map[string]any {
if !isNilValue(fc.RunsOn) {
result["runs-on"] = fc.RunsOn
}
if fc.RunsOnSlim != "" {
if !isEmptyRunsOnValue(fc.RunsOnSlim) {
result["runs-on-slim"] = fc.RunsOnSlim
}
Comment on lines 182 to 187
if fc.RunName != "" {
Expand Down
2 changes: 1 addition & 1 deletion pkg/workflow/frontmatter_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ type FrontmatterConfig struct {

// Workflow execution settings
RunsOn any `json:"runs-on,omitempty"` // Supports string, array, or object GitHub Actions runner forms
RunsOnSlim string `json:"runs-on-slim,omitempty"` // Runner for all framework/generated jobs (activation, safe-outputs, unlock, etc.)
RunsOnSlim any `json:"runs-on-slim,omitempty"` // Runner for all framework/generated jobs; supports the same forms as runs-on
RunName string `json:"run-name,omitempty"`
PreSteps []any `json:"pre-steps,omitempty"` // Pre-workflow steps (run before checkout)
Steps []any `json:"steps,omitempty"` // Custom workflow steps
Expand Down
10 changes: 10 additions & 0 deletions pkg/workflow/frontmatter_types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1075,6 +1075,16 @@ func TestFrontmatterConfigIntegration(t *testing.T) {
assert.Equal(t, true, networkMap["allowed-input"], "allowed-input should be preserved")
assert.Equal(t, []string{"defaults"}, networkMap["allowed"], "allowed list should be preserved")
})

t.Run("ToMap omits empty runs-on-slim", func(t *testing.T) {
config := &FrontmatterConfig{
RunsOnSlim: "",
}

reconstructed := config.ToMap()
_, ok := reconstructed["runs-on-slim"]
assert.False(t, ok, "empty runs-on-slim should be omitted")
})
}

// TestRuntimesConfigTyping tests the new typed RuntimesConfig field
Expand Down
24 changes: 24 additions & 0 deletions pkg/workflow/runs_on_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,30 @@ func validateRunsOnValue(value any) error {
}
}

func isEmptyRunsOnValue(value any) bool {
switch v := value.(type) {
case nil:
return true
case string:
return strings.TrimSpace(v) == ""
case []any:
return len(v) == 0
case map[string]any:
if len(v) == 0 {
return true
}

group, hasGroup := v["group"].(string)
labels, hasLabels := v["labels"].([]any)
if !hasGroup && !hasLabels {
return false
}
return strings.TrimSpace(group) == "" && len(labels) == 0
default:
return false
}
}

// extractRunnerLabels extracts individual runner label strings from a runs-on value.
// Handles all supported GitHub Actions runs-on forms:
// - string: "ubuntu-latest"
Expand Down
Loading
Loading