Skip to content
86 changes: 86 additions & 0 deletions docs/adr/34007-shared-runs-on-schema-and-any-typed-frontmatter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# ADR-34007: Shared `runs-on` Schema Across Top-Level and Jobs With `any`-Typed Frontmatter Field

**Date**: 2026-05-22
**Status**: Draft
**Deciders**: Unknown

---

## Part 1 — Narrative (Human-Friendly)

### Context

The custom workflow schema in `pkg/parser/schemas/main_workflow_schema.json` defined two independent `runs-on` shapes: a rich top-level definition that accepted string, array, and object (`{ group, labels }`) forms, and a duplicate `jobs.*.runs-on` definition whose object branch had `additionalProperties: false` with no declared properties, so any object value was rejected at schema-validation time. The compiler and runtime already understood the object form everywhere (it round-trips through GitHub Actions natively), so the gap was purely a validation artefact that blocked users from writing `jobs.<id>.runs-on: { group: arc-custom }` even though the generated workflow would have worked. On the Go side, `FrontmatterConfig.RunsOn` was typed `string`, which caused JSON unmarshalling to fail for the array and object forms before validation could even report a useful error, so the typed parser was the second copy of the same restriction.

### Decision

We will replace both `runs-on` schemas with a single `$ref` to a shared `#/$defs/github_actions_runs_on` definition that allows string, array, and object forms, and we will widen `FrontmatterConfig.RunsOn` from `string` to `any` so the typed frontmatter parser preserves whichever shape the user supplied. The serializer in `pkg/workflow/frontmatter_serialization.go` is updated to emit the field whenever it is non-`nil` (replacing the old non-empty-string check), so round-trip through `ToMap` is fidelity-preserving for all three forms. The driving principle is "validate against one source of truth that matches GitHub Actions semantics" rather than maintaining two parallel definitions that can drift.

### Alternatives Considered

#### Alternative 1: Fix the job-level object branch in place, keep two schemas

Add `properties: { group, labels }` to the inline `jobs.*.runs-on` object branch so it matches the top-level definition, leaving the two schemas physically separate. Rejected because the duplication is precisely what produced the original divergence: every future addition to `runs-on` semantics (e.g., a new GitHub Actions property) would need to be applied in two places, and the regression test would only catch drift that the author also remembered to write twice.

#### Alternative 2: Introduce a typed `RunsOn` union struct in Go

Replace the `string` field with a custom type that implements `json.Unmarshaler` / `yaml.Unmarshaler` and exposes typed accessors (`AsString`, `AsLabels`, `AsGroup`). Rejected because the field is passed through to YAML emission as-is — consumers of `FrontmatterConfig` do not branch on the runs-on shape, they only need to preserve it. A typed union would add unmarshaling code, generated-workflow code paths, and tests for zero functional benefit over `any` with round-trip preservation.

#### Alternative 3: Restrict job-level `runs-on` to string and array only

Leave the schema rejecting object form at job level and document the restriction. Rejected because the compiler and runtime already accept the object form for jobs, and the PR description shows that real users (ARC custom runner groups) need exactly this shape; the schema would remain the only thing standing in the way of a working workflow.

### Consequences

#### Positive
- `jobs.<id>.runs-on: { group: <name>, labels: [...] }` is accepted at schema-validation time, matching the compiler's existing behaviour and unblocking ARC / runner-group setups for job-level configuration.
- Top-level `runs-on` no longer fails JSON unmarshalling when the user supplies array or object forms — the field is preserved end-to-end through the typed config.
- The shared `$defs/github_actions_runs_on` definition becomes the single source of truth for runs-on validation; future additions need to be made in exactly one place.
- The new tests (`TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_AcceptsJobRunsOnObjectForm`, the three sub-tests in `frontmatter_types_test.go`) lock in regression coverage for all three forms at both positions.

#### Negative
- `FrontmatterConfig.RunsOn` is now `any`, so any future consumer that wants to branch on the runs-on shape must type-assert (`switch v := fc.RunsOn.(type)`). Compile-time guarantees about the field's type are lost.
- The serializer's "emit when non-nil" rule means a caller that explicitly sets `fc.RunsOn = ""` will now serialise an empty string instead of omitting the key. Existing call sites use the zero value (untouched `any`), which is `nil`, so this is latent rather than active risk.
- The schema is slightly less self-contained at the point of use: a reader of `jobs.*.runs-on` now has to follow a `$ref` to see what is allowed, instead of reading the shape inline.

