Skip to content

safe-outputs.jobs.<id>.runs-on rejects the runner-group object form #52303

Description

@Etienne-M

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions