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)
- .github/workflows/dind-probe.yml+67−0
- README.md+2−2
- apps/diffinery/scripts/dind-probe/Dockerfile+15−0
- apps/diffinery/scripts/dind-probe/probe.sh+41−0
- apps/diffinery/scripts/dind-probe/server.mjs+14−0
- apps/diffinery/scripts/dind-probe/worker.js+11−0
- apps/diffinery/src/repo/actions.ts+63−0
- apps/diffinery/test/e2e/e2e.test.mjs+48−0
- containers/runner/Dockerfile+2−1
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 @@ | ||
| 123 | 123 | |
| 124 | 124 | Put workflow files in `.github/workflows/` (or `.diffinery/workflows/`). |
| 125 | 125 | 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: | |
| 128 | 128 | |
| 129 | 129 | - `needs` and job outputs, `if` with `success()`, `failure()`, `always()` and |
| 130 | 130 | `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 @@ | ||
| 439 | 439 | const m = this.repo.meta; |
| 440 | 440 | 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 }); |
| 441 | 441 | this.repo.broadcast("runs", { action: "created", runId: id }); |
| 442 | + await this.onWorkflowRun(run, "requested"); | |
| 442 | 443 | return toRun(run); |
| 443 | 444 | } |
| 444 | 445 | |
| @@ -821,9 +822,71 @@ | ||
| 821 | 822 | 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 }); |
| 822 | 823 | this.repo.broadcast(`run:${runId}`, { action: "completed", conclusion }); |
| 823 | 824 | this.repo.broadcast("runs", { action: "completed", runId }); |
| 825 | + await this.onWorkflowRun(this.runRow(runId), "completed"); | |
| 824 | 826 | return toRun(this.runRow(runId)); |
| 825 | 827 | } |
| 826 | 828 | |
| 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 | + | |
| 827 | 890 | async plannedError(runId: string, message: string): Promise<void> { |
| 828 | 891 | const t = now(); |
| 829 | 892 | 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 @@ | ||
| 452 | 452 | assert.deepEqual(jobs.filter((j) => j.conclusion === "skipped").map((j) => j.name).sort(), ["nope / say", "nope / shout"]); |
| 453 | 453 | }); |
| 454 | 454 | |
| 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 | + | |
| 455 | 503 | const hasDocker = spawnSync("docker", ["info"], { stdio: "ignore", timeout: 15_000 }).status === 0; |
| 456 | 504 | |
| 457 | 505 | 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 @@ | ||
| 20 | 20 | |
| 21 | 21 | RUN apt-get update \ |
| 22 | 22 | && 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 \ | |
| 24 | 24 | build-essential pkg-config openssh-client gnupg \ |
| 25 | 25 | python3 python3-pip python3-venv python-is-python3 \ |
| 26 | 26 | && git config --system --add safe.directory '*' \ |
| 27 | + && git lfs install --system --skip-repo \ | |
| 27 | 28 | && apt-get clean \ |
| 28 | 29 | && rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* |
| 29 | 30 |