Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 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
388 changes: 388 additions & 0 deletions Jobs/GitHub-actions-runner.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,388 @@
---
title: "Run GitHub Actions on Blaxel"
description: "Run GitHub Actions self-hosted runners on Blaxel ephemeral micro-VMs."
sidebarTitle: "GitHub Actions runner"
---

Blaxel can act as a self-hosted GitHub Actions runner. Each workflow job runs inside an ephemeral micro-VM that is spun up on demand and discarded when the job finishes.

There are two ways to connect GitHub to your Blaxel runner. The job configuration is identical in both cases, but the GitHub webhook is handled differently:

- [Blaxel webhook handler](#option-a-blaxel-webhook-handler): Blaxel's GitHub App manages the webhook for you. Recommended for most users.
- [Custom webhook handler](#option-b-custom-webhook-handler): You provide a custom webhook handler. Recommended for deeper control over the integration.

## Job configuration

<Note>
This section applies to both options below.
</Note>

1. Create the Dockerfile.

The Dockerfile defines the micro-VM filesystem. A good starting point is the `catthehacker` Ubuntu image, which includes most tools found on GitHub-hosted runners. Here is an example of a Dockerfile for a Python test runner:

```dockerfile
FROM ghcr.io/catthehacker/ubuntu:full-24.04

USER root

# Install Python
RUN apt-get update -qq \
&& apt-get install -y -qq --no-install-recommends \
python3 python3-pip python3-venv \
&& rm -rf /var/lib/apt/lists/*

# Install pytest
RUN pip3 install pytest pytest-cov pytest-xdist

# Install GitHub Actions runner
ARG RUNNER_VERSION=2.333.0
RUN curl -fSL --retry 3 --retry-delay 5 \
-o /tmp/runner.tar.gz \
"https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz" \
&& mkdir -p /actions-runner \
&& tar xzf /tmp/runner.tar.gz -C /actions-runner \
&& rm /tmp/runner.tar.gz

COPY start.sh /start.sh
RUN chmod +x /start.sh

ENV RUNNER_ALLOW_RUNASROOT=1

ENTRYPOINT ["bash", "/start.sh"]
```

You can install additional tools or set environment variables using standard `RUN`, `COPY`, and `ENV` instructions. When you are ready to use a different image, edit the Dockerfile and redeploy.

1. Create an entrypoint script.

The Dockerfile must reference an entrypoint script (in this example, `start.sh`) that fetches the JIT config from Blaxel and starts the GitHub Actions runner. Here is an example:

```bash
#!/bin/bash
set -euo pipefail

export RUNNER_ALLOW_RUNASROOT=1
RUNNER_DIR="/actions-runner"
export HOME="${RUNNER_DIR}"
TASK_INDEX=${TASK_INDEX:-0}

# fetch task data from Blaxel
if [ -n "${BL_EXECUTION_DATA_URL:-}" ]; then
echo "Fetching task data from Blaxel..."
TASK_DATA=$(curl -fsS --retry 3 --retry-delay 2 --connect-timeout 5 --max-time 30 "${BL_EXECUTION_DATA_URL}")
TASK_JSON=$(printf '%s' "${TASK_DATA}" | jq -c --argjson idx "${TASK_INDEX}" '.tasks[$idx]')

if [ "$TASK_JSON" = "null" ]; then
TASK_JSON=""
fi
fi

# fetch JIT_CONFIG
if [ -n "$TASK_JSON" ]; then
JIT_CONFIG=$(echo "${TASK_JSON}" | jq -r '.JIT_CONFIG // empty')
if [ -n "$JIT_CONFIG" ]; then
export ENCODED_JIT_CONFIG="$JIT_CONFIG"
echo "JIT config fetched successfully"
fi
fi

# start GitHub Actions runner
cd "${RUNNER_DIR}"
echo "Starting GitHub Actions runner..."
if [ -n "${ENCODED_JIT_CONFIG:-}" ]; then
./config.sh --unattended --jitconfig "${ENCODED_JIT_CONFIG}"
./run.sh
else
echo "ERROR: No JIT config available"
exit 1
fi

```

1. Create the `blaxel.toml` job configuration.

The `blaxel.toml` file defines the job configuration. A sample job configuration is shown below. This configuration uses ephemeral volumes.

```toml
type = "job"
name = "pytest-runner"

[runtime]
memory = 16384 # 16 GB of RAM allocated to the micro-VM
timeout = 3600 # maximum job duration: 1 hour (in seconds)
maxRetries = 0 # no automatic retries on failure
diskPercent = 5 # base root disk allocation (percentage)

[[volumes]]
# Dedicated storage for the Docker daemon
name = "docker"
mountPath = "/var/lib/docker"
type = "ephemeral"
sizeMb = 10240

[[volumes]]
# General-purpose scratch space
name = "tmp"
mountPath = "/tmp"
type = "ephemeral"
sizeMb = 102400
```

If you plan to use Blaxel's webhook handler, also add the following field to the `blaxel.toml` file:

```toml
[githubRunner]
repositories = ["owner/repo"]
```

The `repositories` field lists the GitHub repositories this runner is allowed to pick up jobs from. The format is `"owner/repo"`. This is only required when using Blaxel's webhook handler.

1. Deploy the job.

Once the job configuration is completed, deploy it to Blaxel:

```bash
bl deploy
```

## GitHub integration

### Option A: Blaxel webhook handler

Blaxel's GitHub App receives `workflow_job` events from GitHub and automatically launches your job.

1. Install the Blaxel GitHub App (first time only).

- Log in to the Blaxel Console.
- Navigate to **Hosting** > **Jobs** > `<your-job-name>` > **Settings**.
- Scroll to the **GitHub Runner** section. It should show as **Active**.
- Click **Edit**.
- Click **+**.
- Follow the instructions to authorize and install the Blaxel GitHub App on the repository.
- Select the repository and click **Save**.

1. Redeploy the job.

```bash
bl deploy --skip-build
```

1. Once configured, use `runs-on: [workspace/job-name]` in your GitHub Actions workflow to target this runner. For example:

```yaml
jobs:
build:
runs-on: my-blaxel-workspace/pytest-runner
steps:
- uses: actions/checkout@v4
- run: echo "Running on Blaxel!"
```

### Option B: Custom webhook handler

Use this approach when you need deeper control over the process. This method allows you to use a custom webhook handler, which can be a Blaxel agent, a serverless function, or any service capable of accepting an HTTP POST request.

Under this approach, when GitHub sends a `workflow_job` event, the handler:

1. Verifies the webhook signature.
2. Checks whether the job's `runs-on` labels match the configured prefix (default: `blaxel`).
3. Calls the GitHub API to generate a JIT (Just-In-Time) runner configuration.
4. Launches a Blaxel job with that JIT config, which registers itself as a self-hosted runner and picks up the queued work.

The handler uses label prefixes to decide which Blaxel job to spawn. Given `CATCH_LABEL=blaxel` (the default):

| `runs-on` value | Blaxel job triggered |
|---|---|
| `blaxel-github-runner-full` | `github-runner-full` |
| `blaxel-my-custom-runner` | `my-custom-runner` |

Everything after `<CATCH_LABEL>-` is used as the job name on Blaxel.

1. Configure environment variables for the handler as below:

| Variable | Required | Description |
|---|---|---|
| `GITHUB_TOKEN` | Yes | Personal Access Token with admin access to target repos |
| `GITHUB_WEBHOOK_SECRET` | Yes | Secret used to verify webhook payloads from GitHub; you can choose any value and will enter it again when configuring the webhook in GitHub |
| `CATCH_LABEL` | No | Label prefix to match (default: `blaxel`) |


<Tip>
If you deploy the handler as a Blaxel agent, set `public = true` in `blaxel.toml`. Authentication is handled through GitHub webhook signature verification, not Blaxel API keys.
</Tip>

2. Define the handler logic.

Here is an example implementation in TypeScript:

```typescript
import '@blaxel/telemetry'
import { blJob } from "@blaxel/core";
import express, { Request, Response } from "express";
import crypto from "crypto";

const app = express();
app.use(express.json());

const GITHUB_TOKEN = process.env.GITHUB_TOKEN || "";
const GITHUB_WEBHOOK_SECRET = process.env.GITHUB_WEBHOOK_SECRET || "";
const CATCH_LABEL = process.env.CATCH_LABEL || "blaxel";

function verifySignature(payload: string, signature: string | undefined): boolean {
if (!GITHUB_WEBHOOK_SECRET) return true;
if (!signature) return false;

Comment thread
mendral-app[bot] marked this conversation as resolved.
Outdated
const expected = "sha256=" + crypto
.createHmac("sha256", GITHUB_WEBHOOK_SECRET)
.update(payload)
.digest("hex");

return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}

interface JitConfigResponse {
runner: { id: number; name: string };
encoded_jit_config: string;
}

async function generateJitConfig(repoFullName: string, labels: string[]): Promise<JitConfigResponse> {
const runnerName = `blaxel-${crypto.randomBytes(4).toString("hex")}`;

const response = await fetch(
`https://api.github.com/repos/${repoFullName}/actions/runners/generate-jitconfig`,
{
method: "POST",
headers: {
Authorization: `Bearer ${GITHUB_TOKEN}`,
Accept: "application/vnd.github+json",
},
body: JSON.stringify({
name: runnerName,
runner_group_id: 1,
labels: labels,
work_folder: "_work",
}),
}
);

if (!response.ok) {
const error = await response.text();
throw new Error(`GitHub API error (${response.status}): ${error}`);
}

return response.json() as Promise<JitConfigResponse>;
}

