This is a simplified version of
donkersgoed/github-runner-ochestrator
using Tensorlake Orchestrate for scheduling and Tensorlake Sandboxes for ephemeral runner isolation.
The original project uses API Gateway, Lambda, SQS, CDK, SSM, and Lambda MicroVM images. This version keeps the GitHub contract and removes the AWS scheduling layer:
- A GitHub organization webhook sends a
workflow_jobevent. - The webhook signature is verified with
GITHUB_WEBHOOK_SECRET. - Only queued jobs with
REQUIRED_RUNNER_LABELare accepted. - An async Tensorlake Application schedules
run_github_runneras durable work. run_github_runnermints GitHub App credentials, creates a JIT self-hosted runner config, mounts that repository's persistent cache volume, starts a fresh Tensorlake Sandbox from a prebuilt runner image throughAsyncSandbox, runs exactly one GitHub Actions job, and terminates the sandbox.
github_runner_orchestrator/app.py- Tensorlake application and functions.github_runner_orchestrator/cache.py- Per-repository cache volume naming and provisioning.github_runner_orchestrator/github.py- GitHub App JWT, installation token, and JIT runner APIs.github_runner_orchestrator/webhook.py- HMAC verification andworkflow_jobfiltering.actions/setup-uv-cache/action.yml- Reusable uv environment archive cache action.actions/setup-rust-cache/action.yml- Reusable Cargo, sccache, and target cache action.sandbox-image/Dockerfile- Reusable GitHub Actions runner sandbox image built from an OCI base.uv.lock- Locked application and development dependency versions.
The Orchestrate functions are async def and use Tensorlake async function calls. Blocking GitHub
REST helpers run via asyncio.to_thread(), while sandbox operations use the async Sandbox SDK.
Run the organization setup wizard from the repository root:
./scripts/configure-github-org.shThe wizard checks for the GitHub (gh) and Tensorlake (tl) CLIs and offers to install either one
when missing. It also installs uv when needed, uses it to install Python 3.11, and synchronizes the
locked project dependencies. It shows all seven installation phases before changing anything and
explains every manual GitHub step before asking for a value. You need organization-owner access in
GitHub and a Tensorlake project where you can create secrets, images, and applications.
The GitHub App and the webhook are separate GitHub resources with different responsibilities:
| Resource | Purpose | When it is configured |
|---|---|---|
| GitHub App | Gives the Tensorlake application credentials to create ephemeral organization runners | Before deployment; its own webhook is disabled |
| Organization webhook | Sends workflow_job events to the Tensorlake endpoint |
After deployment returns the endpoint URL |
When registering the GitHub App, deselect Webhook → Active. No webhook URL or webhook secret is entered on the GitHub App. Give it only the Self-hosted runners: Read and write organization permission, create a private key, and install it on the target organization.
The exact installation order is:
- Install the local tools and sync the locked Python environment.
- Authenticate
ghandtl, then confirm the GitHub identity and Tensorlake organization/project. - Create and install the credentials-only GitHub App with its webhook disabled.
- Enter a Tensorlake project API key, then store it and the active Tensorlake project context with the GitHub App client ID, installation ID, private key, runner group ID, and a newly generated webhook secret. The deployed runner uses the API key and project context to create sandboxes and per-repository cache volumes. Tensorlake secrets exist at the project level and do not require an application deployment to exist first; see the secrets documentation.
- Build the reusable runner sandbox image.
- Deploy the Tensorlake application. The deployment reads the stored secrets and returns a public endpoint URL.
- Create a separate organization webhook for
workflow_jobevents using that endpoint and the same webhook secret stored in step 4.
The wizard performs all of these steps. The API-key prompt is hidden; for a non-interactive run,
provide it in TENSORLAKE_API_KEY. The wizard writes sensitive values only to
permission-restricted temporary files, deletes those files when it exits, and never prints the API
key, private key, or webhook secret.
If setup reaches deployment and stops, resume without repeating the GitHub App inputs or rebuilding the runner image:
WEBHOOK_SECRET="${WEBHOOK_SECRET:-}" ./scripts/configure-github-org.sh \
--resume-from-step-6 your-organizationWhen WEBHOOK_SECRET is still available in the current shell, the resume command reuses it.
Otherwise it safely rotates the stored webhook secret before deploying, then creates or updates the
organization webhook with the same new value.
After pulling a newer version of this repository, upgrade the deployed application with:
git switch main
git pull --ff-only
./scripts/configure-github-org.sh --upgradeConfirm that tl whoami shows the Tensorlake project containing the existing installation. The
upgrade command syncs the locked Python environment, reuses the stored Tensorlake API key, refreshes
the organization and project context secrets, rebuilds the runner image, and redeploys the
application. It does not ask for the GitHub App inputs, rotate the webhook secret, or modify the
organization webhook. The upgrade prints the deployed endpoint; if it differs from the organization
webhook URL, run --resume-from-step-6 your-organization to update the webhook.
The wizard is recommended because it creates or updates the matching organization webhook for you. For a manual installation, first create and install the GitHub App exactly as described above, then store the secrets before deploying:
WEBHOOK_SECRET="$(openssl rand -hex 32)"
tl secrets set GITHUB_WEBHOOK_SECRET="${WEBHOOK_SECRET}"
tl secrets set GITHUB_APP_CLIENT_ID='Iv1...'
tl secrets set GITHUB_APP_INSTALLATION_ID='12345678'
tl secrets set GITHUB_APP_PRIVATE_KEY="$(cat private-key.pem)"
tl secrets set RUNNER_GROUP_ID='1'
tl secrets set TENSORLAKE_API_KEY='tl_apiKey_...'
tl secrets set TENSORLAKE_ORGANIZATION_ID='org_...'
tl secrets set TENSORLAKE_PROJECT_ID='project_...'Use a project-scoped Tensorlake API key for TENSORLAKE_API_KEY; the deployed function uses it to
create and manage runner sandboxes. Run tl whoami -o json to find the organization and project IDs
for the active CLI context. RUNNER_GROUP_ID defaults to 1 if omitted. Keep WEBHOOK_SECRET in
the current shell until the organization webhook is created; do not enter it in the disabled GitHub
App webhook fields. Tensorlake injects stored secrets when an application is deployed. If you
change a secret later, redeploy the application so the new value takes effect.
Build the runner image, then use the resumable deployment path to deploy the application and configure the organization webhook:
./scripts/build-runner-image.sh
GITHUB_ORG='your-organization'
WEBHOOK_SECRET="${WEBHOOK_SECRET}" ./scripts/configure-github-org.sh \
--resume-from-step-6 "${GITHUB_ORG}"The resume command uses the repository-root app.py deployment entrypoint so the complete
github_runner_orchestrator package is included. It also uses the locked project SDK to deploy,
retrieves the generated public endpoint, and updates an existing matching webhook instead of
duplicating it.
The runner image name, timeout, required label, and optional GitHub organization override are
ordinary constants near the top of github_runner_orchestrator/app.py. They are configuration,
not secrets.
The runner image is built once from the tensorlake/ubuntu-systemd base, then registered as the
github-actions-runner Tensorlake sandbox image. Following the
Tensorlake Docker guide, it installs
Docker CE from Docker's Ubuntu repository and enables the Docker and containerd systemd services.
It also installs the Tensorlake CLI and FUSE support used to mount Cloud Volumes. Every runner waits
for Docker and, when a volume is available, its repository cache mount before invoking
/opt/actions-runner/run.sh --jitconfig ....
Workflow steps run as tl-user, matching GitHub-hosted Linux runners' non-root privilege model.
The account has passwordless sudo and access to the Docker daemon. Use sudo explicitly for
system-level operations:
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y protobuf-compilerThe orchestrator explicitly selects tl-user; it does not depend on the sandbox image's default
process user. The Actions work directory and persistent cache mount are owned and mounted by that
same account.
The initial setup wizard performs this build after storing the secrets and before deploying the application. Run it directly only for a manual installation or to rebuild the image:
./scripts/build-runner-image.shFor a manual or repeat deployment, make sure the required secrets have already been stored, then run the resumable deployment command:
./scripts/configure-github-org.sh --resume-from-step-6 your-organizationThe Tensorlake application allows unauthenticated invocation for GitHub webhooks. Configure GitHub
to send workflow_job events directly to the deployed github_runner_webhook application endpoint.
The application receives the exact request bytes as an SDK HttpBody, verifies GitHub's HMAC
signature before accepting work, and reads the case-insensitive, sanitized Headers collection
from Tensorlake's request context. These APIs require tensorlake>=0.5.97.
Use runs-on: [self-hosted, tensorlake] to request a sandbox runner. Add one resource-profile label
to select its CPU, memory, and disk:
runs-on: [self-hosted, tensorlake, tensorlake-medium]| Label | CPUs | Memory | Disk |
|---|---|---|---|
no profile or tensorlake-small |
2 | 4 GiB | 10 GiB |
tensorlake-medium |
4 | 8 GiB | 50 GiB |
tensorlake-large |
8 | 16 GiB | 100 GiB |
tensorlake-xlarge |
16 | 32 GiB | 100 GiB |
Profiles are defined in github_runner_orchestrator/resources.py; edit that mapping to expose
different sizes. Tensorlake runner disks are capped at 100 GiB. Requests with multiple
resource-profile labels are rejected. Docker is installed and started for every profile, so
workflows do not need a Docker-specific label.
Every GitHub repository gets its own Tensorlake Cloud Volume on its first runner job. The
application finds or creates the volume with Tensorlake's FilesystemClient. Empty volumes are
initialized with a small .tensorlake-cache-initialized marker so they have a generation that can
be mounted. The runner image then starts tl fs mount inside the sandbox at
/mnt/tensorlake-cache, and the orchestrator exports that path to the job as
TENSORLAKE_CACHE_DIR. Later sandboxes for the same repository mount the same volume, while other
repositories receive separate volumes.
The mount receives a short-lived credential scoped to that one volume. The project API key remains inside the orchestrator function and is not passed to the runner sandbox or GitHub workflow.
The cache is a normal writable directory rather than an actions/cache-compatible archive
service. A workflow opts in by pointing a tool's cache directory at a namespaced child directory:
jobs:
build:
runs-on: [self-hosted, tensorlake, tensorlake-medium]
steps:
- uses: actions/checkout@v6
- name: Configure a persistent tool cache
shell: bash
run: |
tool_cache="${TENSORLAKE_CACHE_DIR}/my-tool/${RUNNER_OS}-${RUNNER_ARCH}"
mkdir -p "${tool_cache}"
echo "MY_TOOL_CACHE=${tool_cache}" >> "${GITHUB_ENV}"
- run: my-tool buildUse distinct directories for operating systems, CPU architectures, toolchains, build targets, profiles, features, or other inputs that make cached files incompatible. Cache only reproducible, disposable data. Never write tokens, package-manager credentials, Docker configuration, signing material, or other secrets into the mounted directory. Pull requests and branches in the same repository share this cache security boundary, so do not expose the self-hosted runner to untrusted workflow code.
Cloud Volume writes autosave while the job runs. After the Actions runner exits, the orchestrator
syncs the sandbox and retries a safe unmount until the filesystem confirms that no unsaved work
would be lost. Cache provisioning and mounting are best-effort: if Tensorlake cannot prepare the
volume, the job still runs without TENSORLAKE_CACHE_DIR. The application emits structured logs
with repository, sandbox, volume, stage, and error fields for provisioning or mount failures.
The reference workflow deliberately verifies both TENSORLAKE_CACHE_DIR and its mountpoint before
using the cache. This makes a cache regression fail visibly instead of writing to an identically
named directory on the sandbox's ephemeral disk. Workflows that prefer best-effort caching can omit
that verification.
The runner deliberately exports only the generic TENSORLAKE_CACHE_DIR; it does not set
tool-specific cache variables. Point setup-uv at a child directory with its cache-local-path
input. Its default enable-cache: auto mode does not upload a GitHub Actions cache from a
self-hosted runner:
- uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
cache-local-path: /mnt/tensorlake-cache/uv
- run: uv sync --lockedThe cache and .venv are on different file systems, so uv may copy artifacts instead of
hard-linking them. The volume still avoids repeated downloads and source builds, but benchmark the
result for dependency sets dominated by small prebuilt wheels.
For a dependency set where those cross-filesystem copies are expensive, keep the live environment on the sandbox's local disk and store a compressed, compatibility-keyed environment archive on the volume with the repository's composite action:
concurrency:
group: uv-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
- uses: actions/checkout@v6
- name: Set up cached uv environment
id: uv-cache
uses: tensorlakeai/tensorlake-github-runners/actions/setup-uv-cache@main
with:
python-version: "3.11"
sync-args: --locked --all-extras
- run: uv run --no-sync pytestsetup-uv-cache installs uv and the requested Python version, restores a warm archive when one
exists, runs uv sync, and atomically publishes the environment after a cold sync. It accepts
working-directory, lockfile, and cache-version inputs for monorepos and manual invalidation.
Its state, key, restore-duration-ms, sync-duration-ms, and publish-duration-ms outputs are
also written to the job summary. Pin the action to a release tag or full commit in production
instead of tracking main.
The archive key separates incompatible operating systems, CPU architectures, exact Python versions, jobs, Git refs, working directories, and lockfiles. The local restore path is stable across runner sandboxes so virtual environment script paths remain valid. The concurrency group serializes workflows for the same ref, and the archive is published with an atomic rename so another run never restores a partial file. Different refs get separate archives even when they use the same lockfile.
This layout avoids both cross-filesystem package installation and Python's small-file reads over
FUSE. It also presents the Cloud Volume autosave engine with one sequential archive instead of
thousands of independently changing environment files. Remove obsolete archives under
${TENSORLAKE_CACHE_DIR}/uv-environment-archives-v1 when their refs or lockfiles are no longer
needed.
For Rust compiler artifacts, prefer sccache over sharing a complete mutable Cargo target tree.
Give each compatible build family its own cache namespace:
- uses: actions/checkout@v6
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache: false
# Install sccache with your preferred pinned action, package, or runner image.
- name: Set up persistent Rust caches
id: rust-cache
uses: tensorlakeai/tensorlake-github-runners/actions/setup-rust-cache@main
with:
cache-namespace: rust-workspace-v1
disable-incremental: "true"
- name: Build
run: |
cargo build --locked --workspace
sccache --show-statssetup-rust-cache configures a filesystem-safe cache tree for the requested namespace and exports
CARGO_HOME, SCCACHE_DIR, SCCACHE_CACHE_SIZE, SCCACHE_BASEDIRS, and RUSTC_WRAPPER.
Toolchain and sccache installation stay under workflow control. Set cache-sccache: "false" when
sccache is unavailable, and use sccache-cache-size to change the default 50G limit. The action
does not need to start the sccache server explicitly; the first wrapped compilation starts it.
Persisting CARGO_HOME reuses registry downloads, Git dependencies, and installed Cargo tools.
Authenticate private registries with environment variables; do not run cargo login against the
persistent CARGO_HOME, because it writes credentials. Cloud Volumes reconcile concurrent
same-path writes with last-writer-wins semantics; local Cargo and sccache locks should therefore not
be treated as a distributed lock across sandboxes. Use separate namespaces for concurrently running
job families, or serialize writers to a shared namespace.
Some release builds benefit from reusing the complete target directory. Give each ABI-compatible
build family an explicit versioned namespace:
- name: Set up persistent Rust caches
uses: tensorlakeai/tensorlake-github-runners/actions/setup-rust-cache@main
with:
# Include the runtime ABI, Rust target, profile, and a manual version bump.
cache-namespace: rust-release-glibc-2.35-x86_64-unknown-linux-gnu-v2
cache-target: "true"
- run: cargo build --locked --release --target x86_64-unknown-linux-gnuCargo target directories contain mutable incremental state. Do not let incompatible jobs share a
namespace, and serialize concurrent writers to the same namespace with a GitHub concurrency
group. For broadly parallel checks, use sccache and keep target on the sandbox's local disk.
For repositories with several Rust workflows:
- Remove an existing cache action only after pointing the same tool directories at the mounted cache.
- Set
cache: falseon toolchain setup actions when the mounted volume owns those files. - Do not combine the mounted cache with another cache backend for the same directory.
- Preserve compatibility distinctions such as workspace, runtime ABI, architecture, Rust target, build profile, and a manual cache version.
- Keep Docker BuildKit
registryorghacache backends. Do not place/var/lib/dockeron a Cloud Volume.
For outputs keyed by an immutable source commit, store the completed file under that key and publish it with a rename:
- name: Restore or build Firecracker
shell: bash
run: |
source_sha="$(git -C firecracker rev-parse HEAD)"
cache_dir="${TENSORLAKE_CACHE_DIR}/binaries/firecracker/${RUNNER_OS}-${RUNNER_ARCH}"
cached_binary="${cache_dir}/${source_sha}"
output="firecracker/build/cargo_target/x86_64-unknown-linux-musl/release/firecracker"
mkdir -p "${cache_dir}" "$(dirname "${output}")"
if [[ -x "${cached_binary}" ]]; then
cp "${cached_binary}" "${output}"
else
cargo build \
--manifest-path firecracker/Cargo.toml \
--release \
--target x86_64-unknown-linux-musl \
-p firecracker
temporary="${cached_binary}.tmp.${GITHUB_RUN_ID}.${GITHUB_RUN_ATTEMPT}"
cp "${output}" "${temporary}"
chmod +x "${temporary}"
mv -f "${temporary}" "${cached_binary}"
fiCloud Volumes are mutable shared storage, so GitHub cache eviction rules do not apply. Package
managers should prune their own caches, and obsolete manually versioned directories should be
removed explicitly. Use tl fs ls to list the generated github-actions-cache-* volumes and
tl fs rm <name> to delete a repository cache that is no longer needed. Do not create permanent
volume snapshots for disposable cache data.
.github/workflows/build-reference.yml runs on a tensorlake-small runner. It keeps uv's live
environment on local sandbox storage and caches a compatibility-keyed compressed environment on the
persistent volume. The job summary records cold/warm state plus archive restore, uv sync, archive
publish, and test durations. It installs and lints the Python project, builds its distribution,
validates the setup scripts, runs the test suite, and runs Docker's hello-world image to verify the
runner's Docker daemon. The reusable runner sandbox image still builds through
scripts/build-runner-image.sh, because tensorlake/ubuntu-systemd is a Tensorlake registered base
rather than a public Docker Hub image. Once the organization webhook is configured, pushes to
main, pull requests, and manual dispatches exercise the runner implementation from its own
repository.