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.

513 lines · 25.1 KBRaw History
1// End-to-end: a real `wrangler dev` Worker, the real git CLI and the real runner agent.
2//
3// pnpm --filter @diffinery/runner build && pnpm --filter @diffinery/app e2e
4//
5// Covers: token auth, repo creation, git push/clone/fetch (protocol v2), push
6// protection, workflows triggered by push and pull_request and executed by a
7// self-hosted runner (the same agent that runs in Cloudflare Containers),
8// check runs, branch protection with required checks and human review for
9// agent-authored PRs, merging, the REST contents API and the MCP server.
10import { spawn, spawnSync, execFileSync } from "node:child_process";
11import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, existsSync, rmSync } from "node:fs";
12import { tmpdir } from "node:os";
13import { randomBytes } from "node:crypto";
14import { join, dirname, resolve } from "node:path";
15import { fileURLToPath } from "node:url";
16import { createServer } from "node:net";
17import { test, before, after } from "node:test";
18import assert from "node:assert/strict";
19
20const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
21const AGENT = resolve(APP, "../../containers/runner/dist/agent.mjs");
22const work = mkdtempSync(join(tmpdir(), "diffinery-e2e-"));
23let base;
24let dev;
25let devLog = "";
26let token;
27let agentToken;
28
29const freePort = () =>
30 new Promise((res) => {
31 const s = createServer();
32 s.listen(0, "127.0.0.1", () => {
33 const { port } = s.address();
34 s.close(() => res(port));
35 });
36 });
37
38async function api(method, path, body, tok = token) {
39 const res = await fetch(`${base}/api/v3${path}`, {
40 method,
41 headers: { authorization: `Bearer ${tok}`, "content-type": "application/json" },
42 body: body === undefined ? undefined : JSON.stringify(body),
43 });
44 const text = await res.text();
45 let json = null;
46 try {
47 json = text ? JSON.parse(text) : null;
48 } catch {
49 json = text;
50 }
51 return { status: res.status, json };
52}
53async function ok(method, path, body, tok) {
54 const r = await api(method, path, body, tok);
55 assert.ok(r.status >= 200 && r.status < 300, `${method} ${path} -> ${r.status} ${JSON.stringify(r.json)}`);
56 return r.json;
57}
58
59function git(cwd, args, tok = token, opts = {}) {
60 const auth = Buffer.from(`x-token:${tok}`).toString("base64");
61 return execFileSync("git", ["-c", `http.extraHeader=Authorization: Basic ${auth}`, "-c", "protocol.version=2", ...args], {
62 cwd,
63 encoding: "utf8",
64 stdio: ["ignore", "pipe", "pipe"],
65 env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_AUTHOR_NAME: "E2E", GIT_AUTHOR_EMAIL: "e2e@example.com", GIT_COMMITTER_NAME: "E2E", GIT_COMMITTER_EMAIL: "e2e@example.com" },
66 ...opts,
67 });
68}
69function gitFails(cwd, args, tok) {
70 try {
71 git(cwd, args, tok);
72 } catch (e) {
73 return `${e.stdout ?? ""}${e.stderr ?? ""}`;
74 }
75 assert.fail(`expected git ${args.join(" ")} to fail`);
76}
77
78const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
79async function until(what, fn, timeoutMs = 60_000) {
80 const end = Date.now() + timeoutMs;
81 let last;
82 while (Date.now() < end) {
83 last = await fn();
84 if (last) return last;
85 await sleep(500);
86 }
87 assert.fail(`timed out waiting for ${what}\n--- wrangler log tail ---\n${devLog.slice(-4000)}`);
88}
89
90function spawnAsync(cmd, args, env, timeoutMs) {
91 return new Promise((done) => {
92 const child = spawn(cmd, args, { env: { ...process.env, ...env } });
93 let stdout = "";
94 let stderr = "";
95 child.stdout.on("data", (d) => (stdout += d));
96 child.stderr.on("data", (d) => (stderr += d));
97 const t = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
98 child.on("close", (status) => {
99 clearTimeout(t);
100 done({ status, stdout, stderr });
101 });
102 });
103}
104
105/** Claim one waiting self-hosted job and run it with the real agent. */
106async function runOneJob(owner, repo) {
107 const claim = await until("a job to claim", async () => {
108 const r = await api("POST", `/repos/${owner}/${repo}/actions/runners/claim`, { name: "e2e", labels: ["linux"] });
109 return r.status === 200 ? r.json : null;
110 });
111 const jobEnv = { DIFFINERY_API_URL: claim.api_url, DIFFINERY_JOB_ID: claim.job_id, DIFFINERY_JOB_TOKEN: claim.token };
112 // E2E_RUNNER_IMAGE runs the job inside the real runner image instead of the local agent bundle.
113 const image = process.env.E2E_RUNNER_IMAGE;
114 // Spawned asynchronously: a blocked event loop leaves undici holding keep-alive sockets the server already closed.
115 const r = image
116 ? await spawnAsync("docker", ["run", "--rm", "--network", "host", ...Object.entries(jobEnv).flatMap(([k, v]) => ["-e", `${k}=${v}`]), image], {}, 300_000)
117 : await spawnAsync(process.execPath, [AGENT], { ...process.env, ...jobEnv, DIFFINERY_WORK_ROOT: join(work, `job-${claim.job_id}`) }, 180_000);
118 const out = `${r.stdout}${r.stderr}`;
119 if (r.status !== 0) {
120 const log = await fetch(`${base}/api/v3/repos/${owner}/${repo}/actions/jobs/${claim.job_id}/logs`, { headers: { authorization: `Bearer ${token}` } }).then((x) => x.text(), (e) => String(e));
121 const job = await api("GET", `/repos/${owner}/${repo}/actions/jobs/${claim.job_id}`).catch((e) => ({ json: String(e) }));
122 assert.fail(`runner agent exited ${r.status}:\n${out}\n--- job ---\n${JSON.stringify(job.json, null, 2)}\n--- job log ---\n${log}\n--- wrangler log tail ---\n${devLog.slice(-3000)}`);
123 }
124 return { claim, out };
125}
126
127before(async () => {
128 assert.ok(existsSync(AGENT), `build the runner first: pnpm --filter @diffinery/runner build (missing ${AGENT})`);
129 const port = await freePort();
130 base = `http://127.0.0.1:${port}`;
131 const persist = join(work, "state");
132 execFileSync("pnpm", ["exec", "wrangler", "d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
133 // Until the web UI exists, run the same app through the API-only entry.
134 const entry = existsSync(join(APP, "src/web/routes.tsx")) ? [] : ["test/e2e/entry.ts"];
135 dev = spawn("pnpm", ["exec", "wrangler", "dev", ...entry, "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--var", `MASTER_KEY:${randomBytes(32).toString("base64")}`, "--show-interactive-dev-session=false"], {
136 cwd: APP,
137 env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" },
138 stdio: ["ignore", "pipe", "pipe"],
139 detached: true,
140 });
141 dev.stdout.on("data", (d) => (devLog += d));
142 dev.stderr.on("data", (d) => (devLog += d));
143 await until("wrangler dev", async () => {
144 try {
145 return (await fetch(`${base}/healthz`)).ok;
146 } catch {
147 return false;
148 }
149 }, 120_000);
150 // DEV_AUTH signs requests without credentials in as dev@diffinery.local; mint a real token for everything else.
151 const res = await fetch(`${base}/api/v3/diffinery/tokens`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "e2e", scopes: ["admin"] }) });
152 assert.equal(res.status, 201, await res.clone().text());
153 token = (await res.json()).token;
154});
155
156after(() => {
157 if (dev?.pid) {
158 try {
159 process.kill(-dev.pid, "SIGTERM");
160 } catch {}
161 }
162 if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true });
163});
164
165test("git hosting, Actions, protected PR flow and MCP", async () => {
166 const me = await ok("GET", "/user");
167 const owner = me.login;
168 assert.ok(me.site_admin, "dev user is a bootstrap admin");
169
170 // ------------------------------------------------------------ repo + first push
171 const repo = await ok("POST", "/user/repos", { name: "demo", description: "e2e" });
172 assert.equal(repo.full_name, `${owner}/demo`);
173 const src = join(work, "src");
174 mkdirSync(join(src, ".github/workflows"), { recursive: true });
175 writeFileSync(join(src, "README.md"), "# demo\n");
176 writeFileSync(join(src, "app.txt"), "one\ntwo\nthree\n");
177 writeFileSync(
178 join(src, ".github/workflows/ci.yml"),
179 `name: CI
180on:
181 push:
182 branches: [main]
183 pull_request:
184jobs:
185 build:
186 runs-on: [self-hosted, linux]
187 steps:
188 - uses: actions/checkout@v4
189 - name: Check files
190 run: |
191 test -f README.md
192 echo "event=\${{ github.event_name }} sha=\${{ github.sha }}"
193 grep -q bad app.txt && { echo "found bad line"; exit 1; } || true
194 - id: out
195 run: echo "lines=$(wc -l < app.txt | tr -d ' ')" >> "$GITHUB_OUTPUT"
196 - run: echo "app.txt has \${{ steps.out.outputs.lines }} lines"
197`,
198 );
199 git(src, ["init", "-q", "-b", "main"]);
200 git(src, ["add", "-A"]);
201 git(src, ["commit", "-q", "-m", "initial"]);
202 git(src, ["remote", "add", "origin", `${base}/${owner}/demo.git`]);
203 git(src, ["push", "-q", "origin", "main"]);
204 const mainSha = git(src, ["rev-parse", "HEAD"]).trim();
205
206 const branch = await ok("GET", `/repos/${owner}/demo/branches/main`);
207 assert.equal(branch.commit.sha, mainSha);
208
209 // ------------------------------------------------------------ the push ran CI on a self-hosted runner
210 const pushRun = await until("push run", async () => (await ok("GET", `/repos/${owner}/demo/actions/runs?event=push`)).workflow_runs[0]);
211 assert.equal(pushRun.head_sha, mainSha);
212 const job1 = await runOneJob(owner, "demo");
213 const finished = await until("push run to complete", async () => {
214 const r = await ok("GET", `/repos/${owner}/demo/actions/runs/${pushRun.id}`);
215 return r.status === "completed" ? r : null;
216 });
217 assert.equal(finished.conclusion, "success", job1.out);
218 const logs = await (await fetch(`${base}/api/v3/repos/${owner}/demo/actions/jobs/${job1.claim.job_id}/logs`, { headers: { authorization: `Bearer ${token}` } })).text();
219 assert.match(logs, /event=push/);
220 assert.match(logs, /app\.txt has 3 lines/);
221 const checks = await ok("GET", `/repos/${owner}/demo/commits/${mainSha}/check-runs`);
222 assert.equal(checks.check_runs.find((c) => c.name === "CI / build")?.conclusion, "success");
223
224 // ------------------------------------------------------------ clone over protocol v2
225 const clone = join(work, "clone");
226 git(work, ["clone", "-q", `${base}/${owner}/demo.git`, clone]);
227 assert.equal(readFileSync(join(clone, "app.txt"), "utf8"), "one\ntwo\nthree\n");
228
229 // ------------------------------------------------------------ secrets are blocked on push
230 writeFileSync(join(clone, "config.env"), "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPL3\n");
231 git(clone, ["add", "-A"]);
232 git(clone, ["commit", "-q", "-m", "oops"]);
233 assert.match(gitFails(clone, ["push", "origin", "main"]), /secret|AWS/i);
234 git(clone, ["reset", "-q", "--hard", "origin/main"]);
235
236 // ------------------------------------------------------------ protect main: PR + green CI + review
237 await ok("PUT", `/repos/${owner}/demo/branches/main/protection`, {
238 requirePullRequest: true,
239 requiredApprovals: 0,
240 dismissStaleReviews: true,
241 requiredChecks: ["CI / build"],
242 allowForcePush: false,
243 allowDeletion: false,
244 requireLinearHistory: false,
245 enforceAdmins: true,
246 agentsMayPush: false,
247 agentsMayMerge: false,
248 });
249 writeFileSync(join(clone, "app.txt"), "one\ntwo\nthree\nfour\n");
250 git(clone, ["commit", "-q", "-am", "direct push"]);
251 assert.match(gitFails(clone, ["push", "origin", "main"]), /pull request|protected/i);
252 git(clone, ["reset", "-q", "--hard", "origin/main"]);
253
254 // ------------------------------------------------------------ an AI agent opens a PR
255 const created = await ok("POST", "/diffinery/agents", { login: "e2e-bot", name: "E2E Bot" });
256 agentToken = created.token.token;
257 const agentClone = join(work, "agent");
258 git(work, ["clone", "-q", `${base}/${owner}/demo.git`, agentClone], agentToken);
259 git(agentClone, ["checkout", "-q", "-b", "feature"], agentToken);
260 writeFileSync(join(agentClone, "app.txt"), "one\ntwo\nthree\nfour\n");
261 git(agentClone, ["commit", "-q", "-am", "Add a fourth line"], agentToken);
262 assert.match(gitFails(agentClone, ["push", "origin", "HEAD:main"], agentToken), /agent|protected|pull request/i);
263 // push.negotiate=true makes git run a protocol v2 fetch with wait-for-done before pushing.
264 const negotiated = spawnSync("git", ["-c", `http.extraHeader=Authorization: Basic ${Buffer.from(`x-token:${agentToken}`).toString("base64")}`, "-c", "protocol.version=2", "-c", "push.negotiate=true", "push", "-q", "origin", "feature"], { cwd: agentClone, encoding: "utf8", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
265 assert.equal(negotiated.status, 0, negotiated.stderr);
266 assert.doesNotMatch(negotiated.stderr, /negotiation failed|unexpected/i);
267 const pr = await ok("POST", `/repos/${owner}/demo/pulls`, { title: "Add a fourth line", head: "feature", base: "main", intent: "Extend app.txt for the e2e test" }, agentToken);
268 assert.equal(pr.number, 1);
269
270 // The pull_request run executes; until then the PR is blocked on the required check.
271 let m = await ok("GET", `/repos/${owner}/demo/pulls/1/merge`);
272 assert.equal(m.mergeable, false);
273 await runOneJob(owner, "demo");
274 await until("PR check to pass", async () => {
275 const r = await ok("GET", `/repos/${owner}/demo/pulls/1/merge`);
276 return r.checks?.length > 0 && r.checks.every((c) => c.state === "success") ? r : null;
277 });
278 m = await ok("GET", `/repos/${owner}/demo/pulls/1/merge`);
279 assert.equal(m.mergeable, false, "agent-authored PRs need a human approval");
280 assert.ok(JSON.stringify(m.blockers).match(/approv|human/i), JSON.stringify(m.blockers));
281
282 // The agent cannot approve or merge its own work.
283 const agentMerge = await api("PUT", `/repos/${owner}/demo/pulls/1/merge`, { merge_method: "squash" }, agentToken);
284 assert.ok([403, 405, 409].includes(agentMerge.status), JSON.stringify(agentMerge));
285
286 // Another agent's approval is advisory and does not unblock the merge.
287 const reviewer = (await ok("POST", "/diffinery/agents", { login: "e2e-reviewer" })).token.token;
288 await ok("POST", `/repos/${owner}/demo/pulls/1/reviews`, { event: "APPROVE", body: "bot says ok" }, reviewer);
289 assert.equal((await ok("GET", `/repos/${owner}/demo/pulls/1/merge`)).mergeable, false);
290
291 // A human reviews (line comment + approval) and merges.
292 await ok("POST", `/repos/${owner}/demo/pulls/1/reviews`, { event: "APPROVE", body: "LGTM", comments: [{ path: "app.txt", line: 4, body: "nice line" }] });
293 m = await ok("GET", `/repos/${owner}/demo/pulls/1/merge`);
294 assert.equal(m.mergeable, true, JSON.stringify(m));
295 const merged = await ok("PUT", `/repos/${owner}/demo/pulls/1/merge`, { merge_method: "squash", delete_branch: true });
296 assert.ok(merged.sha);
297 const prAfter = await ok("GET", `/repos/${owner}/demo/pulls/1`);
298 assert.equal(prAfter.merged, true);
299
300 // ------------------------------------------------------------ fetch the merge, read through REST
301 git(clone, ["pull", "-q", "--ff-only", "origin", "main"]);
302 assert.equal(readFileSync(join(clone, "app.txt"), "utf8"), "one\ntwo\nthree\nfour\n");
303 const content = await ok("GET", `/repos/${owner}/demo/contents/app.txt`);
304 assert.equal(Buffer.from(content.content, "base64").toString(), "one\ntwo\nthree\nfour\n");
305 const branches = await ok("GET", `/repos/${owner}/demo/branches`);
306 assert.ok(!branches.some((b) => b.name === "feature"), "head branch deleted after merge");
307
308 // ------------------------------------------------------------ shallow clone, then deepen
309 const shallow = join(work, "shallow");
310 git(work, ["clone", "-q", "--depth", "1", `${base}/${owner}/demo.git`, shallow]);
311 assert.equal(git(shallow, ["rev-list", "--count", "HEAD"]).trim(), "1");
312 assert.equal(readFileSync(join(shallow, "app.txt"), "utf8"), "one\ntwo\nthree\nfour\n");
313 git(shallow, ["fetch", "-q", "--unshallow"]);
314 assert.equal(git(shallow, ["rev-list", "--count", "HEAD"]).trim(), "2");
315 git(shallow, ["fsck", "--no-progress"]);
316
317 // ------------------------------------------------------------ MCP: the agent works without git
318 const rpc = async (method, params, id = 1) => {
319 const r = await fetch(`${base}/mcp`, { method: "POST", headers: { authorization: `Bearer ${agentToken}`, "content-type": "application/json", accept: "application/json, text/event-stream" }, body: JSON.stringify({ jsonrpc: "2.0", id, method, params }) });
320 assert.equal(r.status, 200);
321 return (await r.json()).result;
322 };
323 const init = await rpc("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "e2e", version: "1" } });
324 assert.equal(init.serverInfo.name, "diffinery");
325 const tools = await rpc("tools/list", {});
326 assert.ok(tools.tools.some((t) => t.name === "commit_files"));
327 const file = await rpc("tools/call", { name: "get_file", arguments: { owner, repo: "demo", path: "README.md" } });
328 assert.equal(file.isError, false);
329 assert.equal(file.structuredContent.content, "# demo\n");
330 const commit = await rpc("tools/call", { name: "commit_files", arguments: { owner, repo: "demo", branch: "mcp-change", create_branch_from: "main", message: "Edit via MCP", files: [{ path: "docs/notes.md", content: "hello\n" }] } });
331 assert.equal(commit.isError, false, JSON.stringify(commit));
332 const blocked = await rpc("tools/call", { name: "commit_files", arguments: { owner, repo: "demo", branch: "main", message: "sneaky", files: [{ path: "x", content: "y" }] } });
333 assert.equal(blocked.isError, true);
334 const opened = await rpc("tools/call", { name: "create_pull_request", arguments: { owner, repo: "demo", title: "Notes", head: "mcp-change", intent: "Add notes" } });
335 assert.equal(opened.isError, false, JSON.stringify(opened));
336 assert.equal(opened.structuredContent.number, 2);
337
338 // ------------------------------------------------------------ web UI renders the same data (signed in via DEV_AUTH)
339 for (const path of ["/", `/${owner}`, `/${owner}/demo`, `/${owner}/demo/blob/main/app.txt`, `/${owner}/demo/commits/main`, `/${owner}/demo/pulls`, `/${owner}/demo/pull/1`, `/${owner}/demo/pull/1/files`, `/${owner}/demo/actions`, `/${owner}/demo/actions/runs/${pushRun.id}`, `/${owner}/demo/actions/runs/${pushRun.id}/job/${job1.claim.job_id}`, `/${owner}/demo/settings/branches`, "/settings/tokens"]) {
340 const res = await fetch(`${base}${path}`);
341 const html = await res.text();
342 assert.equal(res.status, 200, `${path} -> ${res.status}\n${html.slice(0, 500)}`);
343 assert.match(res.headers.get("content-type") ?? "", /text\/html/, path);
344 }
345 assert.match(await (await fetch(`${base}/${owner}/demo/pull/1`)).text(), /Add a fourth line/);
346 const forged = await fetch(`${base}/${owner}/demo/issues/1/comment`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", origin: "https://evil.example", "sec-fetch-site": "cross-site" }, body: "body=hi" });
347 assert.equal(forged.status, 403);
348
349 // ------------------------------------------------------------ the CLI against the same server
350 const CLI = resolve(APP, "../cli/dist/diffinery.mjs");
351 if (existsSync(CLI)) {
352 const cli = (...args) => execFileSync(process.execPath, [CLI, ...args], { encoding: "utf8", cwd: clone, env: { ...process.env, DIFFINERY_URL: base, DIFFINERY_TOKEN: token, NO_COLOR: "1", XDG_CONFIG_HOME: join(work, "cfg") } });
353 assert.match(cli("whoami"), new RegExp(owner));
354 const prs = JSON.parse(cli("pr", "list", "--state", "all", "--json"));
355 assert.ok(prs.some((p) => p.number === 1));
356 assert.match(cli("workflow", "validate"), /ci\.yml/);
357 }
358
359 // The commit made through MCP carries the agent's identity.
360 const agentCommit = await ok("GET", `/repos/${owner}/demo/commits/mcp-change`);
361 assert.match(JSON.stringify(agentCommit), /e2e-bot/);
362});
363
364test("reusable workflows: inputs, secrets, outputs, nesting and skipped calls", async () => {
365 const owner = (await ok("GET", "/user")).login;
366 await ok("POST", "/user/repos", { name: "reuse" });
367 await api("PUT", `/repos/${owner}/reuse/actions/secrets/TOPSECRET`, { value: "s3cret" });
368 const src = join(work, "reuse");
369 mkdirSync(join(src, ".github/workflows"), { recursive: true });
370 writeFileSync(
371 join(src, ".github/workflows/lib.yml"),
372 `on:
373 workflow_call:
374 inputs:
375 greeting: { type: string, required: true }
376 loud: { type: boolean, default: false }
377 secrets:
378 TOPSECRET: { required: true }
379 outputs:
380 word:
381 value: \${{ jobs.shout.outputs.word }}
382jobs:
383 say:
384 runs-on: [self-hosted, linux]
385 outputs:
386 word: \${{ steps.s.outputs.word }}
387 steps:
388 - id: s
389 run: echo "word=\${{ inputs.greeting }}-there" >> "$GITHUB_OUTPUT"
390 - run: echo "secret ok=\${{ secrets.TOPSECRET == 's3cret' }} loud=\${{ inputs.loud }} job=\${{ github.job }}"
391 shout:
392 needs: say
393 runs-on: [self-hosted, linux]
394 outputs:
395 word: \${{ steps.s.outputs.word }}
396 steps:
397 - id: s
398 run: echo "word=\${{ needs.say.outputs.word }}!" >> "$GITHUB_OUTPUT"
399`,
400 );
401 writeFileSync(
402 join(src, ".github/workflows/main.yml"),
403 `name: Main
404on: push
405jobs:
406 call:
407 name: Greeter
408 uses: ./.github/workflows/lib.yml
409 with:
410 greeting: \${{ github.event_name == 'push' && 'hi' || 'no' }}
411 loud: true
412 secrets: inherit
413 nope:
414 if: \${{ false }}
415 uses: ./.github/workflows/lib.yml
416 with:
417 greeting: never
418 secrets:
419 TOPSECRET: \${{ secrets.TOPSECRET }}
420 after:
421 needs: [call, nope]
422 if: \${{ always() }}
423 runs-on: [self-hosted, linux]
424 steps:
425 - run: echo "got=\${{ needs.call.outputs.word }} call=\${{ needs.call.result }} nope=\${{ needs.nope.result }}"
426`,
427 );
428 git(src, ["init", "-q", "-b", "main"]);
429 git(src, ["add", "."]);
430 git(src, ["commit", "-q", "-m", "reusable"]);
431 git(src, ["remote", "add", "origin", `${base}/${owner}/reuse.git`]);
432 git(src, ["push", "-q", "origin", "main"]);
433
434 const run = await until("reuse run", async () => (await ok("GET", `/repos/${owner}/reuse/actions/runs`)).workflow_runs[0]);
435 const logOf = async (id) => (await fetch(`${base}/api/v3/repos/${owner}/reuse/actions/jobs/${id}/logs`, { headers: { authorization: `Bearer ${token}` } })).text();
436 const say = await runOneJob(owner, "reuse");
437 assert.equal(say.claim.name, "Greeter / say");
438 const sayLog = await logOf(say.claim.job_id);
439 assert.match(sayLog, /secret ok=true loud=true job=say/);
440 assert.doesNotMatch(sayLog, /s3cret/);
441 const shout = await runOneJob(owner, "reuse");
442 assert.equal(shout.claim.name, "Greeter / shout");
443 const after = await runOneJob(owner, "reuse");
444 assert.equal(after.claim.name, "after");
445 assert.match(await logOf(after.claim.job_id), /got=hi-there! call=success nope=skipped/);
446 const done = await until("reuse run to complete", async () => {
447 const r = await ok("GET", `/repos/${owner}/reuse/actions/runs/${run.id}`);
448 return r.status === "completed" ? r : null;
449 });
450 assert.equal(done.conclusion, "success");
451 const jobs = (await ok("GET", `/repos/${owner}/reuse/actions/runs/${run.id}/jobs`)).jobs;
452 assert.deepEqual(jobs.filter((j) => j.conclusion === "skipped").map((j) => j.name).sort(), ["nope / say", "nope / shout"]);
453});
454
455const hasDocker = spawnSync("docker", ["info"], { stdio: "ignore", timeout: 15_000 }).status === 0;
456
457test("containers: a job container and service containers on a self-hosted runner", { skip: !hasDocker && "Docker is not available" }, async () => {
458 const owner = (await ok("GET", "/user")).login;
459 await ok("POST", "/user/repos", { name: "boxes" });
460 const src = join(work, "boxes");
461 mkdirSync(join(src, ".github/workflows"), { recursive: true });
462 // Docker Hub rate-limits anonymous pulls; mirror.gcr.io serves the same official images.
463 writeFileSync(
464 join(src, ".github/workflows/ci.yml"),
465 `on: push
466jobs:
467 hosted:
468 runs-on: ubuntu-latest
469 services:
470 redis: mirror.gcr.io/library/redis:7-alpine
471 steps:
472 - run: echo never
473 boxed:
474 runs-on: [self-hosted, linux]
475 container:
476 image: mirror.gcr.io/library/debian:\${{ github.event_name == 'push' && 'bookworm-slim' || 'nope' }}
477 services:
478 cache:
479 image: mirror.gcr.io/library/redis:7-alpine
480 options: --health-cmd "redis-cli ping" --health-interval 1s --health-retries 30
481 steps:
482 - run: |
483 grep -q bookworm /etc/os-release && echo "inside debian"
484 exec 3<>/dev/tcp/cache/6379
485 printf 'PING\\r\\n' >&3
486 read -r line <&3
487 echo "cache answered \${line%$'\\r'}"
488`,
489 );
490 git(src, ["init", "-q", "-b", "main"]);
491 git(src, ["add", "."]);
492 git(src, ["commit", "-q", "-m", "containers"]);
493 git(src, ["remote", "add", "origin", `${base}/${owner}/boxes.git`]);
494 git(src, ["push", "-q", "origin", "main"]);
495
496 const run = await until("boxes run", async () => (await ok("GET", `/repos/${owner}/boxes/actions/runs`)).workflow_runs[0]);
497 const boxed = await runOneJob(owner, "boxes");
498 assert.equal(boxed.claim.name, "boxed");
499 const log = await (await fetch(`${base}/api/v3/repos/${owner}/boxes/actions/jobs/${boxed.claim.job_id}/logs`, { headers: { authorization: `Bearer ${token}` } })).text();
500 assert.match(log, /inside debian/);
501 assert.match(log, /cache answered \+PONG/);
502 const done = await until("boxes run to complete", async () => {
503 const r = await ok("GET", `/repos/${owner}/boxes/actions/runs/${run.id}`);
504 return r.status === "completed" ? r : null;
505 });
506 // Hosted runners can't run Docker, and say so.
507 assert.equal(done.conclusion, "failure");
508 const jobs = (await ok("GET", `/repos/${owner}/boxes/actions/runs/${run.id}/jobs`)).jobs;
509 const hosted = jobs.find((j) => j.name === "hosted");
510 assert.equal(hosted.conclusion, "failure");
511 assert.match(JSON.stringify(hosted), /hosted Diffinery runners don't have/);
512 assert.equal(jobs.find((j) => j.name === "boxed").conclusion, "success");
513});