function findJobName(requestedLabels: string[]): string | null {
const prefix = CATCH_LABEL + "-";
const match = requestedLabels.find((l) => l.startsWith(prefix));
if (!match) return null;
return match.slice(prefix.length) || null;
}

app.post("/webhook", async (req: Request, res: Response) => {
const event = req.headers["x-github-event"] as string;
const signature = req.headers["x-hub-signature-256"] as string | undefined;
const payload = JSON.stringify(req.body);

if (!verifySignature(payload, signature)) {
console.error("Invalid webhook signature");
res.status(401).json({ error: "Invalid signature" });
return;
}

if (event !== "workflow_job") {
res.json({ status: "ignored", event });
return;
}

const { action, workflow_job } = req.body;

if (action !== "queued") {
console.log(`Job ${workflow_job.id} action: ${action}, ignoring`);
res.json({ status: "ignored", action });
return;
}

const requestedLabels: string[] = workflow_job.labels || [];
const jobName = findJobName(requestedLabels);

if (!jobName) {
console.log(`Job ${workflow_job.id} not targeted at us (labels: ${requestedLabels.join(", ")})`);
res.json({ status: "ignored", reason: "not_targeted" });
return;
}

const repoFullName: string = req.body.repository.full_name;
console.log(`Job ${workflow_job.id} queued for ${repoFullName}, spawning job "${jobName}"...`);

try {
const jitConfig = await generateJitConfig(repoFullName, requestedLabels);
console.log(`JIT config generated for runner ${jitConfig.runner.name} (id: ${jitConfig.runner.id})`);

const job = blJob(jobName);
await job.run([{ JIT_CONFIG: jitConfig.encoded_jit_config }]);

console.log(`Runner job "${jobName}" launched for workflow job ${workflow_job.id}`);
res.json({ status: "runner_spawned", runner: jitConfig.runner.name, job: jobName });
} catch (err: unknown) {
let message: string;
if (err instanceof Error) {
message = err.message;
if (err.stack) console.error(err.stack);
} else {
message = String(err);
}
console.error(`Failed to spawn runner for job "${jobName}":`, err);
res.status(500).json({ error: message, job: jobName });
}
});

