Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
143 changes: 143 additions & 0 deletions .github/scripts/check-runner-labels.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"""Keep workflow runner selection on hosted pools.

This repository has no self-hosted runners. Pull requests from forks can propose
workflow changes, and a workflow that selects a private runner would put
untrusted code on hardware that holds credentials and reaches the internal
network. Every `runs-on` value must therefore name a Blacksmith pool
(`blacksmith-*`) or a GitHub-hosted image (`ubuntu-*`, `macos-*`, `windows-*`).

Two modes:

check-runner-labels.py
Scan `.github/workflows/*.yml` and fail on `self-hosted` or a
private-runner label (`linux-runner`, `windows-runner`,
`macos-runner`, `bench-*`). Comments are ignored.

check-runner-labels.py --runner LABEL [--verify-runner LABEL]
Validate labels passed to a reusable workflow, where the static scan
cannot see through `${{ inputs.runner }}`.
"""

import argparse
import os
import re
import sys
from pathlib import Path

WORKFLOWS_DIR = Path(__file__).resolve().parent.parent / "workflows"

# A label a hosted pool provides: Blacksmith's per-capacity labels and GitHub's
# own images. `[a-z0-9.-]*` covers suffixes such as `ubuntu-24.04-arm`.
HOSTED_LABEL = re.compile(r"^(?:blacksmith|ubuntu|macos|windows)-[a-z0-9][a-z0-9.-]*$")

# Runner labels that name hardware this account owns. `-runner` is a stronger
# signal than `-hosted`, so a label such as `linux-runner` is private by
# construction; `self-hosted` is GitHub's own selector.
PRIVATE_LABEL_HINT = ("self-hosted", "-runner")

# What the static scan looks for in workflow text: GitHub's self-hosted
# selector, the private labels this repository used to have, and the `bench-*`
# capacity pool. `android-emulator-runner` and other action names do not match.
FORBIDDEN_REFERENCE = re.compile(
r"self-hosted|\b(?:linux|windows|macos)-runner\b|\bbench-[a-z0-9][a-z0-9.-]*"
)


def strip_comment(line):
"""Drop a trailing YAML comment without touching quoted `#` characters."""
quote = None
for index, char in enumerate(line):
if quote:
if char == quote:
quote = None
elif char in ("'", '"'):
quote = char
elif char == "#" and (index == 0 or line[index - 1] in " \t"):
return line[:index]
return line


def scan_workflows(workflows_dir=WORKFLOWS_DIR):
"""Return one message per workflow line that references a private runner."""
problems = []
paths = sorted(workflows_dir.glob("*.yml")) + sorted(workflows_dir.glob("*.yaml"))
for path in paths:
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
code = strip_comment(line)
match = FORBIDDEN_REFERENCE.search(code)
if match:
problems.append(
f"{path.name}:{number}: references private runner {match.group(0)!r}: "
f"{code.strip()}"
)
return problems


def check_label(name, label, required):
"""Return a message when `label` is empty-but-required or not a hosted pool."""
if not label:
if required:
return f"inputs.{name} is empty; this workflow needs a hosted runner label"
return None
if any(hint in label for hint in PRIVATE_LABEL_HINT) or not HOSTED_LABEL.match(label):
return (
f"inputs.{name}={label!r} is not a hosted runner label; "
"use blacksmith-*, ubuntu-*, macos-* or windows-*"
)
return None


def check_labels(runner, verify_runner):
"""Validate the runner inputs of a reusable workflow."""
problems = [
message
for message in (
check_label("runner", runner, required=True),
check_label("verify_runner", verify_runner, required=False),
)
if message
]
return problems


def report(message):
prefix = "::error::" if os.environ.get("GITHUB_ACTIONS") else "error: "
print(f"{prefix}{message}", file=sys.stderr)


def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument(
"--runner",
help="runner input of a reusable workflow; validates labels instead of scanning",
)
parser.add_argument(
"--verify-runner",
help="optional verify_runner input; empty means the workflow has no verify job",
)
parser.add_argument(
"--workflows-dir",
type=Path,
default=WORKFLOWS_DIR,
help=f"directory to scan (default: {WORKFLOWS_DIR})",
)
args = parser.parse_args(argv)

