#!/usr/bin/env bun /** * gitfor self-hosted runner. * * GITFOR_URL=https://forge.example.com GITFOR_RUNNER_TOKEN=gitfor_runner_... bun runner/gitfor-runner.ts * * Polls the forge for queued jobs whose `runs-on` labels match, executes the generated job script * with bash, streams logs back and uploads artifacts. Requires bash, git, tar and curl on the host. * * Security: steps run with the runner's uid. On Linux, set `kernel.yama.ptrace_scope` to 1 or higher so a step * cannot read the job script's memory (`/proc/$PPID/mem`) and recover the log-marker nonce; run the runner * under a dedicated, unprivileged account. */ import { spawn } from "node:child_process"; import { mkdir, rm, stat, writeFile, readFile } from "node:fs/promises"; import { hostname, tmpdir } from "node:os"; import { join } from "node:path"; type ClaimedJob = { id: string; name: string; repository: string; timeoutMinutes: number; script: string; markerNonce: string; env: Record; }; const SERVER = requireEnv("GITFOR_URL").replace(/\/$/, ""); const TOKEN = requireEnv("GITFOR_RUNNER_TOKEN"); const LABELS = (process.env.GITFOR_RUNNER_LABELS ?? "") .split(",") .map((label) => label.trim()) .filter(Boolean); const WORK_ROOT = process.env.GITFOR_RUNNER_WORKDIR ?? join(tmpdir(), "gitfor-runner"); const IDLE_POLL_MS = 5000; const ERROR_BACKOFF_MS = 15_000; const LOG_FLUSH_MS = 1000; const MAX_LOG_CHUNK = 512 * 1024; const ARTIFACT_NAME = /^[A-Za-z0-9._-]+$/; const KILL_GRACE_MS = 15_000; const HTTP_NO_CONTENT = 204; function requireEnv(name: string): string { const value = process.env[name]; if (!value) { console.error(`Missing required environment variable ${name}`); process.exit(1); } return value; } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } async function api(path: string, init: RequestInit = {}): Promise { const headers = new Headers(init.headers); headers.set("authorization", `Bearer ${TOKEN}`); return fetch(`${SERVER}/api/runner/${path}`, { ...init, headers }); } async function claim(): Promise { const response = await api("claim", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ labels: LABELS }), }); if (response.status === HTTP_NO_CONTENT) return null; if (!response.ok) throw new Error(`claim failed: ${response.status} ${await response.text()}`); const body: { job: ClaimedJob } = await response.json(); return body.job; } class LogShipper { private buffer = ""; private pending: Promise = Promise.resolve(); private readonly timer: ReturnType; readonly artifacts: string[] = []; private partial = ""; private readonly artifactPrefix: string; constructor( private readonly jobId: string, nonce: string, ) { this.artifactPrefix = `::gitfor::${nonce} artifact `; this.timer = setInterval(() => this.flush(), LOG_FLUSH_MS); } push(text: string): void { process.stdout.write(text); this.buffer += text; const lines = (this.partial + text).split("\n"); this.partial = lines.pop() ?? ""; for (const line of lines) { if (!line.startsWith(this.artifactPrefix)) continue; const name = line.slice(this.artifactPrefix.length).trim(); if (ARTIFACT_NAME.test(name) && !this.artifacts.includes(name)) this.artifacts.push(name); } if (this.buffer.length >= MAX_LOG_CHUNK) this.flush(); } flush(): Promise { if (!this.buffer) return this.pending; const chunk = this.buffer; this.buffer = ""; this.pending = this.pending.then(async () => { const response = await api(`jobs/${this.jobId}/logs`, { method: "POST", body: chunk }).catch(() => null); if (!response?.ok) console.error("failed to ship logs", response?.status); }); return this.pending; } async close(): Promise { clearInterval(this.timer); if (this.partial) this.push("\n"); await this.flush(); } } async function uploadArtifact(jobId: string, name: string, path: string): Promise { const info = await stat(path).catch(() => null); if (!info?.isFile()) return; const body = await readFile(path); const response = await api(`jobs/${jobId}/artifacts/${encodeURIComponent(name)}`, { method: "PUT", headers: { "content-length": String(info.size), "content-type": "application/gzip" }, body, }); if (!response.ok) console.error(`artifact ${name} upload failed: ${response.status}`); } async function runJob(job: ClaimedJob): Promise { console.warn(`\n==> ${job.repository} :: ${job.name} (${job.id})`); const workspace = join(WORK_ROOT, job.id); await mkdir(workspace, { recursive: true }); const scriptPath = join(workspace, ".gitfor-job.sh"); await writeFile(scriptPath, job.script, { mode: 0o700 }); const shipper = new LogShipper(job.id, job.markerNonce); const noncePath = join(workspace, ".gitfor-marker-nonce"); await writeFile(noncePath, job.markerNonce, { mode: 0o600 }); let exitCode = 1; try { exitCode = await new Promise((resolve) => { const child = spawn("bash", [scriptPath], { cwd: workspace, env: { ...process.env, ...job.env, GITFOR_WORKSPACE: workspace, GITFOR_RUNNER_NAME: hostname() }, stdio: ["ignore", "pipe", "pipe"], detached: true, }); const killGroup = (signal: NodeJS.Signals) => { if (child.pid) process.kill(-child.pid, signal); }; const timeout = setTimeout( () => { shipper.push(`\nJob exceeded ${job.timeoutMinutes} minutes; terminating\n`); killGroup("SIGTERM"); setTimeout(() => killGroup("SIGKILL"), KILL_GRACE_MS).unref(); }, job.timeoutMinutes * 60 * 1000, ); child.stdout.on("data", (chunk: Buffer) => shipper.push(chunk.toString("utf8"))); child.stderr.on("data", (chunk: Buffer) => shipper.push(chunk.toString("utf8"))); child.on("close", (code) => { clearTimeout(timeout); resolve(code ?? 1); }); child.on("error", (error) => { clearTimeout(timeout); shipper.push(`\nFailed to start bash: ${error.message}\n`); resolve(1); }); }); } finally { await rm(noncePath, { force: true }); } await shipper.close(); const artifactDir = join(workspace, ".gitfor", "artifacts"); for (const name of shipper.artifacts) await uploadArtifact(job.id, name, join(artifactDir, `${name}.tgz`)); const response = await api(`jobs/${job.id}/complete`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ exitCode }), }); if (!response.ok) console.error(`failed to report completion: ${response.status}`); await rm(workspace, { recursive: true, force: true }); console.warn(`<== ${job.name} finished with exit code ${exitCode}`); } async function main(): Promise { await mkdir(WORK_ROOT, { recursive: true }); console.warn(`gitfor runner polling ${SERVER} with labels [${["self-hosted", ...LABELS].join(", ")}]`); while (true) { try { const job = await claim(); if (job) await runJob(job); else await sleep(IDLE_POLL_MS); } catch (error) { console.error(error instanceof Error ? error.message : error); await sleep(ERROR_BACKOFF_MS); } } } await main();