Skip to content
Draft
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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}-<literal-job-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

Expand Down
14 changes: 12 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion examples/basic/workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion examples/gpu/workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions scripts/archetype_ci_app.py
Original file line number Diff line number Diff line change
@@ -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,
)
4 changes: 4 additions & 0 deletions src/runner_modal/entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions src/runner_modal/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,9 @@ def docker_image(
"docker.io",
"docker-buildx",
"libicu-dev",
"make",
"python3",
"python-is-python3",
"tar",
"unzip",
"zip",
Expand Down
7 changes: 6 additions & 1 deletion tests/unit/test_entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import os
from unittest.mock import MagicMock, patch

import pytest
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand Down
16 changes: 16 additions & 0 deletions tests/unit/test_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()