fix(actions): reject jobs without runs-on (#39480)

Align job and `runs-on` validation with github.com, as implemented by
the parser in https://github.com/actions/runner. A job without `runs-on`
could be claimed by any runner, so a job meant for a container could run
on the host.

- Jobs without `runs-on` fail with `Required property is missing:
runs-on`, called workflows included
- Unknown job keys and callers (`uses:`) mixed with steps-only keys like
`runs-on` are rejected
- Empty, null and nested `runs-on` values are rejected
- A `runs-on` evaluating to such a value fails only that job
- Called workflows are validated at run creation, an invalid one fails
the run as an invalid workflow file
- Zero labels (`runs-on: []` or `{}`) never match a runner, including
jobs queued before upgrading

<img width="960" alt="image"
src="https://github.com/user-attachments/assets/e746ce5a-b711-4e8b-aab8-81336ff53d86"
/>

**Behavior Change:** workflows that omit `runs-on`, use unknown job keys
or mix `uses` with `runs-on` stop running until fixed.

---------

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
Zettat123
2026-09-30 21:42:07 -06:00
committed by GitHub
parent f81a2ab69a
commit a71c5c94c5
23 changed files with 410 additions and 98 deletions
+12 -7
View File
@@ -30,18 +30,23 @@ jobs:
func TestReadWorkflowEventsStaticErrors(t *testing.T) {
for content, static := range map[string]bool{
"on: push\njobs: {}": true,
"on: push\njobs: {test: {needs: absent}}": true,
"on: push\njobs: {one: {needs: two}, two: {needs: one}}": true,
"on: push\njobs: {test: {strategy: {matrix: {os: []}}}}": true,
"on: push\nrun-name: ${{ secrets.TOKEN }}\njobs: {test: {}}": true,
"on: push\nrun-name: ${{ fromJSON(inputs.x) }}\njobs: {test: {steps: [{run: echo}]}}": false,
"on: push\njobs: {}": true,
"on: push\njobs: {test: {runs-on: x, needs: absent}}": true,
"on: push\njobs: {one: {runs-on: x, needs: two}, two: {runs-on: x, needs: one}}": true,
"on: push\njobs: {test: {runs-on: x, strategy: {matrix: {os: []}}}}": true,
"on: push\nrun-name: ${{ secrets.TOKEN }}\njobs: {test: {runs-on: x}}": true,
"on: push\njobs: {test: {steps: [{run: echo}]}}": true,
"on: push\nrun-name: ${{ fromJSON(inputs.x) }}\njobs: {test: {runs-on: x, steps: [{run: echo}]}}": false,
} {
_, gotStatic, err := readWorkflowEvents([]byte(content))
require.Error(t, err, content)
assert.Equal(t, static, gotStatic, content)
}
for _, content := range []string{"on: push\njobs: {test: {steps: [{run: echo}]}}", "on: push\nrun-name: ${{ github.ref }}\njobs: {test: {}}"} {
for _, content := range []string{
"on: push\njobs: {test: {runs-on: x, steps: [{run: echo}]}}",
"on: push\nrun-name: ${{ github.ref }}\njobs: {test: {runs-on: x}}",
"on: push\njobs: {call: {uses: ./.gitea/workflows/called.yml}}",
} {
_, _, err := readWorkflowEvents([]byte(content))
assert.NoError(t, err, content)
}