if args.runner is None and args.verify_runner is None:
problems = scan_workflows(args.workflows_dir)
checked = "workflow runner references"
else:
# A caller may pass neither flag through the shell, so both are checked.
problems = check_labels(args.runner or "", args.verify_runner or "")
checked = "runner inputs"

for problem in problems:
report(problem)
if problems:
return 1
print(f"All {checked} name hosted runner pools.")
return 0


if __name__ == "__main__":
sys.exit(main())
109 changes: 109 additions & 0 deletions .github/scripts/check-runner-labels.test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import importlib.util
import tempfile
import unittest
from pathlib import Path

spec = importlib.util.spec_from_file_location(
"check_runner_labels", Path(__file__).with_name("check-runner-labels.py")
)
labels = importlib.util.module_from_spec(spec)
spec.loader.exec_module(labels)


class ScanWorkflowsTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.workflows = Path(self.temp.name)

def scan(self, filename, text):
(self.workflows / filename).write_text(text, encoding="utf-8")
return labels.scan_workflows(self.workflows)

def test_accepts_hosted_pools(self):
self.assertEqual(self.scan("build.yml", """
jobs:
build:
runs-on: blacksmith-8vcpu-ubuntu-2404
verify:
runs-on: windows-11-arm
lint:
runs-on: ubuntu-24.04-arm
mac:
runs-on: macos-26
"""), [])

def test_rejects_self_hosted(self):
problems = self.scan("build.yml", " runs-on: [self-hosted, linux]\n")
self.assertEqual(len(problems), 1)
self.assertIn("build.yml:1", problems[0])
self.assertIn("self-hosted", problems[0])

def test_rejects_private_runner_labels(self):
for label in ("linux-runner", "windows-runner", "macos-runner", "bench-32vcpu"):
with self.subTest(label=label):
self.workflows.joinpath("build.yml").unlink(missing_ok=True)
problems = self.scan("build.yml", f" runs-on: {label}\n")
self.assertEqual(len(problems), 1, problems)

def test_rejects_matrix_values(self):
problems = self.scan("release.yml", """
strategy:
matrix:
runner: [blacksmith-8vcpu-ubuntu-2404, bench-arm64]
runs-on: ${{ matrix.runner }}
""")
self.assertEqual(len(problems), 1)
self.assertIn("bench-arm64", problems[0])

def test_ignores_comments_and_action_names(self):
self.assertEqual(self.scan("notes.yml", """
# A private runner would be self-hosted or linux-runner.
jobs:
emulator:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: reactivecircus/android-emulator-runner@v2
"""), [])

def test_ignores_trailing_comments(self):
self.assertEqual(self.scan("build.yml", "runs-on: ubuntu-24.04 # not self-hosted\n"), [])

def test_strip_comment_keeps_hash_inside_quotes(self):
self.assertEqual(
labels.strip_comment('run: echo "a # b" # mentions linux-runner'),
'run: echo "a # b" ',
)


class CheckLabelsTests(unittest.TestCase):
def test_accepts_hosted_labels(self):
self.assertEqual(
labels.check_labels("blacksmith-16vcpu-ubuntu-2404", "windows-2025"), []
)
self.assertEqual(labels.check_labels("ubuntu-24.04-arm", ""), [])

def test_rejects_private_labels(self):
for label in ("self-hosted", "linux-runner", "bench-32vcpu", "macos-26-runner"):
with self.subTest(label=label):
problems = labels.check_labels(label, "")
self.assertEqual(len(problems), 1, problems)
self.assertIn("inputs.runner", problems[0])

def test_rejects_verify_runner_on_a_private_pool(self):
problems = labels.check_labels("ubuntu-24.04", "linux-runner")
self.assertEqual(len(problems), 1)
self.assertIn("inputs.verify_runner", problems[0])

def test_requires_runner_but_not_verify_runner(self):
self.assertEqual(len(labels.check_labels("", "")), 1)
self.assertIn("inputs.runner is empty", labels.check_labels("", "")[0])


class MainTests(unittest.TestCase):
def test_scan_mode_passes_on_the_repository_workflows(self):
self.assertEqual(labels.scan_workflows(), [])


if __name__ == "__main__":
unittest.main()
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,23 @@ jobs:
/pnpm-workspace.yaml
/.github/scripts/ci-changes.cjs
/.github/actions/ci-changes/
/.github/workflows/
/.github/scripts/check-runner-labels.py
/.github/scripts/check-runner-labels.test.py
/*/package.json
/*/*/package.json
!/.repos/
sparse-checkout-cone-mode: false

