diff --git a/.github/scripts/runner_guard.py b/.github/scripts/runner_guard.py new file mode 100644 index 0000000..4d848fa --- /dev/null +++ b/.github/scripts/runner_guard.py @@ -0,0 +1,66 @@ +"""Fail when a workflow job could run on a billed or unapproved runner. + +This repository is public. GitHub's standard hosted runners are free here, but +larger runners (-N-cores, -arm, -gpu) and third-party runners such as +Blacksmith are billed, and self-hosted runners must never run untrusted PR +code. Self-hosted jobs are allowed only when listed in SELF_HOSTED_OK as +":". + +Fail-closed: runs-on must be literal labels or ${{ matrix.os }} over a literal +os list. Any other shape raises and is rejected, including other expressions, +expression-built matrices, runner groups and reusable workflows from other +repositories. +""" + +import os +import pathlib +import sys + +import yaml + +FREE = { + "ubuntu-latest", "ubuntu-24.04", "ubuntu-22.04", + "windows-latest", "windows-2025", "windows-2022", + "macos-latest", "macos-15", "macos-14", +} +UNPROVABLE = (KeyError, TypeError, ValueError, AttributeError) + + +def runner_labels(job: dict) -> set: + """Every label the job can select; raises when that cannot be proven.""" + if "uses" in job: + if not job["uses"].startswith("./"): + raise ValueError("reusable workflow from another repository") + return set() + runs_on = job["runs-on"] + if runs_on == "${{ matrix.os }}": + matrix = job["strategy"]["matrix"] + runs_on = matrix["os"] + [x["os"] for x in matrix.get("include", []) if "os" in x] + labels = [runs_on] if isinstance(runs_on, str) else list(runs_on) + if not labels or any("${{" in label for label in labels): + raise ValueError(f"runs-on {runs_on!r} is not literal") + return set(labels) + + +def problems(workflows: pathlib.Path, approved: set) -> list: + found = [] + for workflow in sorted(workflows.glob("*.y*ml")): + for job_id, job in (yaml.safe_load(workflow.read_text()).get("jobs") or {}).items(): + key = f"{workflow.name}:{job_id}" + try: + labels = runner_labels(job) + except UNPROVABLE as error: + found.append(f"{key}: cannot prove the runner is free ({error!r})") + continue + if "self-hosted" in labels: + labels = set() if key in approved else {"unapproved self-hosted"} + if labels - FREE: + found.append(f"{key}: may run on {sorted(labels - FREE)}") + return found + + +if __name__ == "__main__": + approved = set(filter(None, os.environ.get("SELF_HOSTED_OK", "").replace(" ", "").split(","))) + found = problems(pathlib.Path(".github/workflows"), approved) + print("\n".join(found) or "All jobs use free standard runners or approved self-hosted ones.") + sys.exit(1 if found else 0) diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 22938d5..6756867 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -17,7 +17,7 @@ concurrency: jobs: build: - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 @@ -41,7 +41,7 @@ jobs: environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 needs: build steps: diff --git a/.github/workflows/runner-guard.yml b/.github/workflows/runner-guard.yml new file mode 100644 index 0000000..ff75720 --- /dev/null +++ b/.github/workflows/runner-guard.yml @@ -0,0 +1,24 @@ +name: Runner guard + +# Public repo: keep jobs on GitHub's free standard runners. See +# .github/scripts/runner_guard.py for what is allowed and why. + +on: + pull_request: + paths: [".github/workflows/**", ".github/scripts/runner_guard.py"] + push: + branches: [main] + paths: [".github/workflows/**", ".github/scripts/runner_guard.py"] + +permissions: + contents: read + +jobs: + runner-guard: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - name: Allow only free standard or approved self-hosted runners + env: + SELF_HOSTED_OK: "" + run: python3 .github/scripts/runner_guard.py diff --git a/.github/workflows/verify-pages-build.yml b/.github/workflows/verify-pages-build.yml index 2c99782..a221093 100644 --- a/.github/workflows/verify-pages-build.yml +++ b/.github/workflows/verify-pages-build.yml @@ -8,7 +8,7 @@ on: jobs: verify: - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4