from __future__ import annotations
import glob
import re
import sys
HOSTED_LABELS = frozenset({"ubuntu-latest", "macos-latest", "windows-latest"})
RESOLVABLE_EXPR = re.compile(r"^\$\{\{\s*matrix\.(os|runner)\s*\}\}$")
KEY = re.compile(
r"^(?P<indent>\s*)(?:-\s+)?(?P<q>[\"']?)(?P<key>runs-on|os|runner|matrix|include|uses)(?P=q)"
r"\s*:\s*(?P<value>.*?)\s*$"
)
SEQ_ITEM = re.compile(r"^(?P<indent>\s*)-\s*(?P<value>.+?)\s*$")
MAP_ITEM = re.compile(r"^(?P<indent>\s*)(?P<key>group|labels):\s*(?P<value>.*?)\s*$")
ON_BLOCK = re.compile(r"^[\"']?on[\"']?\s*:\s*(?:#.*)?$")
FLOW_START = re.compile(r"^\s*(?:-\s+)?(?:[\"']?[\w.-]+[\"']?\s*:\s*)?(?:[&!]\S+\s+)?\{")
FLOW_KEY = re.compile(
r"[{,]\s*[\"']?(?:runs-on|os|runner)[\"']?\s*:"
r"|[{,]\s*[\"']?uses[\"']?\s*:\s*[^,}\s]*\.github/workflows/"
)
def _strip_comment(text: str) -> str:
return re.sub(r"(?:^|\s)#.*$", "", text).strip()
def _unquote(token: str) -> str:
token = token.strip()
if len(token) >= 2 and token[0] in "\"'" and token[-1] == token[0]:
token = token[1:-1]
return token.strip()
def _labels_from_inline(value: str) -> list[str]:
value = _strip_comment(value)
if value.startswith("[") and value.endswith("]"):
return [_unquote(t) for t in value[1:-1].split(",") if _unquote(t)]
token = _unquote(value)
return [token] if token else []
def _bad_label(label: str) -> bool:
return label not in HOSTED_LABELS
def _indent(line: str) -> int:
return len(line) - len(line.lstrip())
def find_violations(text: str) -> list[tuple[int, str, str]]:
lines = text.splitlines()
out: list[tuple[int, str, str]] = []
i = 0
while i < len(lines):
if ON_BLOCK.match(lines[i]):
i += 1
while i < len(lines) and (not lines[i].strip() or lines[i][0] in " \t#"):
i += 1
continue
m = KEY.match(lines[i])
if not m:
stripped = _strip_comment(lines[i])
if FLOW_START.match(stripped) and FLOW_KEY.search(stripped):
out.append((i + 1, "flow mapping (unscannable)", stripped))
i += 1
continue
key, raw = m.group("key"), m.group("value")
value = _strip_comment(raw)
if key == "uses":
v = _unquote(value)
if not v or v[0] in ">|*":
out.append((i + 1, "uses (unverifiable form)", v))
elif ".github/workflows/" in v and not v.startswith("./"):
out.append((i + 1, "uses (remote reusable workflow)", v))
i += 1
continue
if key in ("matrix", "include"):
if value:
out.append((i + 1, f"{key} (not a static block)", value))
i += 1
continue
if value:
if not (key == "runs-on" and RESOLVABLE_EXPR.match(_unquote(value))):
for label in _labels_from_inline(value):
if _bad_label(label):
out.append((i + 1, key, label))
i += 1
continue
key_indent = _indent(m.group(0))
on_dash = m.group(0).lstrip().startswith("-")
j = i + 1
while j < len(lines):
if not lines[j].strip() or lines[j].lstrip().startswith("#"):
j += 1
continue
ind = _indent(lines[j])
if ind < key_indent or (ind == key_indent and (on_dash or not SEQ_ITEM.match(lines[j]))):
break
seq = SEQ_ITEM.match(lines[j])
if seq:
label = _unquote(_strip_comment(seq.group("value")))
if label and _bad_label(label):
out.append((j + 1, key, label))
j += 1
continue
mp = MAP_ITEM.match(lines[j])
if mp and key == "runs-on":
sub, sval = mp.group("key"), _strip_comment(mp.group("value"))
if sub == "group":
out.append((j + 1, "runs-on.group", _unquote(sval)))
elif sval: for label in _labels_from_inline(sval):
if _bad_label(label):
out.append((j + 1, "runs-on.labels", label))
else:
out.append((j + 1, f"{key} (unrecognised block form)", _strip_comment(lines[j])))
j += 1
i = j
return out
def main() -> int:
files = sorted(
glob.glob(".github/workflows/*.yml") + glob.glob(".github/workflows/*.yaml")
)
if not files:
print("::error::no workflow files found under .github/workflows/", file=sys.stderr)
return 1
failed = False
for path in files:
try:
with open(path, encoding="utf-8") as fh:
text = fh.read()
except (OSError, UnicodeDecodeError) as exc:
print(f"::error file={path}::cannot read workflow file: {exc}", file=sys.stderr)
failed = True
continue
for lineno, what, value in find_violations(text):
print(
f"::error file={path},line={lineno}::{what}: '{value}' is not provably "
f"GitHub-hosted. Every job must run on an allow-listed hosted label "
f"(ubuntu-latest/macos-latest/windows-latest) as a "
f"plain scalar, `[a, b]`, block list, or `${{{{ matrix.os }}}}` over a "
f"static block matrix; self-hosted labels, runner groups, other ${{{{ }}}} "
f"expressions, flow mappings, generated matrices and remote reusable "
f"workflows fail closed (see the docstring of "
f".github/scripts/assert_hosted_runners.py)."
)
failed = True
if failed:
return 1
print(f"OK: all runner targets across {len(files)} workflow file(s) are GitHub-hosted.")
return 0
_CASES: list[tuple[str, list[str]]] = [
(" runs-on: cachekit\n", ["cachekit"]),
(" runs-on: cachekit-lean\n", ["cachekit-lean"]),
(" runs-on: self-hosted\n", ["self-hosted"]),
(" runs-on: cachekit-turbo\n", ["cachekit-turbo"]),
(" runs-on: ubuntu-private\n", ["ubuntu-private"]),
(" runs-on: windows-cachekit\n", ["windows-cachekit"]),
(" runs-on: ubuntu-lates\n", ["ubuntu-lates"]),
(" runs-on: ubuntu-24.04\n", ["ubuntu-24.04"]),
(" runs-on: ubuntu-24.04-arm\n", ["ubuntu-24.04-arm"]),
(" runs-on: [self-hosted, linux, x64]\n", ["self-hosted", "linux", "x64"]),
(" runs-on:\n - self-hosted\n - linux\n", ["self-hosted", "linux"]),
(" runner: cachekit\n", ["cachekit"]),
(' os: "self-hosted" # quoted + comment\n', ["self-hosted"]),
(" runs-on:\n group: private-runners\n", ["private-runners"]),
(
" runs-on:\n group: private-runners\n labels: [self-hosted]\n",
["private-runners", "self-hosted"],
),
(" runs-on:\n group: ubuntu-big\n", ["ubuntu-big"]),
(" include:\n - os: cachekit\n rust: stable\n", ["cachekit"]),
(" - runner: self-hosted\n", ["self-hosted"]),
(" runs-on: ${{ matrix.platform }}\n", ["${{ matrix.platform }}"]),
(" runs-on: ${{ vars.RUNNER }}\n", ["${{ vars.RUNNER }}"]),
(" runs-on: ${{ env.TARGET }}\n", ["${{ env.TARGET }}"]),
(" os: ${{ vars.TARGET }}\n", ["${{ vars.TARGET }}"]),
(
" runs-on: ${{ matrix.os }}\n strategy:\n matrix:\n"
" os: ${{ fromJSON(inputs.oses) }}\n",
["${{ fromJSON(inputs.oses) }}"],
),
(" runs-on:\n - ${{ vars.RUNNER }}\n", ["${{ vars.RUNNER }}"]),
(" runs-on:\n group: ${{ vars.GROUP }}\n", ["${{ vars.GROUP }}"]),
(" runs-on: [${{ matrix.os }}]\n", ["${{ matrix.os }}"]),
(
" runs-on: ${{ matrix.os }}\n strategy:\n"
" matrix: ${{ fromJSON(needs.gen.outputs.matrix) }}\n",
["${{ fromJSON(needs.gen.outputs.matrix) }}"],
),
(" include: ${{ fromJSON(inputs.include) }}\n", ["${{ fromJSON(inputs.include) }}"]),
(" matrix: {os: [cachekit]}\n", ["{os: [cachekit]}"]),
(
" include:\n - { os: cachekit, rust: stable }\n",
["- { os: cachekit, rust: stable }"],
),
(
" strategy: { fail-fast: false, matrix: { os: [ubuntu-latest, cachekit] } }\n",
["strategy: { fail-fast: false, matrix: { os: [ubuntu-latest, cachekit] } }"],
),
("jobs: {build: {runs-on: cachekit}}\n", ["jobs: {build: {runs-on: cachekit}}"]),
(
"jobs: {build: {runs-on: {group: private-runners}}}\n",
["jobs: {build: {runs-on: {group: private-runners}}}"],
),
("jobs: {build: {runs-on: *shared}}\n", ["jobs: {build: {runs-on: *shared}}"]),
(
"jobs: {ci: {uses: org/repo/.github/workflows/ci.yml@main}}\n",
["jobs: {ci: {uses: org/repo/.github/workflows/ci.yml@main}}"],
),
(' - { "os": cachekit, rust: stable }\n', ['- { "os": cachekit, rust: stable }']),
(" - &x { os: cachekit }\n", ["- &x { os: cachekit }"]), (' "runs-on": cachekit\n', ["cachekit"]),
(" runs-on : cachekit\n", ["cachekit"]),
(
" runs-on: ${{ matrix.os }}\n strategy:\n matrix:\n os : [cachekit]\n",
["cachekit"],
),
(" runs-on:\n - self-hosted\n", ["self-hosted"]),
(
" runs-on: ${{ matrix.os }}\n strategy:\n matrix:\n os:\n - cachekit\n",
["cachekit"],
),
(" runs-on:\n# - ubuntu-latest\n - self-hosted\n", ["self-hosted"]),
(" runs-on:\n cachekit\n", ["cachekit"]),
(" runs-on:\n [self-hosted, linux]\n", ["[self-hosted, linux]"]),
(" runs-on:\n labels:\n [self-hosted]\n", ["[self-hosted]"]),
(
" uses: cachekit-io/tooling/.github/workflows/ci.yml@main\n",
["cachekit-io/tooling/.github/workflows/ci.yml@main"],
),
(" uses: >-\n org/repo/.github/workflows/ci.yml@main\n", [">-"]),
(" uses:\n org/repo/.github/workflows/ci.yml@main\n", [""]),
(" uses: *remote\n", ["*remote"]),
(
"on:\n workflow_dispatch:\n inputs:\n os:\n type: choice\n"
" options: [ubuntu-latest]\njobs:\n b:\n runs-on: cachekit\n",
["cachekit"],
),
(" runs-on: ubuntu-latest\n", []),
(" runs-on: macos-latest\n", []),
(" runs-on: windows-latest\n", []),
(" runs-on: [ubuntu-latest]\n", []),
(" runs-on: ${{ matrix.os }}\n", []),
(" runs-on: ${{ matrix.runner }}\n", []),
(' runs-on: "${{ matrix.os }}"\n', []), (" os:\n - ubuntu-latest\n - macos-latest\n", []),
(" runs-on:\n - ubuntu-latest\n", []), (" runs-on:\n labels:\n - ubuntu-latest\n", []), (" runs-on: # pick per matrix\n - ubuntu-latest\n", []),
(" # runs-on: cachekit was the old value\n runs-on: ubuntu-latest\n", []),
(
" runs-on: ${{ matrix.os }}\n"
" strategy:\n matrix:\n include:\n"
" - os: ubuntu-latest\n - os: macos-latest\n",
[],
),
(" uses: ./.github/workflows/ci.yml\n", []),
(' uses: "./.github/workflows/ci.yml"\n', []),
(" - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6\n", []),
(
'"on":\n workflow_dispatch:\n inputs:\n runner:\n description: x\n'
" required: false\n",
[],
),
(" script: |\n const payload = { os: process.platform };\n", []),
(" core.setOutput('meta', JSON.stringify({ runner: process.env.RUNNER_NAME }));\n", []),
(" - run: jq -n --arg os \"$RUNNER_OS\" '{os: $os, runner: .r}'\n", []),
]
def _selftest() -> int:
failed = False
for text, expected in _CASES:
got = [val for _, _, val in find_violations(text)]
if got != expected:
print(f"::error::selftest {text!r}: expected {expected}, got {got}", file=sys.stderr)
failed = True
if failed:
return 1
print(f"selftest OK ({len(_CASES)} cases)")
return 0
if __name__ == "__main__":
if "--selftest" in sys.argv[1:]:
sys.exit(_selftest())
sys.exit(main())