diff --git a/README.md b/README.md index b22ecbc..d4e5952 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,9 @@ print(Runner.from_name("ci").url) # None until deployed Copy [`examples/basic/workflow.yml`](examples/basic/workflow.yml) into `.github/workflows/`. Labels must match `Runner.create(labels=…)`. Keep the -unique `job-${{ github.run_id }}-${{ github.job }}` pin. +unique `job-${{ github.run_id }}-` pin. GitHub does not populate +the `github.job` context while it evaluates `runs-on`, so use the job's literal +YAML key (for example, `job-${{ github.run_id }}-test`). ## Examples diff --git a/SECURITY.md b/SECURITY.md index 10db35f..27b921e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -23,7 +23,13 @@ Use **two** named Modal Secrets: 1. **`github_secret`** — `GITHUB_TOKEN` only — mounted on Job Sandboxes 2. **`webhook_secret`** — `WEBHOOK_SECRET` only — mounted on the webhook Function -Never put both keys in one Secret. Workflow code on a Job can read every env key Modal mounts. If `WEBHOOK_SECRET` is present in a Job, a hostile workflow can forge control-plane requests. +Never put both keys in one Secret. The Job entrypoint reads `GITHUB_TOKEN`, +removes it from the process environment before starting Docker or any other +child process, and retains it only as a local value while minting the one-use +JIT configuration. Workflow code therefore receives the ordinary job-scoped +GitHub Actions token, not the repository-administration token used for JIT +registration. If `WEBHOOK_SECRET` is present in a Job, a hostile workflow can +forge control-plane requests. Prefer a fine-scoped GitHub App or PAT limited to runner JIT / admin on the allowlisted repos. @@ -45,7 +51,11 @@ After a Job Sandbox is created, the control plane binds `object_id` onto the del ## Fork PRs and untrusted workflows -Self-hosted runners execute workflow steps with access to the Job environment (including `GITHUB_TOKEN`). Treat fork PRs and untrusted workflows as hostile. Prefer private repos, trusted branches, and unique `runs-on` pins (`job-${{ github.run_id }}-${{ github.job }}`). +Self-hosted runners execute workflow steps with access to the Job environment +(including `GITHUB_TOKEN`). Treat fork PRs and untrusted workflows as hostile. +Prefer private repos, trusted branches, and unique `runs-on` pins such as +`job-${{ github.run_id }}-test`, where `test` is the literal job key. Do not use +`github.job` in `runs-on`; GitHub leaves that context empty while scheduling. ## Reporting issues diff --git a/examples/basic/workflow.yml b/examples/basic/workflow.yml index 755521b..fd3256a 100644 --- a/examples/basic/workflow.yml +++ b/examples/basic/workflow.yml @@ -13,7 +13,7 @@ jobs: - self-hosted - modal - ci - - job-${{ github.run_id }}-${{ github.job }} + - job-${{ github.run_id }}-test timeout-minutes: 30 steps: - uses: actions/checkout@v4 diff --git a/examples/gpu/workflow.yml b/examples/gpu/workflow.yml index c7bb7d7..eb85027 100644 --- a/examples/gpu/workflow.yml +++ b/examples/gpu/workflow.yml @@ -13,7 +13,7 @@ jobs: - self-hosted - modal - gpu - - job-${{ github.run_id }}-${{ github.job }} + - job-${{ github.run_id }}-gpu timeout-minutes: 60 steps: - uses: actions/checkout@v4 diff --git a/scripts/archetype_ci_app.py b/scripts/archetype_ci_app.py new file mode 100644 index 0000000..2c39189 --- /dev/null +++ b/scripts/archetype_ci_app.py @@ -0,0 +1,43 @@ +"""VangelisTech Archetype nightly benchmark runner. + +Deploy from this checkout after creating the two named Secrets documented in +``SECURITY.md``:: + + modal deploy scripts/archetype_ci_app.py --environment main + +Register ``Runner.from_name("archetype-nightly").url + "/github"`` as a +repository ``workflow_job`` webhook on ``VangelisTech/archetype``. +""" + +from __future__ import annotations + +import modal + +from runner_modal import Runner + +app = modal.App("archetype-nightly-runner") +github = modal.Secret.from_name( + "archetype-ci-github-token", + required_keys=["GITHUB_TOKEN"], +) +webhook = modal.Secret.from_name( + "archetype-ci-webhook", + required_keys=["WEBHOOK_SECRET"], +) + +Runner.create( + app=app, + name="archetype-nightly", + github_secret=github, + webhook_secret=webhook, + repositories=["VangelisTech/archetype"], + labels=["self-hosted", "modal", "archetype-nightly"], + # actions/setup-python publishes compatible toolcache builds for Ubuntu, + # not runner-modal's default Debian image. + image=Runner.docker_image(), + cpu=4.0, + memory=16_384, + max_concurrent=2, + min_containers=0, + idle_timeout=900, +) diff --git a/src/runner_modal/entrypoint.py b/src/runner_modal/entrypoint.py index cdbe5a6..c76fc9e 100644 --- a/src/runner_modal/entrypoint.py +++ b/src/runner_modal/entrypoint.py @@ -49,6 +49,10 @@ class JobSpec(BaseModel): use_docker: bool = False def start(self, *, token: str) -> None: + # The repository-administration token is needed only to mint this + # runner's one-use JIT configuration. Remove it before starting any + # child process so workflow code and dockerd cannot inherit it. + os.environ.pop("GITHUB_TOKEN", None) if self.use_docker: self.boot_docker() jit = self.mint_jitconfig(token=token) diff --git a/src/runner_modal/runner.py b/src/runner_modal/runner.py index 6abd6cf..679723a 100644 --- a/src/runner_modal/runner.py +++ b/src/runner_modal/runner.py @@ -438,6 +438,9 @@ def docker_image( "docker.io", "docker-buildx", "libicu-dev", + "make", + "python3", + "python-is-python3", "tar", "unzip", "zip", diff --git a/tests/unit/test_entrypoint.py b/tests/unit/test_entrypoint.py index 2f97ed4..591c80c 100644 --- a/tests/unit/test_entrypoint.py +++ b/tests/unit/test_entrypoint.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os from unittest.mock import MagicMock, patch import pytest @@ -36,8 +37,11 @@ def test_main_requires_github_token(monkeypatch: pytest.MonkeyPatch) -> None: JobSpec.main() -def test_start_execs_actions_runner_with_jit() -> None: +def test_start_execs_actions_runner_with_jit( + monkeypatch: pytest.MonkeyPatch, +) -> None: spec = JobSpec(repository="a/b", labels=["modal"], runner_name="r1") + monkeypatch.setenv("GITHUB_TOKEN", "admin-token") with ( patch.object(JobSpec, "mint_jitconfig", return_value="JIT") as mint, patch("runner_modal.entrypoint.os.execv") as execv, @@ -47,6 +51,7 @@ def test_start_execs_actions_runner_with_jit() -> None: execv.assert_called_once_with( ACTIONS_RUNNER, [ACTIONS_RUNNER, "--jitconfig", "JIT"] ) + assert "GITHUB_TOKEN" not in os.environ def test_start_boots_docker_when_vm_runtime_requested() -> None: diff --git a/tests/unit/test_runner.py b/tests/unit/test_runner.py index fb7c5b9..ce93fe1 100644 --- a/tests/unit/test_runner.py +++ b/tests/unit/test_runner.py @@ -402,3 +402,19 @@ def test_images_install_runtime_deps_and_local_package() -> None: Runner.default_image() slim.uv_pip_install.assert_called() slim.add_local_python_source.assert_called_with("runner_modal", copy=True) + + ubuntu = MagicMock() + image_cls.from_registry.return_value = ubuntu + ubuntu.env.return_value = ubuntu + ubuntu.apt_install.return_value = ubuntu + ubuntu.uv_pip_install.return_value = ubuntu + ubuntu.add_local_python_source.return_value = ubuntu + with patch.object( + Runner, + "install_actions_runner", + side_effect=lambda img, **kwargs: img, + ): + Runner.docker_image() + packages = ubuntu.apt_install.call_args.args + assert {"make", "python3", "python-is-python3"} <= set(packages) + ubuntu.uv_pip_install.assert_called()