Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

Run workflow_run workflows, add Git LFS to runners, probe Docker in containers (#11)

Workflows with `on: workflow_run` now start on the default branch when a
workflow they name is requested or completes, with the triggering run in
github.event.workflow_run, and chains stop after three levels like GitHub.
The runner image gains Git LFS. A manual workflow checks whether a Cloudflare
Container can run a Docker daemon, the first step towards Docker on hosted
runners.


Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL

Co-authored-by: Claude <noreply@anthropic.com>
Canberk Demirci authored · GitHub committed signed1 parent 2fa79bdcommit 36b475f7102b61d7491db3274aee0681227c30dcBrowse files
9 files changed+263−3
Files (9)
added.github/workflows/dind-probe.yml+67−0View file
@@ -1,0 +1,67 @@
1+name: Docker in Cloudflare Containers
2+
3+# Checks whether a Cloudflare Container can run a Docker daemon (rootful or
4+# rootless), so hosted Diffinery runners can offer Docker. Deploys a throwaway
5+# probe Worker with a container, prints what worked, and removes it.
6+# Uses the repository secrets CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID.
7+on:
8+ workflow_dispatch:
9+
10+permissions:
11+ contents: read
12+
13+jobs:
14+ probe:
15+ name: Probe Docker inside a container
16+ runs-on: ubuntu-latest
17+ timeout-minutes: 40
18+ env:
19+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
20+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
21+ NAME: diffinery-dind-probe-${{ github.run_id }}
22+ steps:
23+ - uses: actions/checkout@v4
24+ - uses: pnpm/action-setup@v4
25+ - uses: actions/setup-node@v4
26+ with:
27+ node-version: 22
28+ cache: pnpm
29+ - run: pnpm install --frozen-lockfile
30+ - name: Deploy the probe
31+ working-directory: apps/diffinery/scripts/dind-probe
32+ run: |
33+ set -euo pipefail
34+ cat > wrangler.json <<JSON
35+ {"name":"$NAME","main":"worker.js","compatibility_date":"2026-10-01","workers_dev":true,
36+ "containers":[{"class_name":"Probe","image":"./Dockerfile","instance_type":"standard-2","max_instances":1}],
37+ "durable_objects":{"bindings":[{"name":"PROBE","class_name":"Probe"}]},
38+ "migrations":[{"tag":"v1","new_sqlite_classes":["Probe"]}]}
39+ JSON
40+ pnpm --dir "$GITHUB_WORKSPACE/apps/diffinery" exec wrangler deploy -c "$PWD/wrangler.json"
41+ sub=$(curl -sS "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/subdomain" -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" | jq -r '.result.subdomain // empty')
42+ echo "PROBE_URL=https://$NAME.$sub.workers.dev" >> "$GITHUB_ENV"
43+ - name: Run the probe
44+ run: |
45+ set -uo pipefail
46+ for i in $(seq 1 60); do
47+ curl -fsS -m 30 -o /dev/null "$PROBE_URL/run" && break
48+ sleep 10
49+ done
50+ for i in $(seq 1 120); do
51+ out=$(curl -sS -m 30 "$PROBE_URL/log")
52+ grep -q "PROBE DONE" <<<"$out" && break
53+ sleep 10
54+ done
55+ echo "$out"
56+ echo "::group::Summary"
57+ grep '^RESULT' <<<"$out" | tee -a "$GITHUB_STEP_SUMMARY"
58+ echo "::endgroup::"
59+ - name: Remove the probe
60+ if: always()
61+ working-directory: apps/diffinery/scripts/dind-probe
62+ run: |
63+ W=(pnpm --dir "$GITHUB_WORKSPACE/apps/diffinery" exec wrangler)
64+ [ -f wrangler.json ] && "${W[@]}" delete -c "$PWD/wrangler.json" --force || true
65+ for id in $("${W[@]}" containers list --json 2>/dev/null | jq -r --arg n "$NAME" '.[] | select(.name | startswith($n)) | .id'); do
66+ "${W[@]}" containers delete "$id" -c "$PWD/wrangler.json" || true
67+ done
modifiedREADME.md+2−2View file
@@ -123,8 +123,8 @@
123123
124124 Put workflow files in `.github/workflows/` (or `.diffinery/workflows/`).
125125 Supported triggers are `push`, `pull_request`, `workflow_dispatch` (with
126-inputs) and `schedule`, with branch, tag and path filters. Supported workflow
127-features:
126+inputs), `schedule` and `workflow_run` (up to three levels deep, like GitHub),
127+with branch, tag and path filters. Supported workflow features:
128128
129129 - `needs` and job outputs, `if` with `success()`, `failure()`, `always()` and
130130 `cancelled()`, `strategy.matrix` with include and exclude, `fail-fast` and
addedapps/diffinery/scripts/dind-probe/Dockerfile+15−0View file
@@ -1,0 +1,15 @@
1+# Probe image: can a Cloudflare Container run a Docker daemon (rootful or rootless)?
2+FROM ubuntu:24.04
3+ENV DEBIAN_FRONTEND=noninteractive
4+RUN apt-get update \
5+ && apt-get install -y --no-install-recommends ca-certificates curl gnupg uidmap iproute2 slirp4netns fuse-overlayfs dbus-user-session kmod nodejs procps iptables \
6+ && install -m 0755 -d /etc/apt/keyrings \
7+ && curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc \
8+ && echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu noble stable" > /etc/apt/sources.list.d/docker.list \
9+ && apt-get update \
10+ && apt-get install -y --no-install-recommends docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-ce-rootless-extras \
11+ && useradd -m -u 1000 rootless \
12+ && rm -rf /var/lib/apt/lists/*
13+COPY probe.sh server.mjs /opt/probe/
14+EXPOSE 8080
15+ENTRYPOINT ["node", "/opt/probe/server.mjs"]
addedapps/diffinery/scripts/dind-probe/probe.sh+41−0View file
@@ -1,0 +1,41 @@
1+#!/bin/bash
2+# Each check prints "RESULT <name> ok|fail" so the workflow can summarise.
3+r() { local name=$1; shift; echo "--- $name: $*"; out=$(timeout 300 bash -c "$*" 2>&1); code=$?; echo "$out" | tail -n 15; [ $code = 0 ] && echo "RESULT $name ok" || echo "RESULT $name fail ($code)"; }
4+echo "=== environment"
5+uname -a; id; grep -E '^(CapEff|CapBnd|NoNewPrivs|Seccomp)' /proc/self/status; nproc; free -m | head -2; df -h / | tail -1
6+grep -E 'overlay|fuse' /proc/filesystems; ls -l /dev/fuse /dev/net/tun 2>&1; mount | grep -E 'cgroup' | head -3
7+r userns "unshare -Ur id"
8+r netns "unshare -n true"
9+
10+wait_docker() { for i in $(seq 1 60); do docker version >/dev/null 2>&1 && return 0; sleep 1; done; return 1; }
11+
12+checks() {
13+ local mode=$1
14+ r "$mode-info" "docker info --format '{{.Driver}} {{.CgroupVersion}} {{.SecurityOptions}}'"
15+ r "$mode-run-host" "docker run --rm --network=host mirror.gcr.io/library/alpine:3.20 sh -c 'echo hello && wget -qO- https://example.com | head -c 60'"
16+ r "$mode-run-default" "docker run --rm mirror.gcr.io/library/alpine:3.20 sh -c 'wget -T 10 -qO- https://example.com | head -c 60'"
17+ r "$mode-bridge-dns" "docker network create probe-net >/dev/null && docker run -d --name svc --network probe-net --network-alias svc mirror.gcr.io/library/nginx:alpine >/dev/null && sleep 3 && docker run --rm --network probe-net mirror.gcr.io/library/alpine:3.20 wget -T 10 -qO- http://svc | head -c 80"
18+ r "$mode-publish" "docker run -d --name pub -p 18080:80 mirror.gcr.io/library/nginx:alpine >/dev/null && sleep 3 && curl -sS -m 10 http://127.0.0.1:18080 | head -c 80"
19+ mkdir -p /tmp/ctx && printf 'FROM mirror.gcr.io/library/alpine:3.20\nRUN wget -qO /x https://example.com && echo built\n' > /tmp/ctx/Dockerfile
20+ r "$mode-build-default" "docker build --progress=plain -t probe-default /tmp/ctx"
21+ r "$mode-build-host" "docker build --progress=plain --network=host -t probe-host --no-cache /tmp/ctx"
22+ r "$mode-volume" "echo vol-ok > /tmp/ctx/v && docker run --rm -v /tmp/ctx:/data mirror.gcr.io/library/alpine:3.20 cat /data/v"
23+ docker rm -f svc pub >/dev/null 2>&1; docker network rm probe-net >/dev/null 2>&1
24+}
25+
26+echo "=== rootful dockerd"
27+(dockerd --iptables=false --ip6tables=false > /tmp/dockerd.log 2>&1 &)
28+if wait_docker; then echo "RESULT rootful-daemon ok"; checks rootful; else echo "RESULT rootful-daemon fail"; tail -n 25 /tmp/dockerd.log; fi
29+pkill dockerd; sleep 3
30+
31+echo "=== rootful dockerd with iptables"
32+(dockerd > /tmp/dockerd2.log 2>&1 &)
33+if wait_docker; then echo "RESULT rootful-iptables-daemon ok"; checks rootful-ipt; else echo "RESULT rootful-iptables-daemon fail"; tail -n 25 /tmp/dockerd2.log; fi
34+pkill dockerd; sleep 3
35+
36+echo "=== rootless dockerd"
37+mkdir -p /run/user/1000 && chown rootless /run/user/1000 && chmod 700 /run/user/1000
38+su rootless -c 'export XDG_RUNTIME_DIR=/run/user/1000 HOME=/home/rootless PATH=/usr/bin:/usr/sbin:/bin; dockerd-rootless.sh --iptables=false --ip6tables=false > /tmp/rootless.log 2>&1 &'
39+export DOCKER_HOST=unix:///run/user/1000/docker.sock
40+if wait_docker; then echo "RESULT rootless-daemon ok"; checks rootless; else echo "RESULT rootless-daemon fail"; tail -n 25 /tmp/rootless.log; fi
41+echo "PROBE DONE"
addedapps/diffinery/scripts/dind-probe/server.mjs+14−0View file
@@ -1,0 +1,14 @@
1+import { createServer } from "node:http";
2+import { spawn } from "node:child_process";
3+import { openSync, readFileSync } from "node:fs";
4+let started = false;
5+createServer((req, res) => {
6+ if (req.url === "/run" && !started) {
7+ started = true;
8+ const out = openSync("/tmp/probe.log", "a");
9+ spawn("bash", ["/opt/probe/probe.sh"], { stdio: ["ignore", out, out], detached: true }).unref();
10+ }
11+ let log = "";
12+ try { log = readFileSync("/tmp/probe.log", "utf8"); } catch {}
13+ res.writeHead(200, { "content-type": "text/plain" }).end(started ? log : "not started\n");
14+}).listen(8080);
addedapps/diffinery/scripts/dind-probe/worker.js+11−0View file
@@ -1,0 +1,11 @@
1+import { Container, getContainer } from "@cloudflare/containers";
2+export class Probe extends Container {
3+ defaultPort = 8080;
4+ sleepAfter = "30m";
5+ enableInternet = true;
6+}
7+export default {
8+ fetch(req, env) {
9+ return getContainer(env.PROBE, "probe").fetch(req);
10+ },
11+};
modifiedapps/diffinery/src/repo/actions.ts+63−0View file
@@ -439,6 +439,7 @@
439439 const m = this.repo.meta;
440440 this.repo.emit({ type: "workflow_run", action: "requested", repoId: m.repoId, owner: m.owner, repo: m.name, runId: id, actor: JSON.parse(run.actor), at: t });
441441 this.repo.broadcast("runs", { action: "created", runId: id });
442+ await this.onWorkflowRun(run, "requested");
442443 return toRun(run);
443444 }
444445
@@ -821,9 +822,71 @@
821822 this.repo.emit({ type: "workflow_run", action: "completed", repoId: m.repoId, owner: m.owner, repo: m.name, runId, conclusion: conclusion ?? undefined, actor: JSON.parse(run.actor), at: t });
822823 this.repo.broadcast(`run:${runId}`, { action: "completed", conclusion });
823824 this.repo.broadcast("runs", { action: "completed", runId });
825+ await this.onWorkflowRun(this.runRow(runId), "completed");
824826 return toRun(this.runRow(runId));
825827 }
826828
829+ /**
830+ * Starts the default branch's `on: workflow_run` workflows that follow `run`. Like
831+ * GitHub, they run on the default branch, must name the workflows they follow, and
832+ * chains stop after three levels (A -> B -> C -> D; D's runs trigger nothing).
833+ */
834+ private async onWorkflowRun(run: RunRow, action: "requested" | "completed"): Promise<void> {
835+ if (this.chainDepth(run) >= 3) return;
836+ try {
837+ const sha = this.repo.getRef(this.repo.headRef);
838+ if (!sha) return;
839+ const followers = (await this.loadWorkflows(sha)).filter((w) => w.workflow?.on.workflow_run?.workflows?.length);
840+ if (!followers.length) return;
841+ const m = this.repo.meta;
842+ const payload = JSON.parse(run.event_payload) as { pull_request?: { head?: { ref?: string } } };
843+ const branch = payload.pull_request?.head?.ref ?? run.ref.replace(/^refs\/(heads|tags)\//, "");
844+ const actor = JSON.parse(run.actor) as Actor;
845+ const workflowRun = {
846+ id: run.id,
847+ name: run.workflow_name,
848+ display_title: run.display_title,
849+ path: run.workflow_path,
850+ run_number: run.run_number,
851+ run_attempt: run.attempt,
852+ event: run.event,
853+ status: run.status,
854+ conclusion: run.conclusion,
855+ head_branch: branch,
856+ head_sha: run.sha,
857+ actor: { login: actor.login },
858+ triggering_actor: { login: actor.login },
859+ pull_requests: run.pr_number ? [{ number: run.pr_number }] : [],
860+ created_at: run.created_at,
861+ updated_at: run.updated_at,
862+ html_url: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}`,
863+ };
864+ for (const w of followers) {
865+ if (!matchesTrigger(w.workflow!, { name: "workflow_run", action, workflow: run.workflow_name, branch })) continue;
866+ await this.createRun(w.workflow!, {
867+ event: "workflow_run",
868+ ref: this.repo.headRef,
869+ sha,
870+ payload: { action, workflow_run: workflowRun, workflow: { name: run.workflow_name, path: run.workflow_path }, repository: this.repositoryPayload(), sender: { login: actor.login } },
871+ caller: actor,
872+ title: w.workflow!.name ?? w.path,
873+ });
874+ }
875+ } catch (e) {
876+ console.warn("workflow_run trigger failed", e);
877+ }
878+ }
879+
880+ /** How many workflow_run links led to `run` (0 for a run started by any other event). */
881+ private chainDepth(run: RunRow): number {
882+ let depth = 0;
883+ for (let r: RunRow | undefined = run; r?.event === "workflow_run" && depth < 3; depth++) {
884+ const parent: string | undefined = (JSON.parse(r.event_payload) as { workflow_run?: { id?: string } }).workflow_run?.id;
885+ r = parent ? this.sql.exec<RunRow>("SELECT * FROM runs WHERE id = ?", parent).toArray()[0] : undefined;
886+ }
887+ return depth;
888+ }
889+
827890 async plannedError(runId: string, message: string): Promise<void> {
828891 const t = now();
829892 this.sql.exec("INSERT INTO jobs (id, run_id, job_key, name, status, conclusion, started_at, completed_at, failure) VALUES (?, ?, 'setup', 'Set up workflow', 'completed', 'failure', ?, ?, ?)", newId(), runId, t, t, JSON.stringify({ message }));
modifiedapps/diffinery/test/e2e/e2e.test.mjs+48−0View file
@@ -452,6 +452,54 @@
452452 assert.deepEqual(jobs.filter((j) => j.conclusion === "skipped").map((j) => j.name).sort(), ["nope / say", "nope / shout"]);
453453 });
454454
455+test("workflow_run: a workflow follows another one on the default branch", async () => {
456+ const owner = (await ok("GET", "/user")).login;
457+ await ok("POST", "/user/repos", { name: "chain" });
458+ const src = join(work, "chain");
459+ mkdirSync(join(src, ".github/workflows"), { recursive: true });
460+ writeFileSync(join(src, ".github/workflows/ci.yml"), `name: CI\non: push\njobs:\n build:\n runs-on: [self-hosted, linux]\n steps:\n - run: echo built\n`);
461+ writeFileSync(
462+ join(src, ".github/workflows/after.yml"),
463+ `name: After CI
464+on:
465+ workflow_run:
466+ workflows: [CI]
467+ types: [completed]
468+ branches: [main]
469+jobs:
470+ report:
471+ if: \${{ github.event.workflow_run.conclusion == 'success' }}
472+ runs-on: [self-hosted, linux]
473+ steps:
474+ - run: echo "followed \${{ github.event.workflow_run.name }} on \${{ github.event.workflow_run.head_branch }} (\${{ github.event.action }}) ref=\${{ github.ref }}"
475+`,
476+ );
477+ git(src, ["init", "-q", "-b", "main"]);
478+ git(src, ["add", "."]);
479+ git(src, ["commit", "-q", "-m", "chain"]);
480+ git(src, ["remote", "add", "origin", `${base}/${owner}/chain.git`]);
481+ git(src, ["push", "-q", "origin", "main"]);
482+
483+ const runs = async () => (await ok("GET", `/repos/${owner}/chain/actions/runs`)).workflow_runs;
484+ await until("CI run", async () => (await runs()).find((r) => r.name === "CI"));
485+ assert.equal((await runs()).length, 1, "only CI runs before it completes");
486+ const build = await runOneJob(owner, "chain");
487+ assert.equal(build.claim.name, "build");
488+ const follower = await until("follower run", async () => (await runs()).find((r) => r.event === "workflow_run"));
489+ assert.equal(follower.name, "After CI");
490+ assert.equal(follower.head_branch, "main");
491+ const report = await runOneJob(owner, "chain");
492+ assert.equal(report.claim.name, "report");
493+ const log = await (await fetch(`${base}/api/v3/repos/${owner}/chain/actions/jobs/${report.claim.job_id}/logs`, { headers: { authorization: `Bearer ${token}` } })).text();
494+ assert.match(log, /followed CI on main \(completed\) ref=refs\/heads\/main/);
495+ const done = await until("follower to complete", async () => {
496+ const r = await ok("GET", `/repos/${owner}/chain/actions/runs/${follower.id}`);
497+ return r.status === "completed" ? r : null;
498+ });
499+ assert.equal(done.conclusion, "success");
500+ assert.equal((await runs()).length, 2, "the follower does not trigger itself");
501+});
502+
455503 const hasDocker = spawnSync("docker", ["info"], { stdio: "ignore", timeout: 15_000 }).status === 0;
456504
457505 test("containers: a job container and service containers on a self-hosted runner", { skip: !hasDocker && "Docker is not available" }, async () => {
modifiedcontainers/runner/Dockerfile+2−1View file
@@ -20,10 +20,11 @@
2020
2121 RUN apt-get update \
2222 && apt-get install -y --no-install-recommends \
23- ca-certificates curl git jq zip unzip xz-utils tar gzip \
23+ ca-certificates curl git git-lfs jq zip unzip xz-utils tar gzip \
2424 build-essential pkg-config openssh-client gnupg \
2525 python3 python3-pip python3-venv python-is-python3 \
2626 && git config --system --add safe.directory '*' \
27+ && git lfs install --system --skip-repo \
2728 && apt-get clean \
2829 && rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
2930