diff --git a/.github/scripts/check-runner-labels.py b/.github/scripts/check-runner-labels.py new file mode 100644 index 0000000000000..15ca898e08423 --- /dev/null +++ b/.github/scripts/check-runner-labels.py @@ -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()) diff --git a/.github/scripts/check-runner-labels.test.py b/.github/scripts/check-runner-labels.test.py new file mode 100644 index 0000000000000..dc6186a9dae50 --- /dev/null +++ b/.github/scripts/check-runner-labels.test.py @@ -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() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4777f032705a..b7b74e3049625 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/fork-ci.yml b/.github/workflows/fork-ci.yml index efff2a86fdcaf..4b6ef83ed4820 100644 --- a/.github/workflows/fork-ci.yml +++ b/.github/workflows/fork-ci.yml @@ -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 diff --git a/.github/workflows/mobile-eas-preview-fork.yml b/.github/workflows/mobile-eas-preview-fork.yml index e7615d46011bd..d263461e44317 100644 --- a/.github/workflows/mobile-eas-preview-fork.yml +++ b/.github/workflows/mobile-eas-preview-fork.yml @@ -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" diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 8fbe8af478b75..2deae61f01b1d 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -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. @@ -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