app.get("/health", (_req: Request, res: Response) => {
res.json({ status: "ok", catch_label: CATCH_LABEL });
});

const HOST = process.env.HOST || "0.0.0.0";
const PORT = parseInt(process.env.PORT || "8080", 10);

app.listen(PORT, HOST, () => {
console.log(`GitHub Runner webhook agent listening on ${HOST}:${PORT}`);
console.log(`Catching jobs with label: "${CATCH_LABEL}"`);
});
```

3. Deploy your handler to the platform of your choice. Once deployed, note the public URL, as you will need it for the webhook.

4. Configure the GitHub webhook.

Go to your repository (or organization) and navigate to the list of webhooks (**Settings > Webhooks > Add webhook**). Configure a new webhook as follows:

| Field | Value |
|---|---|
| **Payload URL** | `https://<your-handler-url>/webhook` |
| **Content type** | `application/json` |
| **Secret** | Same value as `GITHUB_WEBHOOK_SECRET` |
| **Events** | Select **"Workflow jobs"** only |

5. Use it in your workflows

In any GitHub Actions workflow, set `runs-on` to target your Blaxel runner:

```yaml
jobs:
build:
runs-on: blaxel-pytest-runner
steps:
- uses: actions/checkout@v4
- run: echo "Running on Blaxel!"
```

The label `blaxel-pytest-runner` triggers the `pytest-runner` job on Blaxel.

You can maintain multiple runner profiles by creating separate jobs with different Dockerfiles and routing to them via different labels:

| `runs-on` label | Job |
|---|---|
| `blaxel-runner-slim` | `github-runner-slim` |
| `blaxel-runner-gpu` | `github-runner-gpu` |
Loading