# The repository has no self-hosted runners, so a workflow that selects
# one is a security bug rather than a configuration choice. This is the
# cheapest job and it runs before everything else, so a pull request that
# introduces a private runner label fails before any other job starts.
- name: Check workflow runner labels
run: |
python3 -B .github/scripts/check-runner-labels.test.py
python3 -B .github/scripts/check-runner-labels.py

- name: Detect affected jobs
id: detect
uses: ./.github/actions/ci-changes
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/fork-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ jobs:
git fetch --no-tags --depth=2 origin "$GITHUB_SHA"
git sparse-checkout set --no-cone '/*' '!/.repos/' '!/apps/mobile/modules/t3-terminal/Vendor/**/*.a'
git checkout --detach FETCH_HEAD

# Mirrors ci.yml: the repository has no self-hosted runners, so a workflow
# that selects one fails here, before any other job starts.
- name: Check workflow runner labels
run: |
python3 -B .github/scripts/check-runner-labels.test.py
python3 -B .github/scripts/check-runner-labels.py

- name: Detect affected jobs
id: detect
uses: ./.github/actions/ci-changes
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/mobile-eas-preview-fork.yml
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,10 @@ jobs:

- name: Deploy with fingerprint check
if: steps.expo-token.outputs.present == 'true'
uses: expo/expo-github-action/continuous-deploy-fingerprint@main
# Pinned to the commit v9.0.1 tags rather than `main`: this job runs the
# reviewed head commit of a fork PR with EXPO_PREVIEW_TOKEN, so a moving
# branch ref would let a new upstream commit run here unreviewed.
uses: expo/expo-github-action/continuous-deploy-fingerprint@9da89d3617bb88139671bad31b09c65714c2ee06 # v9.0.1
env:
EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }}
RUNNER_DEBUG: "1"
Expand Down
47 changes: 46 additions & 1 deletion .github/workflows/release-desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,53 @@ permissions:
contents: read

jobs:
# `runner` and `verify_runner` are caller-supplied strings used directly as
# `runs-on`, so this workflow cannot tell a hosted pool from a private one on
# its own. Fail closed before either job is scheduled: a caller that passes
# `self-hosted`, a `*-runner` label, or anything outside the pools this
# workflow is designed for stops here instead of running release work (with
# release secrets) on hardware this repository does not control. The static
# half of the same policy is .github/scripts/check-runner-labels.py.
validate:
name: Validate runner inputs
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Reject private runner labels
shell: bash
env:
RUNNER_LABEL: ${{ inputs.runner }}
VERIFY_RUNNER_LABEL: ${{ inputs.verify_runner }}
run: |
set -euo pipefail
# Blacksmith's per-capacity labels and GitHub's own images only.
allowed='^(blacksmith|ubuntu|macos|windows)-[a-z0-9][a-z0-9.-]*$'
failed=0
check_label() {
local name="$1" label="$2" required="$3"
if [[ -z "$label" ]]; then
if [[ "$required" == "true" ]]; then
echo "::error::inputs.$name is empty, but this workflow needs a hosted runner label."
failed=1
fi
return 0
fi
# A private label is either a `*-runner` pool or simply outside the
# hosted pools above (GitHub's own self-hosted selector included).
if [[ "$label" == *-runner* ]] || [[ ! "$label" =~ $allowed ]]; then
echo "::error::inputs.$name=$label is not a hosted runner label (blacksmith-*, ubuntu-*, macos-*, windows-*)."
failed=1
else
echo "inputs.$name=$label is a hosted runner label."
fi
}
check_label runner "$RUNNER_LABEL" true
check_label verify_runner "$VERIFY_RUNNER_LABEL" false
exit "$failed"

build:
name: Build ${{ inputs.label }}
needs: [validate]
runs-on: ${{ inputs.runner }}
# Windows waits for the Linux CLI archive inside its own budget (see
# "Wait for Linux CLI archive"), so it gets that wait on top of the usual 30.
Expand Down Expand Up @@ -801,7 +846,7 @@ jobs:

verify:
name: Verify
needs: [build]
needs: [validate, build]
if: inputs.verify_runner != ''
runs-on: ${{ inputs.verify_runner }}
timeout-minutes: 15
Expand Down
Loading