#### Neutral
- The `$defs/github_actions_runs_on` definition is a verbatim copy of the previous top-level inline schema, including descriptions and examples; behaviour for top-level `runs-on` is unchanged.
- The `examples` array stays at the top-level use site rather than moving into `$defs`, which keeps top-level documentation visible without forcing the same examples onto the job-level binding.
- The PR also updates the actions lockfile and one workflow lock to a newer `docker/metadata-action@v6` pin; that change is unrelated to the schema decision and is not covered by this ADR.

---

## Part 2 — Normative Specification (RFC 2119)

> The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, **SHOULD**, **SHOULD NOT**, **RECOMMENDED**, **MAY**, and **OPTIONAL** in this section are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119).

### Schema Definition

1. The workflow schema **MUST** declare a single `$defs/github_actions_runs_on` definition whose top-level `oneOf` accepts: a `string`, an `array` of `string`, and an `object` with `additionalProperties: false` and optional properties `group` (`string`) and `labels` (`array` of `string`).
2. The top-level `runs-on` property **MUST** reference `#/$defs/github_actions_runs_on` and **MUST NOT** inline its own `oneOf` shape.
3. The `jobs.<job_id>.runs-on` property **MUST** reference `#/$defs/github_actions_runs_on` and **MUST NOT** inline its own `oneOf` shape.
4. The schema **MUST** validate a workflow where `jobs.<job_id>.runs-on` is the object `{ "group": "<name>" }` as conformant.
5. The schema **MUST** validate a workflow where `jobs.<job_id>.runs-on` is the object `{ "group": "<name>", "labels": ["<a>", "<b>"] }` as conformant.

### Typed Frontmatter Parsing

1. The `RunsOn` field on `FrontmatterConfig` **MUST** be declared with Go type `any` and tagged `json:"runs-on,omitempty"`.
2. `ParseFrontmatterConfig` **MUST** preserve the user-supplied shape of `runs-on` such that:
- a string input is exposed as a `string` value,
- an array input is exposed as a `[]any` value,
- an object input is exposed as a `map[string]any` value.
3. `FrontmatterConfig.ToMap` **MUST** include the `"runs-on"` key when `fc.RunsOn` is non-`nil` and **MUST** omit it when `fc.RunsOn` is `nil`.
4. `FrontmatterConfig.ToMap` **MUST** round-trip the runs-on value such that the value at key `"runs-on"` is identical (by type and content) to `fc.RunsOn`.

### Regression Coverage

1. The parser test suite **MUST** contain a test that calls `ValidateMainWorkflowFrontmatterWithSchemaAndLocation` with a workflow whose `jobs.<job_id>.runs-on` is `map[string]any{"group": "<name>"}` and asserts that no validation error is returned.
2. The frontmatter typed-parsing test suite **MUST** contain tests that round-trip `runs-on` through `ParseFrontmatterConfig` + `ToMap` for the string, array, and object forms and assert the value is preserved.

### Conformance

An implementation is considered conformant with this ADR if it satisfies all **MUST** and **MUST NOT** requirements above. Failure to meet any **MUST** or **MUST NOT** requirement constitutes non-conformance.

---

*This is a DRAFT ADR generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/26291961745) workflow. The PR author must review, complete, and finalize this document before the PR can merge.*
65 changes: 65 additions & 0 deletions pkg/parser/schema_location_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,71 @@ func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_AdditionalProperti
}
}

