Summary
runs-on accepts the GitHub Actions runner-group object form (group: + labels:) everywhere in gh-aw except custom safe-jobs under safe-outputs.jobs.<id>. Using the object form there fails compilation with expected string or array, got object, forcing anyone on a runner-group-only fleet (no shared/self-hosted labels available) to abandon custom safe-jobs entirely.
Reproduction
---
on: workflow_dispatch
permissions: read-all
engine: copilot
runs-on:
group: my-group
labels: [linux, x64]
safe-outputs:
runs-on:
group: so-group
labels: [linux]
threat-detection:
runs-on:
group: td-group
labels: [linux]
create-issue:
jobs:
notify:
runs-on:
group: sj-group
labels: [linux]
inputs:
msg:
description: m
steps:
- run: echo hi
---
# Test
Do nothing.
$ gh aw compile .github/workflows/rg.md
✗ rg.md (1 error(s)):
• 1. rg.md:19:15: error: expected string or array, got object
16 | create-issue:
17 | jobs:
18 | notify:
19 | runs-on:
20 | group: sj-group
21 | labels: [linux]
Removing only the safe-outputs.jobs.notify.runs-on block makes the same workflow compile cleanly, and the lock file correctly emits the group form for the agent job, the safe-outputs job, and the detection job:
runs-on:
group: so-group
labels:
- linux
So the gap is isolated to custom safe-jobs.
Analysis / Root cause
Two independent places reject the object form.
1. Schema declares only string | string[]
Everywhere else, runs-on is a $ref to the shared github_actions_runs_on definition, which is a oneOf of string / array / {group, labels} object:
pkg/parser/schemas/main_workflow_schema.json — $defs.github_actions_runs_on (~L12426)
- Top-level
runs-on (~L2769), runs-on-slim (~L2780), safe-outputs.runs-on (~L11263), safe-outputs.threat-detection.runs-on (~L10724) all $ref it.
But the safe-job schema hand-rolls a narrower oneOf:
pkg/parser/schemas/main_workflow_schema.json ~L10762 — safe-outputs.jobs.<id>.runs-on
pkg/parser/schemas/main_workflow_schema.json ~L10879 — safe-outputs.jobs.<id>.runner (documented alias for runs-on)
Both allow only {"type": "string"} or {"type": "array", "items": {"type": "string"}}. This is the source of the compile error message.
2. Go extraction silently drops the object form
pkg/workflow/safe_jobs.go L235-L252 hand-rolls the string/array cases:
if jobConfig.RunsOn != nil {
if runsOnStr, ok := jobConfig.RunsOn.(string); ok {
job.RunsOn = "runs-on: " + runsOnStr
} else if runsOnList, ok := jobConfig.RunsOn.([]any); ok {
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 {
job.RunsOn = "runs-on: ubuntu-latest" // Default
}
A map[string]any matches neither branch, so job.RunsOn stays "". Note this is a silent-failure fallthrough: even if only the schema were relaxed, the emitted job would have no runs-on: line at all (see pkg/workflow/jobs.go L275, which skips the field when empty), producing an invalid workflow rather than an error.
Contrast with top-level custom jobs, which handle this correctly in pkg/workflow/compiler_custom_jobs.go (extractCustomJobRunsOn, ~L245) by delegating the non-string case to formatIndentedYAMLField (~L578). That helper marshals any value and indents continuation lines by 6 spaces — exactly the indentation job.RunsOn needs, since jobs.go writes the first line with a 4-space prefix.
safe_jobs.go is the only remaining runs-on code path that never routes through a shared helper (renderRunsOnSnippet / formatIndentedYAMLField).
Proposed fix
1. Schema — pkg/parser/schemas/main_workflow_schema.json
Replace the inline oneOf for safe-outputs.jobs.<id>.runs-on (~L10762) with a $ref, matching every other runs-on in the schema:
"runs-on": {
"$ref": "#/$defs/github_actions_runs_on",
"description": "Runner specification for this job. Supports string, array, or runner-group object forms. Defaults to 'ubuntu-latest'.",
"examples": [
"ubuntu-latest",
["self-hosted", "linux", "x64"],
{ "group": "larger-runners", "labels": ["ubuntu-latest-8-cores"] }
]
}
Do the same for the runner alias (~L10879), keeping its "(alias for runs-on)" wording so the two stay consistent.
2. Go — pkg/workflow/safe_jobs.go
Replace the hand-rolled block at L235-L252 with the shared helper already used by custom jobs:
if jobConfig.RunsOn != nil {
if runsOnStr, ok := jobConfig.RunsOn.(string); ok {
job.RunsOn = "runs-on: " + runsOnStr
} else {
formatted, err := formatIndentedYAMLField("runs-on", jobConfig.RunsOn, true)
if err != nil {
return fmt.Errorf("runs-on field for safe-job '%s' could not be converted to YAML: %w. Expected a string, array of strings, or an object with 'group' and 'labels'. Example: runs-on:\n group: my-runner-group", jobName, err)
}
job.RunsOn = formatted
}
} else {
job.RunsOn = "runs-on: ubuntu-latest"
}
Notes for the implementing agent:
- Keep the existing
ubuntu-latest default for the unset case — several tests assert it (pkg/workflow/safe_jobs_test.go L296).
formatIndentedYAMLField uses 6-space continuation indentation, which matches the current hardcoded " - " for the array case, so existing array-form output is byte-identical. Verify this rather than assuming.
- The surrounding function may not currently return an error; if plumbing an error through is disruptive, mirror the existing style but do not leave a silent
"" fallthrough — that emits an invalid job.
- Error message must follow the [what's wrong]. [what's expected]. [example] template per the Error Message Style Guide, and
make lint-errors must pass.
- Confirm whether
validateRunsOnValue (pkg/workflow/runs_on_validation.go L79) and the macOS guard in validateRunsOn (same file, L42) cover safe-job runs-on. From reading validateRunsOn, the runsOnFields list includes runs-on, runs-on-slim, safe-outputs.runs-on, and safe-outputs.threat-detection.runs-on — but not safe-outputs.jobs.<id>.runs-on. Once the object form is accepted, this is worth extending so a safe-job cannot smuggle in a macOS runner that the other paths reject.
3. Tests — pkg/workflow/safe_jobs_test.go
Add table-driven coverage for safe-job runs-on extraction:
- string form →
runs-on: ubuntu-latest
- array form → unchanged from today's output (regression guard)
- object form with
group only
- object form with
group + labels
- object form via the
runner alias
- unset →
runs-on: ubuntu-latest
Add an end-to-end compile test asserting the generated lock file contains the indented group: / labels: block for the safe-job, similar to the existing safe-outputs runner tests.
4. Docs
docs/src/content/docs/reference/self-hosted-runners.md (~L70) — the "Object — named runner group" section currently reads as universal; add the safe-job case to the supported list once fixed.
docs/src/content/docs/reference/safe-outputs.md — document runs-on on custom safe-jobs with a runner-group example.
Environment
github/gh-aw @ main, reproduced locally with go run ./cmd/gh-aw compile
Suggested labels
bug, workflow
Summary
runs-onaccepts the GitHub Actions runner-group object form (group:+labels:) everywhere in gh-aw except custom safe-jobs undersafe-outputs.jobs.<id>. Using the object form there fails compilation withexpected string or array, got object, forcing anyone on a runner-group-only fleet (no shared/self-hosted labels available) to abandon custom safe-jobs entirely.Reproduction
--- on: workflow_dispatch permissions: read-all engine: copilot runs-on: group: my-group labels: [linux, x64] safe-outputs: runs-on: group: so-group labels: [linux] threat-detection: runs-on: group: td-group labels: [linux] create-issue: jobs: notify: runs-on: group: sj-group labels: [linux] inputs: msg: description: m steps: - run: echo hi --- # Test Do nothing.Removing only the
safe-outputs.jobs.notify.runs-onblock makes the same workflow compile cleanly, and the lock file correctly emits the group form for the agent job, the safe-outputs job, and the detection job:So the gap is isolated to custom safe-jobs.
Analysis / Root cause
Two independent places reject the object form.
1. Schema declares only
string | string[]Everywhere else,
runs-onis a$refto the sharedgithub_actions_runs_ondefinition, which is aoneOfof string / array /{group, labels}object:pkg/parser/schemas/main_workflow_schema.json—$defs.github_actions_runs_on(~L12426)runs-on(~L2769),runs-on-slim(~L2780),safe-outputs.runs-on(~L11263),safe-outputs.threat-detection.runs-on(~L10724) all$refit.But the safe-job schema hand-rolls a narrower
oneOf:pkg/parser/schemas/main_workflow_schema.json~L10762 —safe-outputs.jobs.<id>.runs-onpkg/parser/schemas/main_workflow_schema.json~L10879 —safe-outputs.jobs.<id>.runner(documented alias forruns-on)Both allow only
{"type": "string"}or{"type": "array", "items": {"type": "string"}}. This is the source of the compile error message.2. Go extraction silently drops the object form
pkg/workflow/safe_jobs.goL235-L252 hand-rolls the string/array cases:A
map[string]anymatches neither branch, sojob.RunsOnstays"". Note this is a silent-failure fallthrough: even if only the schema were relaxed, the emitted job would have noruns-on:line at all (seepkg/workflow/jobs.goL275, which skips the field when empty), producing an invalid workflow rather than an error.Contrast with top-level custom jobs, which handle this correctly in
pkg/workflow/compiler_custom_jobs.go(extractCustomJobRunsOn, ~L245) by delegating the non-string case toformatIndentedYAMLField(~L578). That helper marshals any value and indents continuation lines by 6 spaces — exactly the indentationjob.RunsOnneeds, sincejobs.gowrites the first line with a 4-space prefix.safe_jobs.gois the only remainingruns-oncode path that never routes through a shared helper (renderRunsOnSnippet/formatIndentedYAMLField).Proposed fix
1. Schema —
pkg/parser/schemas/main_workflow_schema.jsonReplace the inline
oneOfforsafe-outputs.jobs.<id>.runs-on(~L10762) with a$ref, matching every otherruns-onin the schema:Do the same for the
runneralias (~L10879), keeping its "(alias for runs-on)" wording so the two stay consistent.2. Go —
pkg/workflow/safe_jobs.goReplace the hand-rolled block at L235-L252 with the shared helper already used by custom jobs:
Notes for the implementing agent:
ubuntu-latestdefault for the unset case — several tests assert it (pkg/workflow/safe_jobs_test.goL296).formatIndentedYAMLFielduses 6-space continuation indentation, which matches the current hardcoded" - "for the array case, so existing array-form output is byte-identical. Verify this rather than assuming.""fallthrough — that emits an invalid job.make lint-errorsmust pass.validateRunsOnValue(pkg/workflow/runs_on_validation.goL79) and the macOS guard invalidateRunsOn(same file, L42) cover safe-jobruns-on. From readingvalidateRunsOn, therunsOnFieldslist includesruns-on,runs-on-slim,safe-outputs.runs-on, andsafe-outputs.threat-detection.runs-on— but notsafe-outputs.jobs.<id>.runs-on. Once the object form is accepted, this is worth extending so a safe-job cannot smuggle in a macOS runner that the other paths reject.3. Tests —
pkg/workflow/safe_jobs_test.goAdd table-driven coverage for safe-job
runs-onextraction:runs-on: ubuntu-latestgrouponlygroup+labelsrunneraliasruns-on: ubuntu-latestAdd an end-to-end compile test asserting the generated lock file contains the indented
group:/labels:block for the safe-job, similar to the existing safe-outputs runner tests.4. Docs
docs/src/content/docs/reference/self-hosted-runners.md(~L70) — the "Object — named runner group" section currently reads as universal; add the safe-job case to the supported list once fixed.docs/src/content/docs/reference/safe-outputs.md— documentruns-onon custom safe-jobs with a runner-group example.Environment
github/gh-aw@main, reproduced locally withgo run ./cmd/gh-aw compileSuggested labels
bug,workflow