workflow_runners
Only the GPU publish workflow may run on the self-hosted runner. MIP-0065 §5.2.
A public repo's self-hosted runner executes whatever a pull request asks it to, so this fails when
any runs-on: or reusable-workflow runner: outside marola-sea-publish.yml names self-hosted
or is an expression (a variable or matrix entry can be pointed at the desktop), or when that
workflow's on: gains a trigger a pull request can reach: pull_request(_target) or workflow_call.
scripts/workflow_runners.py [.github/workflows]
scripts/workflow_runners.py --self-test
1#!/usr/bin/env python3 2"""Only the GPU publish workflow may run on the self-hosted runner. MIP-0065 §5.2. 3 4A public repo's self-hosted runner executes whatever a pull request asks it to, so this fails when 5any `runs-on:` or reusable-workflow `runner:` outside marola-sea-publish.yml names `self-hosted` 6or is an expression (a variable or matrix entry can be pointed at the desktop), or when that 7workflow's `on:` gains a trigger a pull request can reach: pull_request(_target) or workflow_call. 8 9 scripts/workflow_runners.py [.github/workflows] 10 scripts/workflow_runners.py --self-test 11""" 12 13from __future__ import annotations 14 15import argparse 16import re 17import sys 18from pathlib import Path 19 20GPU_WORKFLOW = "marola-sea-publish.yml" 21RUNNER_KEY = re.compile(r"^(\s*)(?:-\s+)?(runs-on|runner):\s*(.*)$") 22FORBIDDEN = re.compile(r"self-hosted|\$\{\{") 23PR_TRIGGER = re.compile(r"\b(pull_request(?:_target)?|workflow_call)\b") 24# A key or list item, so an input's `description:` that mentions pull_request is not a trigger. 25PR_TRIGGER_KEY = re.compile( 26 r"^\s+(?:-\s+)?['\"]?(pull_request(?:_target)?|workflow_call)['\"]?\s*(:|$)" 27) 28 29 30def _strip_comment(value: str) -> str: 31 return value.split(" #", 1)[0].strip() 32 33 34def runner_findings(name: str, text: str) -> list[str]: 35 lines = text.splitlines() 36 out = [] 37 for i, line in enumerate(lines): 38 m = RUNNER_KEY.match(line) 39 if not m: 40 continue 41 indent, value = len(m.group(1)), _strip_comment(m.group(3)) 42 # Block form (a list, or `group:`/`labels:`): every line indented under the key counts. 43 j = i + 1 44 while not m.group(3).strip() and j < len(lines): 45 nxt = lines[j] 46 if nxt.strip() and (len(nxt) - len(nxt.lstrip())) <= indent: 47 break 48 value += " " + _strip_comment(nxt) 49 j += 1 50 hit = FORBIDDEN.search(value) 51 if hit: 52 what = "an expression" if hit.group(0) == "${{" else hit.group(0) 53 out.append(f"{name}:{i + 1} targets {what} — only {GPU_WORKFLOW} may be self-hosted") 54 return out 55 56 57def trigger_findings(name: str, text: str) -> list[str]: 58 out = [] 59 in_on = False 60 for i, line in enumerate(text.splitlines()): 61 top = re.match(r"^['\"]?(\w+)['\"]?:\s*(.*)$", line) 62 if top: 63 in_on = top.group(1) in ("on", "true") # YAML 1.1 reads a bare `on` as true 64 hit = in_on and PR_TRIGGER.search(_strip_comment(top.group(2))) 65 else: 66 hit = in_on and PR_TRIGGER_KEY.match(line) 67 if hit: 68 out.append(f"{name}:{i + 1} adds {hit.group(1)} to the self-hosted workflow") 69 return out 70 71 72def scan(root: Path) -> list[str]: 73 findings = [] 74 for f in sorted([*root.glob("*.yml"), *root.glob("*.yaml")]): 75 text = f.read_text(encoding="utf-8") 76 if f.name == GPU_WORKFLOW: 77 findings += trigger_findings(f.name, text) 78 else: 79 findings += runner_findings(f.name, text) 80 return findings 81 82 83def check(root: Path) -> int: 84 if not root.is_dir(): 85 print(f"workflow_runners: no such directory: {root}", file=sys.stderr) 86 return 2 87 findings = scan(root) 88 for line in findings: 89 print(f"workflow_runners: {line}", file=sys.stderr) 90 if findings: 91 return 1 92 print(f"workflow_runners: only {GPU_WORKFLOW} is self-hosted") 93 return 0 94 95 96CLEAN_CI = """name: CI 97on: 98 push: 99 pull_request: 100jobs: 101 changes: 102 # the desktop runner is self-hosted; this job is not 103 runs-on: ubuntu-latest 104 steps: [] 105""" 106GPU = """name: marola-sea publish 107on: 108 workflow_dispatch: 109 inputs: 110 preset: 111 description: "not a pull_request" 112jobs: 113 publish: 114 runs-on: [self-hosted, marola-sea] 115""" 116 117 118def self_test() -> int: 119 import tempfile 120 121 fails = 0 122 123 def ok(got, want, label): 124 nonlocal fails 125 if got == want: 126 print(f" ok {label}") 127 else: 128 fails += 1 129 print(f" FAIL {label} — got {got!r}, want {want!r}") 130 131 def tree(ci: str, gpu: str = GPU) -> list[str]: 132 with tempfile.TemporaryDirectory() as tmp: 133 (Path(tmp) / "ci.yml").write_text(ci, encoding="utf-8") 134 (Path(tmp) / GPU_WORKFLOW).write_text(gpu, encoding="utf-8") 135 return scan(Path(tmp)) 136 137 ok(tree(CLEAN_CI), [], "a clean tree has no findings, comments and the GPU job included") 138 stray = tree(CLEAN_CI.replace("ubuntu-latest", "self-hosted")) 139 ok(len(stray), 1, "a stray self-hosted in ci.yml is caught") 140 ok(stray[0].startswith("ci.yml:8 targets self-hosted"), True, "and named by file and line") 141 ok( 142 len(tree(CLEAN_CI.replace("ubuntu-latest", "${{ vars.CI_RUNNER || 'ubuntu-latest' }}"))), 143 1, 144 "a CI_RUNNER fallback is caught even when it falls back to a hosted label", 145 ) 146 ok( 147 len(tree(CLEAN_CI.replace("ubuntu-latest", "${{ matrix.os }}"))), 148 1, 149 "so is any other expression, a matrix entry included", 150 ) 151 ok( 152 len(tree(CLEAN_CI.replace("ubuntu-latest", "\n - linux\n\n - self-hosted"))), 153 1, 154 "the block-list form is read to its end, past a blank line", 155 ) 156 ok( 157 len( 158 tree( 159 CLEAN_CI.replace( 160 "ubuntu-latest", "\n group: desktop\n labels: self-hosted" 161 ) 162 ) 163 ), 164 1, 165 "and so is the group/labels form", 166 ) 167 reusable = "jobs:\n ping:\n uses: o/r/.github/workflows/p.yml@main\n with:\n runner: self-hosted\n" 168 ok(len(tree(reusable)), 1, "a reusable workflow's runner: input counts as runs-on") 169 ok( 170 len( 171 tree( 172 CLEAN_CI, 173 GPU.replace(" workflow_dispatch:", " pull_request:\n workflow_dispatch:"), 174 ) 175 ), 176 1, 177 "pull_request added to the GPU workflow is caught", 178 ) 179 ok( 180 len( 181 tree( 182 CLEAN_CI, 183 GPU.replace( 184 "on:\n workflow_dispatch:", "on: [workflow_dispatch, pull_request_target]\nx:" 185 ), 186 ) 187 ), 188 1, 189 "and so is the inline form, pull_request_target included", 190 ) 191 ok( 192 len( 193 tree( 194 CLEAN_CI, 195 GPU.replace(" workflow_dispatch:", " workflow_call:\n workflow_dispatch:"), 196 ) 197 ), 198 1, 199 "workflow_call on the GPU workflow is caught: a PR workflow could `uses:` it", 200 ) 201 202 if fails: 203 print(f"workflow_runners self-test: {fails} failure(s)", file=sys.stderr) 204 return 1 205 print("workflow_runners self-test: ok") 206 return 0 207 208 209def main(argv: list[str] | None = None) -> int: 210 ap = argparse.ArgumentParser( 211 description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter 212 ) 213 ap.add_argument("root", nargs="?", type=Path, default=Path(".github/workflows")) 214 ap.add_argument("--self-test", action="store_true") 215 args = ap.parse_args(argv) 216 return self_test() if args.self_test else check(args.root) 217 218 219if __name__ == "__main__": 220 sys.exit(main())
GPU_WORKFLOW =
'marola-sea-publish.yml'
RUNNER_KEY =
re.compile('^(\\s*)(?:-\\s+)?(runs-on|runner):\\s*(.*)$')
FORBIDDEN =
re.compile('self-hosted|\\$\\{\\{')
PR_TRIGGER =
re.compile('\\b(pull_request(?:_target)?|workflow_call)\\b')
PR_TRIGGER_KEY =
re.compile('^\\s+(?:-\\s+)?[\'\\"]?(pull_request(?:_target)?|workflow_call)[\'\\"]?\\s*(:|$)')
def
runner_findings(name: str, text: str) -> list[str]:
35def runner_findings(name: str, text: str) -> list[str]: 36 lines = text.splitlines() 37 out = [] 38 for i, line in enumerate(lines): 39 m = RUNNER_KEY.match(line) 40 if not m: 41 continue 42 indent, value = len(m.group(1)), _strip_comment(m.group(3)) 43 # Block form (a list, or `group:`/`labels:`): every line indented under the key counts. 44 j = i + 1 45 while not m.group(3).strip() and j < len(lines): 46 nxt = lines[j] 47 if nxt.strip() and (len(nxt) - len(nxt.lstrip())) <= indent: 48 break 49 value += " " + _strip_comment(nxt) 50 j += 1 51 hit = FORBIDDEN.search(value) 52 if hit: 53 what = "an expression" if hit.group(0) == "${{" else hit.group(0) 54 out.append(f"{name}:{i + 1} targets {what} — only {GPU_WORKFLOW} may be self-hosted") 55 return out
def
trigger_findings(name: str, text: str) -> list[str]:
58def trigger_findings(name: str, text: str) -> list[str]: 59 out = [] 60 in_on = False 61 for i, line in enumerate(text.splitlines()): 62 top = re.match(r"^['\"]?(\w+)['\"]?:\s*(.*)$", line) 63 if top: 64 in_on = top.group(1) in ("on", "true") # YAML 1.1 reads a bare `on` as true 65 hit = in_on and PR_TRIGGER.search(_strip_comment(top.group(2))) 66 else: 67 hit = in_on and PR_TRIGGER_KEY.match(line) 68 if hit: 69 out.append(f"{name}:{i + 1} adds {hit.group(1)} to the self-hosted workflow") 70 return out
def
scan(root: pathlib.Path) -> list[str]:
73def scan(root: Path) -> list[str]: 74 findings = [] 75 for f in sorted([*root.glob("*.yml"), *root.glob("*.yaml")]): 76 text = f.read_text(encoding="utf-8") 77 if f.name == GPU_WORKFLOW: 78 findings += trigger_findings(f.name, text) 79 else: 80 findings += runner_findings(f.name, text) 81 return findings
def
check(root: pathlib.Path) -> int:
84def check(root: Path) -> int: 85 if not root.is_dir(): 86 print(f"workflow_runners: no such directory: {root}", file=sys.stderr) 87 return 2 88 findings = scan(root) 89 for line in findings: 90 print(f"workflow_runners: {line}", file=sys.stderr) 91 if findings: 92 return 1 93 print(f"workflow_runners: only {GPU_WORKFLOW} is self-hosted") 94 return 0
CLEAN_CI =
'name: CI\non:\n push:\n pull_request:\njobs:\n changes:\n # the desktop runner is self-hosted; this job is not\n runs-on: ubuntu-latest\n steps: []\n'
GPU =
'name: marola-sea publish\non:\n workflow_dispatch:\n inputs:\n preset:\n description: "not a pull_request"\njobs:\n publish:\n runs-on: [self-hosted, marola-sea]\n'
def
self_test() -> int:
119def self_test() -> int: 120 import tempfile 121 122 fails = 0 123 124 def ok(got, want, label): 125 nonlocal fails 126 if got == want: 127 print(f" ok {label}") 128 else: 129 fails += 1 130 print(f" FAIL {label} — got {got!r}, want {want!r}") 131 132 def tree(ci: str, gpu: str = GPU) -> list[str]: 133 with tempfile.TemporaryDirectory() as tmp: 134 (Path(tmp) / "ci.yml").write_text(ci, encoding="utf-8") 135 (Path(tmp) / GPU_WORKFLOW).write_text(gpu, encoding="utf-8") 136 return scan(Path(tmp)) 137 138 ok(tree(CLEAN_CI), [], "a clean tree has no findings, comments and the GPU job included") 139 stray = tree(CLEAN_CI.replace("ubuntu-latest", "self-hosted")) 140 ok(len(stray), 1, "a stray self-hosted in ci.yml is caught") 141 ok(stray[0].startswith("ci.yml:8 targets self-hosted"), True, "and named by file and line") 142 ok( 143 len(tree(CLEAN_CI.replace("ubuntu-latest", "${{ vars.CI_RUNNER || 'ubuntu-latest' }}"))), 144 1, 145 "a CI_RUNNER fallback is caught even when it falls back to a hosted label", 146 ) 147 ok( 148 len(tree(CLEAN_CI.replace("ubuntu-latest", "${{ matrix.os }}"))), 149 1, 150 "so is any other expression, a matrix entry included", 151 ) 152 ok( 153 len(tree(CLEAN_CI.replace("ubuntu-latest", "\n - linux\n\n - self-hosted"))), 154 1, 155 "the block-list form is read to its end, past a blank line", 156 ) 157 ok( 158 len( 159 tree( 160 CLEAN_CI.replace( 161 "ubuntu-latest", "\n group: desktop\n labels: self-hosted" 162 ) 163 ) 164 ), 165 1, 166 "and so is the group/labels form", 167 ) 168 reusable = "jobs:\n ping:\n uses: o/r/.github/workflows/p.yml@main\n with:\n runner: self-hosted\n" 169 ok(len(tree(reusable)), 1, "a reusable workflow's runner: input counts as runs-on") 170 ok( 171 len( 172 tree( 173 CLEAN_CI, 174 GPU.replace(" workflow_dispatch:", " pull_request:\n workflow_dispatch:"), 175 ) 176 ), 177 1, 178 "pull_request added to the GPU workflow is caught", 179 ) 180 ok( 181 len( 182 tree( 183 CLEAN_CI, 184 GPU.replace( 185 "on:\n workflow_dispatch:", "on: [workflow_dispatch, pull_request_target]\nx:" 186 ), 187 ) 188 ), 189 1, 190 "and so is the inline form, pull_request_target included", 191 ) 192 ok( 193 len( 194 tree( 195 CLEAN_CI, 196 GPU.replace(" workflow_dispatch:", " workflow_call:\n workflow_dispatch:"), 197 ) 198 ), 199 1, 200 "workflow_call on the GPU workflow is caught: a PR workflow could `uses:` it", 201 ) 202 203 if fails: 204 print(f"workflow_runners self-test: {fails} failure(s)", file=sys.stderr) 205 return 1 206 print("workflow_runners self-test: ok") 207 return 0
def
main(argv: list[str] | None = None) -> int:
210def main(argv: list[str] | None = None) -> int: 211 ap = argparse.ArgumentParser( 212 description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter 213 ) 214 ap.add_argument("root", nargs="?", type=Path, default=Path(".github/workflows")) 215 ap.add_argument("--self-test", action="store_true") 216 args = ap.parse_args(argv) 217 return self_test() if args.self_test else check(args.root)