func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_AcceptsJobRunsOnObjectForm(t *testing.T) {

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.

[/tdd] Missing test coverage for array form — only object form is tested.

💡 Suggested test expansion

The schema accepts three forms for runs-on (string, array, object), but this test only validates the object form at the job level. Add test cases for the other two forms to prevent regressions:

func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_AcceptsJobRunsOnArrayForm(t *testing.T) {
    frontmatter := map[string]any{
        "on": "workflow_dispatch",
        "jobs": map[string]any{
            "my-job": map[string]any{
                "runs-on": []any{"self-hosted", "linux"},
                "steps": []any{
                    map[string]any{"run": "echo hello"},
                },
            },
        },
    }
    err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(frontmatter, "/test/workflow.md")
    assert.NoError(t, err, "Array form should be valid")
}

Without these tests, a future schema change could break array or string forms without triggering test failures.

frontmatter := map[string]any{
"on": "workflow_dispatch",
"jobs": map[string]any{
"my-prefetch": map[string]any{
"runs-on": map[string]any{
"group": "arc-custom",
},
"steps": []any{
map[string]any{
"run": "echo hello",
},
},
},
},
}

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

func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_AcceptsJobRunsOnStringForm(t *testing.T) {
frontmatter := map[string]any{
"on": "workflow_dispatch",
"jobs": map[string]any{
"my-prefetch": map[string]any{
"runs-on": "ubuntu-latest",
"steps": []any{
map[string]any{
"run": "echo hello",
},
},
},
},
}

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

func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_AcceptsJobRunsOnArrayForm(t *testing.T) {
frontmatter := map[string]any{
"on": "workflow_dispatch",
"jobs": map[string]any{
"my-prefetch": map[string]any{
"runs-on": []any{"self-hosted", "linux"},
"steps": []any{
map[string]any{
"run": "echo hello",
},
},
},
},
}

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
86 changes: 36 additions & 50 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2750,24 +2750,7 @@
"description": "Name of the job"
},
"runs-on": {
"oneOf": [
{
"type": "string",
"description": "Runner type as string"
},
{
"type": "array",
"description": "Runner type as array",
"items": {
"type": "string"
}
},
{
"type": "object",
"description": "Runner type as object",
"additionalProperties": false
}
],
"$ref": "#/$defs/github_actions_runs_on",
"description": "Runner label or environment where the job executes. Can be a string (single runner) or array (multiple runner requirements)."
},
"steps": {
Expand Down Expand Up @@ -3086,38 +3069,7 @@
}
},
"runs-on": {
"description": "Runner type for workflow execution (GitHub Actions standard field). Supports multiple forms: simple string for single runner label (e.g., 'ubuntu-latest'), array for runner selection with fallbacks, or object for GitHub-hosted runner groups with specific labels. For agentic workflows, runner selection matters when AI workloads require specific compute resources or when using self-hosted runners with specialized capabilities. Typically configured at the job level instead. See https://docs.github.com/en/actions/using-jobs/choosing-the-runner-for-a-job",
"oneOf": [
{
"type": "string",
"description": "Simple runner label string. Use for standard GitHub-hosted runners (e.g., 'ubuntu-latest', 'windows-latest', 'macos-latest') or self-hosted runner labels. Most common form for agentic workflows."
},
{
"type": "array",
"description": "Array of runner labels for selection with fallbacks. GitHub Actions will use the first available runner that matches any label in the array. Useful for high-availability setups or when multiple runner types are acceptable.",
"items": {
"type": "string"
}
},
{
"type": "object",
"description": "Runner group configuration for GitHub-hosted runners. Use this form to target specific runner groups (e.g., larger runners with more CPU/memory) or self-hosted runner pools with specific label requirements. Agentic workflows may benefit from larger runners for complex AI processing tasks.",
"additionalProperties": false,
"properties": {
"group": {
"type": "string",
"description": "Runner group name for self-hosted runners or GitHub-hosted runner groups"
},
"labels": {
"type": "array",
"description": "List of runner labels for self-hosted runners or GitHub-hosted runner selection",
"items": {
"type": "string"
}
}
}
}
],
"$ref": "#/$defs/github_actions_runs_on",
"examples": [
"ubuntu-latest",
[
Expand Down Expand Up @@ -11743,6 +11695,40 @@
}
],
"$defs": {
"github_actions_runs_on": {
"description": "Runner type for workflow execution (GitHub Actions standard field). Supports multiple forms: simple string for single runner label (e.g., 'ubuntu-latest'), array for runner selection with fallbacks, or object for GitHub-hosted runner groups with specific labels. For agentic workflows, runner selection matters when AI workloads require specific compute resources or when using self-hosted runners with specialized capabilities. Typically configured at the job level instead. See https://docs.github.com/en/actions/using-jobs/choosing-the-runner-for-a-job",
"oneOf": [
{
"type": "string",
"description": "Simple runner label string. Use for standard GitHub-hosted runners (e.g., 'ubuntu-latest', 'windows-latest', 'macos-latest') or self-hosted runner labels. Most common form for agentic workflows."
},
{
"type": "array",
"description": "Array of runner labels for selection with fallbacks. GitHub Actions will use the first available runner that matches any label in the array. Useful for high-availability setups or when multiple runner types are acceptable.",
"items": {
"type": "string"
}
},
{
"type": "object",
"description": "Runner group configuration for GitHub-hosted runners. Use this form to target specific runner groups (e.g., larger runners with more CPU/memory) or self-hosted runner pools with specific label requirements. Agentic workflows may benefit from larger runners for complex AI processing tasks.",
"additionalProperties": false,
"properties": {
"group": {
"type": "string",
"description": "Runner group name for self-hosted runners or GitHub-hosted runner groups"
},
"labels": {
"type": "array",
"description": "List of runner labels for self-hosted runners or GitHub-hosted runner selection",
"items": {
"type": "string"
}
}
}
}
]
},
"github_toolset_name": {
"type": "string",
"description": "A GitHub MCP server toolset name that enables a specific group of GitHub API functionalities.",
Expand Down
46 changes: 46 additions & 0 deletions pkg/workflow/frontmatter_parsing.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ func ParseFrontmatterConfig(frontmatter map[string]any) (*FrontmatterConfig, err
return nil, fmt.Errorf("failed to unmarshal frontmatter into config: %w", err)
}

if err := validateRunsOnValue(config.RunsOn); err != nil {
return nil, err
}

// Parse typed Runtimes field if runtimes exist
if len(config.Runtimes) > 0 {
runtimesTyped, err := parseRuntimesConfig(config.Runtimes)
Expand Down Expand Up @@ -78,6 +82,48 @@ func ParseFrontmatterConfig(frontmatter map[string]any) (*FrontmatterConfig, err
return &config, nil
}

func validateRunsOnValue(value any) error {
if value == nil {
return nil
}

switch v := value.(type) {
case string:
return nil
case []any:
for _, label := range v {
if _, ok := label.(string); !ok {
return fmt.Errorf("invalid runs-on array entry type %T: expected string", label)
}
}
return nil
case map[string]any:
for key, value := range v {
switch key {
case "group":
if _, ok := value.(string); !ok {
return fmt.Errorf("invalid runs-on.group type %T: expected string", value)
}
case "labels":
labels, ok := value.([]any)
if !ok {
return fmt.Errorf("invalid runs-on.labels type %T: expected array of strings", value)
}
for _, label := range labels {
if _, ok := label.(string); !ok {
return fmt.Errorf("invalid runs-on.labels entry type %T: expected string", label)
}
}
default:
return fmt.Errorf("invalid runs-on object key %q: expected only group or labels", key)
}
}
return nil
default:
return fmt.Errorf("invalid runs-on type %T: expected string, array of strings, or object", value)
}
}

func parseOnNeedsConfig(on map[string]any) ([]string, error) {
return parseOnNeedsValues(on)
}
Expand Down
18 changes: 17 additions & 1 deletion pkg/workflow/frontmatter_serialization.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package workflow

import "reflect"

// countRuntimes counts the number of non-nil runtimes in RuntimesConfig
func countRuntimes(config *RuntimesConfig) int {
if config == nil {
Expand Down Expand Up @@ -180,7 +182,7 @@ func (fc *FrontmatterConfig) ToMap() map[string]any {
}

// Execution settings
if fc.RunsOn != "" {
if !isNilValue(fc.RunsOn) {
result["runs-on"] = fc.RunsOn
}
if fc.RunsOnSlim != "" {
Expand Down Expand Up @@ -233,6 +235,20 @@ func (fc *FrontmatterConfig) ToMap() map[string]any {
return result
}

func isNilValue(v any) bool {
if v == nil {
return true
}

rv := reflect.ValueOf(v)
switch rv.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
return rv.IsNil()
default:
return false
}
}

// runtimeConfigToMap converts a single RuntimeConfig to map[string]any
func runtimeConfigToMap(rc *RuntimeConfig) map[string]any {
m := map[string]any{}
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 @@ -308,7 +308,7 @@ type FrontmatterConfig struct {
Secrets map[string]any `json:"secrets,omitempty"`

// Workflow execution settings
RunsOn string `json:"runs-on,omitempty"`
RunsOn any `json:"runs-on,omitempty"` // Supports string, array, or object GitHub Actions runner forms

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.

[/zoom-out] Type safety erosion — any loses compile-time guarantees.

💡 Consider a typed approach

Changing from string to any aligns with runtime behavior but removes all compile-time type safety. Invalid values like runs-on: 42 will now pass Go type checking and only fail at GitHub Actions runtime.

Options to preserve some type safety:

  1. Custom type with validation:
type RunsOnConfig struct {
    value any
}

func (r *RunsOnConfig) UnmarshalJSON(data []byte) error {
    var v any
    if err := json.Unmarshal(data, &v); err != nil {
        return err
    }
    // Validate v is string, []string, or map[string]any
    switch v.(type) {
    case string, []any, map[string]any:
        r.value = v
        return nil
    default:
        return fmt.Errorf("runs-on must be string, array, or object")
    }
}
  1. Runtime validation in ParseFrontmatterConfig:
if runsOn, ok := frontmatter["runs-on"]; ok {
    if !isValidRunsOn(runsOn) {
        return nil, fmt.Errorf("invalid runs-on type")
    }
    config.RunsOn = runsOn
}

Either approach catches invalid values before they reach GitHub Actions.

RunsOnSlim string `json:"runs-on-slim,omitempty"` // Runner for all framework/generated jobs (activation, safe-outputs, unlock, etc.)
RunName string `json:"run-name,omitempty"`
PreSteps []any `json:"pre-steps,omitempty"` // Pre-workflow steps (run before checkout)
Expand Down
Loading