Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
5 changes: 5 additions & 0 deletions .changeset/major-safe-job-runs-on.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 18 additions & 0 deletions docs/src/content/docs/reference/safe-outputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -1895,6 +1895,24 @@ safe-outputs:

`safe-outputs.runs-on` overrides `runs-on-slim:` for safe-output jobs specifically. To override the runner for all framework jobs at once, use the top-level [`runs-on-slim:`](/gh-aw/reference/self-hosted-runners/#configuring-the-framework-job-runner) field instead.

Custom safe-jobs can select their own runner with `safe-outputs.jobs.<job>.runs-on`. This field supports runner labels, label arrays, and runner-group objects:

```aw
---
safe-outputs:
jobs:
notify:
runs-on:
group: safe-job-runners
labels: [linux]
inputs:
message:
description: Notification message
steps:
- run: echo "Notify"
---
```

### Safe Outputs Job Concurrency (`concurrency-group:`)

Control concurrency for the compiled `safe_outputs` job. When set, the job uses this group with `cancel-in-progress: false` (queuing semantics — in-progress runs are never cancelled).
Expand Down
2 changes: 2 additions & 0 deletions docs/src/content/docs/reference/self-hosted-runners.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ runs-on:
---
```

The string, array, and object forms are supported by the top-level `runs-on`, `runs-on-slim`, `safe-outputs.runs-on`, `safe-outputs.threat-detection.runs-on`, and custom `safe-outputs.jobs.<job>.runs-on` fields.

## Sharing configuration via imports

`runs-on` must be set in each workflow — it is not merged from imports. Other settings like `network` and `tools` can be shared:
Expand Down
143 changes: 143 additions & 0 deletions pkg/cli/codemod_safe_job_runner.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package cli

import (
"strings"

"github.com/github/gh-aw/pkg/logger"
)

var safeJobRunnerCodemodLog = logger.New("cli:codemod_safe_job_runner")

func getSafeJobRunnerCodemod() Codemod {
return Codemod{
ID: "safe-job-runner-to-runs-on",
Name: "Rename safe-outputs.jobs runner to runs-on",
Description: "Renames deprecated safe-outputs.jobs.<job>.runner fields to runs-on.",
IntroducedIn: "1.5.0",
Apply: func(content string, _ map[string]any) (string, bool, error) {
newContent, applied, err := applyFrontmatterLineTransform(content, renameSafeJobRunnerKeys)
if applied {
safeJobRunnerCodemodLog.Print("Renamed safe-job runner fields to runs-on")
}
return newContent, applied, err
},
}
}

func renameSafeJobRunnerKeys(lines []string) ([]string, bool) {
result := append([]string(nil), lines...)
modified := false

for i := range lines {
if strings.TrimSpace(lines[i]) != "safe-outputs:" {

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.

The codemod only matches a line whose trimmed content is exactly safe-outputs:, so any valid header with an inline comment is skipped and the now-removed runner key is left behind.

💡 Why this matters and how to fix it

YAML like safe-outputs: # security settings is legal and common in hand-edited workflow files. This scanner hard-requires strings.TrimSpace(lines[i]) == "safe-outputs:", so it never enters the jobs block for those files and gh aw fix silently misses the deprecated field. After this PR, those workflows stop compiling even though the migration command claimed success.

Match the key structurally instead of by exact trimmed line text, e.g. by recognizing safe-outputs: before any trailing comment, or by reusing the same key-parsing helper used by other codemods. Add a regression test with an inline comment on the safe-outputs: line.

continue
}

safeOutputsIndent := len(getIndentation(lines[i]))
childIndent := -1
for j := i + 1; j < len(lines); j++ {
trimmed := strings.TrimSpace(lines[j])
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}

indent := len(getIndentation(lines[j]))
if indent <= safeOutputsIndent {
break
}
if childIndent == -1 {
childIndent = indent
}
if indent != childIndent || trimmed != "jobs:" {
continue
}

if renameSafeJobRunnerKeysInJobsBlock(result, lines, j) {
modified = true
}
break
}
}

return result, modified
}

func renameSafeJobRunnerKeysInJobsBlock(result, lines []string, jobsLine int) bool {
jobsIndent := len(getIndentation(lines[jobsLine]))
jobIndent := -1
jobStarts := []int{}
blockEnd := jobsLine + 1

for i := jobsLine + 1; i < len(lines); i++ {
trimmed := strings.TrimSpace(lines[i])
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
blockEnd = i + 1
continue
}

indent := len(getIndentation(lines[i]))
if indent <= jobsIndent {
blockEnd = i
break
}
blockEnd = i + 1
if jobIndent == -1 {
jobIndent = indent
}
if indent == jobIndent {
jobStarts = append(jobStarts, i)
}
}

modified := false
for i, start := range jobStarts {
end := blockEnd
if i+1 < len(jobStarts) {
end = jobStarts[i+1]
}
if renameSafeJobRunnerKeyInJob(result, lines, start, end) {
modified = true
}
}
return modified
}

func renameSafeJobRunnerKeyInJob(result, lines []string, start, end int) bool {
jobIndent := len(getIndentation(lines[start]))
fieldIndent := -1
runnerLine := -1
hasRunsOn := false

for i := start + 1; i < end; i++ {
trimmed := strings.TrimSpace(lines[i])
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}

indent := len(getIndentation(lines[i]))
if indent <= jobIndent {
break
}
if fieldIndent == -1 {
fieldIndent = indent
}
if indent != fieldIndent {
continue
}
if strings.HasPrefix(trimmed, "runs-on:") {
hasRunsOn = true
}
if strings.HasPrefix(trimmed, "runner:") {
runnerLine = i
}
}

if runnerLine == -1 || hasRunsOn {
return false
}
replacement, replaced := findAndReplaceInLine(lines[runnerLine], "runner", "runs-on")
if replaced {
result[runnerLine] = replacement
}
return replaced
}
121 changes: 121 additions & 0 deletions pkg/cli/codemod_safe_job_runner_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
//go:build !integration

package cli

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestSafeJobRunnerCodemod(t *testing.T) {
codemod := getSafeJobRunnerCodemod()

t.Run("metadata", func(t *testing.T) {
assert.Equal(t, "safe-job-runner-to-runs-on", codemod.ID)
assert.Equal(t, "Rename safe-outputs.jobs runner to runs-on", codemod.Name)
assert.Equal(t, "Renames deprecated safe-outputs.jobs.<job>.runner fields to runs-on.", codemod.Description)
assert.Equal(t, "1.5.0", codemod.IntroducedIn)
require.NotNil(t, codemod.Apply)
})

tests := []struct {
name string
content string
want string
wantApplied bool
}{
{
name: "renames scalar runner",
content: `---
safe-outputs:
jobs:
notify:
runner: ubuntu-latest
steps:
- run: echo hi
---`,
want: `---
safe-outputs:
jobs:
notify:
runs-on: ubuntu-latest
steps:
- run: echo hi
---`,
wantApplied: true,
},
{
name: "preserves runner group block",
content: `---
safe-outputs:
jobs:
notify:
runner: # runner group
group: larger-runners
labels: [linux]
---`,
want: `---
safe-outputs:
jobs:
notify:
runs-on: # runner group
group: larger-runners
labels: [linux]
---`,
wantApplied: true,
},
{
name: "skips job with canonical field",
content: `---
safe-outputs:
jobs:
notify:
runner: old-runner
runs-on: ubuntu-latest
---
`,
want: `---
safe-outputs:
jobs:
notify:
runner: old-runner
runs-on: ubuntu-latest
---
`,
wantApplied: false,
},
{
name: "ignores runner outside safe jobs",
content: `---
runner: top-level
safe-outputs:
create-issue: {}
jobs:
build:
runner: custom
---
`,
want: `---
runner: top-level
safe-outputs:
create-issue: {}
jobs:
build:
runner: custom
---
`,
wantApplied: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, applied, err := codemod.Apply(tt.content, map[string]any{})
require.NoError(t, err)
assert.Equal(t, tt.wantApplied, applied)
assert.Equal(t, tt.want, result)
})
}
}
1 change: 1 addition & 0 deletions pkg/cli/fix_codemods.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ func GetAllCodemods() []Codemod {
getSafeOutputMergePRConstraintsCodemod(), // Rename deprecated merge-pull-request allowed-labels/allowed-branches
getSafeOutputAddReviewerAllowlistsCodemod(), // Rename deprecated add-reviewer reviewers/team-reviewers
getSafeOutputDispatchRepositoryKeyCodemod(), // Rename deprecated safe-outputs.dispatch_repository key
getSafeJobRunnerCodemod(), // Rename deprecated safe-outputs.jobs runner fields
getSafeInputsToMCPScriptsCodemod(), // Rename safe-inputs to mcp-scripts
getRateLimitToUserRateLimitCodemod(), // Rename rate-limit to user-rate-limit with max key migration
getEffectiveTokensToAICreditsCodemod(), // Migrate obsolete effective-token budget keys to AI credits keys
Expand Down
2 changes: 2 additions & 0 deletions pkg/cli/fix_codemods_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ func TestGetAllCodemods_ContainsExpectedCodemods(t *testing.T) {
"safe-output-merge-pr-constraints",
"safe-output-add-reviewer-allowlists",
"safe-output-dispatch-repository-key",
"safe-job-runner-to-runs-on",
"safe-inputs-to-mcp-scripts",
"rate-limit-to-user-rate-limit",
"effective-tokens-to-ai-credits",
Expand Down Expand Up @@ -228,6 +229,7 @@ func expectedCodemodOrder() []string {
"safe-output-merge-pr-constraints",
"safe-output-add-reviewer-allowlists",
"safe-output-dispatch-repository-key",
"safe-job-runner-to-runs-on",
"safe-inputs-to-mcp-scripts",
"rate-limit-to-user-rate-limit",
"effective-tokens-to-ai-credits",
Expand Down
30 changes: 7 additions & 23 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -11041,16 +11041,14 @@
"description": "Description of the safe-job (used in MCP tool registration)"
},
"runs-on": {
"description": "Runner specification for this job",
"oneOf": [
{
"type": "string"
},
"$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"],
{
"type": "array",
"items": {
"type": "string"
}
"group": "larger-runners",
"labels": ["ubuntu-latest-8-cores"]
}
]
},
Expand Down Expand Up @@ -11156,20 +11154,6 @@
"$ref": "#/$defs/githubActionsStep"
}
},
"runner": {
"description": "Runner specification for this job (alias for runs-on)",
"oneOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
},
"agent-output": {
"type": "string",
"description": "Agent output field to use as input for this safe job (alias for output)"
Expand Down
3 changes: 3 additions & 0 deletions pkg/workflow/compiler_custom_job_properties.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ func (c *Compiler) extractCustomJobRunsOn(job *Job, jobName string, configMap ma
if !hasRunsOn {
return nil
}
if err := validateRunsOnValue(runsOn); err != nil {
return fmt.Errorf("runs-on field for job '%s' is invalid: %w", jobName, err)
}
if runsOnStr, ok := runsOn.(string); ok {
job.RunsOn = "runs-on: " + runsOnStr
return nil
Expand Down
Loading
Loading