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.

159 lines · 7.5 KBRaw History
1import { execFileSync } from "node:child_process";
2import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3import path from "node:path";
4import { afterEach, beforeEach, describe, expect, it } from "vitest";
5import type { JobSpec } from "@diffinery/shared";
6import { apiBaseFor, runAgentJob } from "../src/agent.js";
7import { makeSpec, startFakeApi, tmp, type FakeApi } from "./helpers.js";
8
9/** Job containers, service containers and Docker actions; skipped where Docker isn't available. */
10function dockerWorks(): boolean {
11 try {
12 execFileSync("docker", ["info", "--format", "{{.ServerVersion}}"], { stdio: "ignore", timeout: 15_000 });
13 return true;
14 } catch {
15 return false;
16 }
17}
18
19// Docker Hub rate-limits anonymous pulls; its official images are mirrored here.
20const ALPINE = "mirror.gcr.io/library/alpine:3.20";
21const DEBIAN = "mirror.gcr.io/library/debian:bookworm-slim";
22const REDIS = "mirror.gcr.io/library/redis:7-alpine";
23
24let api: FakeApi;
25let workRoot: string;
26
27beforeEach(async () => {
28 api = await startFakeApi();
29 workRoot = tmp();
30});
31afterEach(async () => {
32 await api.close();
33 // Containers may leave root-owned files behind; clean up through Docker.
34 if (dockerWorks()) execFileSync("docker", ["run", "--rm", "-v", `${workRoot}:/w`, ALPINE, "sh", "-c", "rm -rf /w/* /w/.[!.]*"], { stdio: "ignore" });
35 rmSync(workRoot, { recursive: true, force: true });
36});
37
38function run(spec: JobSpec, baseEnv?: NodeJS.ProcessEnv) {
39 return runAgentJob(spec, { apiBase: apiBaseFor(spec.apiUrl, spec.jobId), workRoot, toolCache: path.join(workRoot, "_tool"), logIntervalMs: 50, echoLogs: false, baseEnv });
40}
41
42const leftovers = (jobId: string) =>
43 execFileSync("docker", ["ps", "-aq", "--filter", `label=diffinery.job=${jobId}`], { encoding: "utf8" }).trim() +
44 execFileSync("docker", ["network", "ls", "-q", "--filter", `label=diffinery.job=${jobId}`], { encoding: "utf8" }).trim();
45
46const redisService = { image: REDIS, ports: [6379], options: "--health-cmd 'redis-cli ping' --health-interval 1s --health-timeout 2s --health-retries 20" };
47
48describe.skipIf(!dockerWorks())("docker", () => {
49 it("runs steps and JavaScript actions in a job container next to a service container", async () => {
50 const action = `name: probe
51outputs:
52 version: { description: node }
53runs:
54 using: node20
55 main: index.js
56`;
57 const js = `const fs = require("fs");
58const os = fs.readFileSync("/etc/os-release", "utf8").includes("bookworm");
59fs.appendFileSync(process.env.GITHUB_OUTPUT, "version=" + process.version + "\\n");
60console.log("node action in debian=" + os + " input=" + process.env.INPUT_WHO);
61`;
62 const spec = makeSpec(
63 api,
64 [
65 {
66 run: [
67 'grep -q bookworm /etc/os-release && echo "in debian, home=$HOME, ws=$GITHUB_WORKSPACE, greeting=$GREETING"',
68 "exec 3<>/dev/tcp/redis/6379",
69 "printf 'PING\\r\\n' >&3",
70 "read -r line <&3",
71 'echo "redis says ${line%$\'\\r\'}"',
72 'echo "FROM_STEP=yes" >> "$GITHUB_ENV"',
73 "mkdir -p .github/probe",
74 `cat > .github/probe/action.yml <<'EOF'\n${action}EOF`,
75 `cat > .github/probe/index.js <<'EOF'\n${js}EOF`,
76 ].join("\n"),
77 },
78 { id: "probe", uses: "./.github/probe", with: { who: "octo" } },
79 { run: 'echo "env=$FROM_STEP node=${{ steps.probe.outputs.version }} container=${{ job.container.id != \'\' }} port=${{ job.services.redis.ports[\'6379\'] != \'\' }}"' },
80 ],
81 { jobId: "dkr-container", container: { image: DEBIAN, env: { GREETING: "hi" } }, services: { redis: redisService } },
82 );
83 const c = await run(spec);
84 const log = api.logText();
85 expect(c.conclusion, log).toBe("success");
86 expect(log).toContain(`in debian, home=/github/home, ws=${path.join(workRoot, "widgets")}, greeting=hi`);
87 expect(log).toContain("redis says +PONG");
88 expect(log).toContain("node action in debian=true input=octo");
89 expect(log).toMatch(/env=yes node=v\d+\.\d+\.\d+ container=true port=true/);
90 expect(leftovers("dkr-container")).toBe("");
91 }, 180_000);
92
93 it("runs docker:// steps and Dockerfile actions with inputs, outputs, args and post entrypoints", async () => {
94 // Written up front: a `run:` script would expand the action's own ${{ inputs.who }}.
95 const dir = path.join(workRoot, "widgets", ".github", "hello");
96 mkdirSync(dir, { recursive: true });
97 writeFileSync(
98 path.join(dir, "action.yml"),
99 `name: hello
100inputs:
101 who: { description: who, default: world }
102runs:
103 using: docker
104 image: Dockerfile
105 args: ["\${{ inputs.who }}", "two words"]
106 post-entrypoint: /post.sh
107`,
108 );
109 writeFileSync(path.join(dir, "Dockerfile"), `FROM ${ALPINE}\nCOPY entrypoint.sh post.sh /\nRUN chmod +x /entrypoint.sh /post.sh\nENTRYPOINT ["/entrypoint.sh"]\n`);
110 writeFileSync(
111 path.join(dir, "entrypoint.sh"),
112 '#!/bin/sh\necho "hello $1 [$2] input=$INPUT_WHO ws=$GITHUB_WORKSPACE pwd=$(pwd)"\necho "said=hello-$1" >> "$GITHUB_OUTPUT"\necho "FROM_DOCKER=1" >> "$GITHUB_ENV"\nprintf "PING\\r\\n" | nc -w 2 redis 6379\n',
113 );
114 writeFileSync(path.join(dir, "post.sh"), '#!/bin/sh\necho "post ran"\n');
115 const spec = makeSpec(
116 api,
117 [
118 { id: "hello", uses: "./.github/hello", with: { who: "octo" } },
119 { id: "raw", uses: `docker://${ALPINE}`, with: { args: "sh -c 'echo raw $INPUT_COLOR > /github/workspace/raw.txt; echo color=$INPUT_COLOR >> $GITHUB_OUTPUT'", color: "blue" } },
120 { uses: `docker://${ALPINE}`, with: { entrypoint: "/bin/false" }, "continue-on-error": true },
121 { run: 'echo "said=${{ steps.hello.outputs.said }} color=${{ steps.raw.outputs.color }} env=$FROM_DOCKER file=$(cat raw.txt)"' },
122 ],
123 { jobId: "dkr-actions", services: { redis: redisService } },
124 );
125 const c = await run(spec);
126 const log = api.logText();
127 expect(c.conclusion, log).toBe("success");
128 expect(log).toContain("hello octo [two words] input=octo ws=/github/workspace pwd=/github/workspace");
129 expect(log).toContain("+PONG");
130 expect(log).toContain("Docker run failed with exit code 1");
131 expect(log).toContain("said=hello-octo color=blue env=1 file=raw blue");
132 expect(log).toContain("post ran");
133 expect(readFileSync(path.join(workRoot, "widgets", "raw.txt"), "utf8")).toBe("raw blue\n");
134 expect(leftovers("dkr-actions")).toBe("");
135 }, 180_000);
136
137 it("fails a service that never gets healthy, and cleans up", async () => {
138 const spec = makeSpec(api, [{ run: "echo never" }], {
139 jobId: "dkr-unhealthy",
140 services: { bad: { image: REDIS, options: "--health-cmd false --health-interval 1s --health-retries 2" } },
141 });
142 const c = await run(spec);
143 expect(c.conclusion).toBe("failure");
144 expect(c.failure?.message).toContain("Service container bad failed its health check");
145 expect(api.logText()).not.toContain("never");
146 expect(leftovers("dkr-unhealthy")).toBe("");
147 }, 120_000);
148});
149
150describe("docker missing", () => {
151 it("explains that containers need Docker", async () => {
152 const spec = makeSpec(api, [{ run: "echo hi" }], { jobId: "no-docker", services: { redis: { image: REDIS } } });
153 const c = await run(spec, { PATH: path.join(workRoot, "empty-bin") });
154 expect(c.conclusion).toBe("failure");
155 expect(c.failure?.message).toContain("Service containers (jobs.<id>.services) need Docker");
156 const c2 = await run(makeSpec(api, [{ uses: `docker://${ALPINE}` }], { jobId: "no-docker-2" }), { PATH: path.join(workRoot, "empty-bin") });
157 expect(c2.failure?.message).toContain("Docker container actions need Docker, and this runner has no docker CLI");
158 });
159});