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.

608 lines · 27.4 KBRaw History
1import { execFileSync, spawn } from "node:child_process";
2import { createServer } from "node:http";
3import type { AddressInfo } from "node:net";
4import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5import path from "node:path";
6import { afterEach, beforeEach, describe, expect, it } from "vitest";
7import type { JobSpec, StepSpec } from "@diffinery/shared";
8import { apiBaseFor, runAgentJob } from "../src/agent.js";
9import type { JobRunner } from "../src/job.js";
10import { makeSpec, startFakeApi, tmp, type FakeApi } from "./helpers.js";
11
12let api: FakeApi;
13let workRoot: string;
14
15beforeEach(async () => {
16 api = await startFakeApi();
17 workRoot = tmp();
18});
19afterEach(async () => {
20 await api.close();
21 rmSync(workRoot, { recursive: true, force: true });
22});
23
24function run(spec: JobSpec, extra: { onRunner?: (r: JobRunner) => void } = {}) {
25 return runAgentJob(spec, {
26 apiBase: apiBaseFor(spec.apiUrl, spec.jobId),
27 workRoot,
28 toolCache: path.join(workRoot, "_tool"),
29 logIntervalMs: 50,
30 echoLogs: false,
31 ...extra,
32 });
33}
34
35const workspace = () => path.join(workRoot, "widgets");
36
37/** Async spawn: the fake API lives in this process, so the event loop must stay free. */
38function spawnAsync(cmd: string, args: string[], env: NodeJS.ProcessEnv): Promise<{ status: number | null; stdout: string; stderr: string }> {
39 return new Promise((resolve) => {
40 const child = spawn(cmd, args, { env });
41 let stdout = "";
42 let stderr = "";
43 child.stdout.on("data", (d) => (stdout += d));
44 child.stderr.on("data", (d) => (stderr += d));
45 const t = setTimeout(() => child.kill("SIGKILL"), 30_000);
46 child.on("close", (status) => {
47 clearTimeout(t);
48 resolve({ status, stdout, stderr });
49 });
50 });
51}
52
53describe("job runner end-to-end", () => {
54 it("propagates env, outputs, path, summaries and job outputs", async () => {
55 const spec = makeSpec(
56 api,
57 [
58 {
59 id: "one",
60 name: "Step ${{ github.job }} one",
61 run: [
62 'echo "hello from step one"',
63 'echo "FOO=bar" >> "$GITHUB_ENV"',
64 '{ echo "MULTI<<EOF"; echo "line1"; echo "line2"; echo "EOF"; } >> "$GITHUB_ENV"',
65 'echo "greeting=hi there" >> "$GITHUB_OUTPUT"',
66 'mkdir -p "$RUNNER_TEMP/mybin" "$GITHUB_WORKSPACE/sub"',
67 `printf '#!/bin/sh\\necho mytool-ran\\n' > "$RUNNER_TEMP/mybin/mytool"`,
68 'chmod +x "$RUNNER_TEMP/mybin/mytool"',
69 'echo "$RUNNER_TEMP/mybin" >> "$GITHUB_PATH"',
70 'echo "::set-output name=legacy::old-style"',
71 'echo "# Summary one" >> "$GITHUB_STEP_SUMMARY"',
72 ].join("\n"),
73 },
74 {
75 id: "two",
76 env: { STEP_VAR: "${{ steps.one.outputs.greeting }}", FOO: "${{ env.FOO }}-override" },
77 run: [
78 'echo "foo=$FOO"',
79 'echo "multi=$(echo "$MULTI" | tr "\\n" ",")"',
80 "mytool",
81 'echo "step_var=$STEP_VAR job_var=$JOB_VAR matrix=${{ matrix.os }}"',
82 'echo "ci=$CI actions=$GITHUB_ACTIONS diffinery=$DIFFINERY repo=$GITHUB_REPOSITORY ref_name=$GITHUB_REF_NAME type=$GITHUB_REF_TYPE"',
83 'echo "server=$GITHUB_SERVER_URL api=$GITHUB_API_URL os=$RUNNER_OS job=$GITHUB_JOB actor=$GITHUB_ACTOR"',
84 'echo "event=$(cat "$GITHUB_EVENT_PATH" | tr -d " \\n")"',
85 'echo "## Summary two" >> "$GITHUB_STEP_SUMMARY"',
86 ].join("\n"),
87 },
88 { name: "conditional", if: "steps.one.outputs.greeting == 'hi there'", run: 'echo "conditional ran with $FOO"' },
89 { name: "never", if: "steps.one.outputs.greeting == 'nope'", run: 'echo "SHOULD NOT RUN"' },
90 { name: "wd", "working-directory": "sub", run: 'echo "pwd=$(pwd)"' },
91 { name: "py", shell: "python", run: 'import os\nprint("py-foo=" + os.environ["FOO"])' },
92 { name: "custom", shell: "sh -e {0}", run: 'echo "custom shell ok"' },
93 ],
94 {
95 env: { JOB_VAR: "${{ github.repository }}-x" },
96 matrix: { os: "linux" },
97 outputs: { greeting: "${{ steps.one.outputs.greeting }}", legacy: "${{ steps.one.outputs.legacy }}", combo: "${{ steps.two.outcome }}/${{ env.FOO }}" },
98 },
99 );
100 const c = await run(spec);
101 const log = api.logText();
102 expect(c.conclusion, log).toBe("success");
103 expect(api.completion).toEqual(c);
104 expect(c.outputs).toEqual({ greeting: "hi there", legacy: "old-style", combo: "success/bar" });
105 expect(c.summary).toContain("# Summary one");
106 expect(c.summary).toContain("## Summary two");
107
108 const s1 = api.logText(1);
109 expect(s1).toContain("foo=bar-override");
110 expect(s1).toContain("multi=line1,line2,");
111 expect(s1).toContain("mytool-ran");
112 expect(s1).toContain("step_var=hi there job_var=acme/widgets-x matrix=linux");
113 expect(s1).toContain("ci=true actions=true diffinery=true repo=acme/widgets ref_name=main type=branch");
114 expect(s1).toContain(`server=${api.url} api=${api.url}/api/v3 os=Linux job=build actor=octo`);
115 expect(s1).toContain('event={"ref":"refs/heads/main","pusher":{"name":"octo"}}');
116 expect(api.logText(2)).toContain("conditional ran with bar");
117 expect(log).not.toContain("SHOULD NOT RUN");
118 expect(api.logText(4)).toContain(`pwd=${path.join(workspace(), "sub")}`);
119 expect(api.logText(5)).toContain("py-foo=bar");
120 expect(api.logText(6)).toContain("custom shell ok");
121
122 expect(api.lastReport(0)).toMatchObject({ status: "completed", conclusion: "success", name: "Step build one" });
123 expect(api.lastReport(3)).toMatchObject({ status: "completed", conclusion: "skipped" });
124 expect(api.steps.find((s) => s.index === 0 && s.report.status === "in_progress")).toBeTruthy();
125 expect(api.steps.find((s) => s.index === 3 && s.report.status === "in_progress")).toBeFalsy();
126 // all requests authenticated with the job token
127 expect(api.requests.every((r) => r.auth === `Bearer ${api.token}`)).toBe(true);
128 });
129
130 it("handles failures, continue-on-error, failure() and always()", async () => {
131 const spec = makeSpec(api, [
132 { id: "ok", run: "echo ok" },
133 { id: "soft", "continue-on-error": true, run: "echo soft-failing; exit 3" },
134 { name: "after-soft", run: 'echo "after soft ${{ steps.soft.outcome }} ${{ steps.soft.conclusion }} ${{ job.status }}"' },
135 { id: "bad", name: "Compile", run: 'echo "compiling..."\necho "::error file=src/a.ts,line=4::type mismatch"\nexit 1' },
136 { name: "skipped-by-failure", run: "echo SHOULD-NOT-RUN" },
137 { name: "on-failure", if: "failure()", run: 'echo "failure handler: ${{ steps.bad.outcome }} ${{ job.status }}"' },
138 { name: "always", if: "${{ always() }}", run: "echo always-ran" },
139 { name: "on-success", if: "success()", run: "echo SHOULD-NOT-RUN-2" },
140 { name: "cancelled-only", if: "cancelled()", run: "echo SHOULD-NOT-RUN-3" },
141 ]);
142 const c = await run(spec);
143 expect(c.conclusion).toBe("failure");
144 expect(c.failure).toEqual({ step: 3, message: "src/a.ts:4: type mismatch" });
145 expect(api.lastReport(1)?.conclusion).toBe("success");
146 expect(api.logText(2)).toContain("after soft failure success success");
147 expect(api.lastReport(3)?.conclusion).toBe("failure");
148 expect(api.lastReport(4)?.conclusion).toBe("skipped");
149 expect(api.logText(5)).toContain("failure handler: failure failure");
150 expect(api.lastReport(5)?.conclusion).toBe("success");
151 expect(api.logText(6)).toContain("always-ran");
152 expect(api.lastReport(7)?.conclusion).toBe("skipped");
153 expect(api.lastReport(8)?.conclusion).toBe("skipped");
154 expect(api.logText()).not.toMatch(/SHOULD-NOT-RUN/);
155 expect(api.logText(3)).toContain("##[error]src/a.ts:4: type mismatch");
156 expect(api.logText(3)).toContain("##[error]Process completed with exit code 1.");
157 });
158
159 it("summarises failures without annotations from the step's last log lines", async () => {
160 const spec = makeSpec(api, [{ run: 'for i in $(seq 1 30); do echo "line $i"; done\necho "fatal: it broke" >&2\nexit 7' }]);
161 const c = await run(spec);
162 expect(c.conclusion).toBe("failure");
163 expect(c.failure?.step).toBe(0);
164 expect(c.failure?.message).toMatch(/^Process completed with exit code 7\./);
165 expect(c.failure?.message).toContain("fatal: it broke");
166 expect(c.failure?.message).toContain("line 30");
167 expect(c.failure?.message).not.toContain("line 5\n");
168 });
169
170 it("masks secrets (incl. multi-line and ::add-mask::) in logs and outputs", async () => {
171 const spec = makeSpec(
172 api,
173 [
174 {
175 id: "s",
176 env: { TOKEN: "${{ secrets.GITHUB_TOKEN }}", PEM: "${{ secrets.PEM }}" },
177 run: [
178 'echo "key=${{ secrets.API_KEY }}"',
179 'echo "token=$TOKEN"',
180 'echo "$PEM"',
181 'V=dynamic; echo "::add-mask::${V}-secret-42"',
182 'echo "now ${V}-secret-42 is hidden"',
183 'echo "leak=${{ secrets.API_KEY }}" >> "$GITHUB_OUTPUT"',
184 'echo "fine=visible" >> "$GITHUB_OUTPUT"',
185 ].join("\n"),
186 },
187 ],
188 {
189 secrets: { API_KEY: "s3cr3t-value", PEM: "-----BEGIN-----\nabcdefghijklmnop\n-----END-----" },
190 outputs: { leak: "${{ steps.s.outputs.leak }}", fine: "${{ steps.s.outputs.fine }}" },
191 },
192 );
193 const c = await run(spec);
194 expect(c.conclusion).toBe("success");
195 const log = api.logText();
196 expect(log).not.toContain("s3cr3t-value");
197 expect(log).not.toContain(api.token);
198 expect(log).not.toContain("abcdefghijklmnop");
199 expect(log).not.toContain("dynamic-secret-42");
200 expect(log).toContain("key=***");
201 expect(log).toContain("token=***");
202 expect(log).toContain("now *** is hidden");
203 expect(c.outputs).toEqual({ fine: "visible" });
204 });
205
206 it("interprets workflow commands", async () => {
207 const spec = makeSpec(
208 api,
209 [
210 {
211 run: [
212 'echo "::group::My Group"',
213 "echo inside",
214 'echo "::endgroup::"',
215 'echo "::warning title=Heads up::be careful"',
216 'echo "::notice::multi%0Aline"',
217 'echo "::debug::hidden debug"',
218 'T=pause123; echo "::stop-commands::$T"',
219 'echo "::warning::not a command"',
220 'echo "::$T::"',
221 'echo "::warning::real again"',
222 'echo "::save-state name=x::y"',
223 ].join("\n"),
224 },
225 ],
226 {},
227 );
228 const c = await run(spec);
229 expect(c.conclusion).toBe("success");
230 const lines = api.logs.filter((l) => l.step === 0).map((l) => l.text);
231 const after = lines.slice(lines.indexOf("##[endgroup]") + 1);
232 expect(after).toEqual([
233 "##[group]My Group",
234 "inside",
235 "##[endgroup]",
236 "##[warning]Heads up: be careful",
237 "##[notice]multi",
238 "line",
239 "::warning::not a command",
240 "##[warning]real again",
241 ]);
242 });
243
244 it("shows ::debug:: when ACTIONS_STEP_DEBUG is set", async () => {
245 const spec = makeSpec(api, [{ run: 'echo "::debug::now visible"; echo "dbg=$RUNNER_DEBUG"' }], { secrets: { ACTIONS_STEP_DEBUG: "true" } });
246 await run(spec);
247 expect(api.logText(0)).toContain("##[debug]now visible");
248 expect(api.logText(0)).toContain("dbg=1");
249 });
250
251 it("runs composite and node local actions with pre/post and state", async () => {
252 const ws = workspace();
253 mkdirSync(path.join(ws, ".github/actions/greet"), { recursive: true });
254 mkdirSync(path.join(ws, ".github/actions/nodey"), { recursive: true });
255 writeFileSync(
256 path.join(ws, ".github/actions/greet/action.yml"),
257 `name: Greet
258description: composite greet
259inputs:
260 who:
261 description: who
262 required: true
263 punct:
264 default: "!"
265outputs:
266 message:
267 description: msg
268 value: \${{ steps.say.outputs.msg }}
269runs:
270 using: composite
271 steps:
272 - id: say
273 shell: bash
274 run: |
275 echo "Hello \${{ inputs.who }}\${{ inputs.punct }}"
276 echo "msg=Hello \${{ inputs.who }}" >> "$GITHUB_OUTPUT"
277 echo "action path is $GITHUB_ACTION_PATH"
278 echo "inherited=$OUTER"
279 - name: nested if
280 if: \${{ inputs.who == 'nobody' }}
281 shell: bash
282 run: echo "SHOULD-NOT-RUN"
283 - shell: bash
284 run: echo "COMPOSITE_ENV=set" >> $GITHUB_ENV
285 - uses: ./.github/actions/nodey
286 with:
287 my-input: from-composite
288`,
289 );
290 writeFileSync(
291 path.join(ws, ".github/actions/nodey/action.yml"),
292 `name: nodey
293inputs:
294 my-input:
295 default: default-val
296 repo:
297 default: \${{ github.repository }}
298outputs:
299 result:
300 description: r
301runs:
302 using: node20
303 pre: pre.js
304 main: index.js
305 post: post.js
306 post-if: success()
307`,
308 );
309 writeFileSync(
310 path.join(ws, ".github/actions/nodey/pre.js"),
311 `require("fs").appendFileSync(process.env.GITHUB_STATE, "fromPre=pre-state\\n"); console.log("pre ran");`,
312 );
313 writeFileSync(
314 path.join(ws, ".github/actions/nodey/index.js"),
315 `const fs = require("fs");
316console.log("input=" + process.env["INPUT_MY-INPUT"] + " repo=" + process.env.INPUT_REPO + " prestate=" + process.env.STATE_fromPre);
317fs.appendFileSync(process.env.GITHUB_OUTPUT, "result=from-node-" + process.env["INPUT_MY-INPUT"] + "\\n");
318fs.appendFileSync(process.env.GITHUB_STATE, "saved=state-" + process.env["INPUT_MY-INPUT"] + "\\n");
319`,
320 );
321 writeFileSync(path.join(ws, ".github/actions/nodey/post.js"), `console.log("post ran with state " + process.env.STATE_saved + "/" + process.env.STATE_fromPre);`);
322
323 const spec = makeSpec(api, [
324 { uses: "./.github/actions/greet", id: "greet", with: { who: "World" }, env: { OUTER: "outer-env" } },
325 { run: 'echo "composite said ${{ steps.greet.outputs.message }} env=$COMPOSITE_ENV"' },
326 { uses: "./.github/actions/nodey", id: "nodey", with: { "my-input": "hello" } },
327 { run: 'echo "node said ${{ steps.nodey.outputs.result }}"' },
328 ]);
329 const c = await run(spec);
330 const log = api.logText();
331 expect(c.conclusion, log).toBe("success");
332 const s0 = api.logText(0);
333 expect(s0).toContain("Hello World!");
334 expect(s0).toContain(`action path is ${path.join(ws, ".github/actions/greet")}`);
335 expect(s0).toContain("inherited=outer-env");
336 expect(s0).toContain("input=from-composite repo=acme/widgets prestate=pre-state");
337 expect(log).not.toContain("SHOULD-NOT-RUN");
338 expect(api.logText(1)).toContain("composite said Hello World env=set");
339 expect(api.logText(2)).toContain("pre ran");
340 expect(api.logText(2)).toContain("input=hello repo=acme/widgets prestate=pre-state");
341 expect(api.logText(3)).toContain("node said from-node-hello");
342 // post steps run in reverse order after the main steps
343 expect(api.logText(4)).toContain("post ran with state state-hello/pre-state");
344 expect(api.logText(5)).toContain("post ran with state state-from-composite/pre-state");
345 expect(api.lastReport(4)).toMatchObject({ status: "completed", conclusion: "success", name: "Post Run ./.github/actions/nodey" });
346 });
347
348 it("fails composite steps and reports the inner error", async () => {
349 const ws = workspace();
350 mkdirSync(path.join(ws, "act"), { recursive: true });
351 writeFileSync(
352 path.join(ws, "act/action.yaml"),
353 `runs:
354 using: composite
355 steps:
356 - run: echo "::error::inner broke"; exit 2
357 shell: bash
358 - run: echo "inner-cleanup"
359 if: always()
360 shell: bash
361`,
362 );
363 const c = await run(makeSpec(api, [{ uses: "./act" }, { run: "echo after", if: "always()" }]));
364 expect(c.conclusion).toBe("failure");
365 expect(c.failure).toEqual({ step: 0, message: "inner broke" });
366 expect(api.logText(0)).toContain("inner-cleanup");
367 expect(api.logText(1)).toContain("after");
368 });
369
370 it("downloads and runs remote actions (owner/repo/path@ref)", async () => {
371 // fake codeload: serves a tarball with a top-level folder, like GitHub does
372 const src = tmp("remote-action-");
373 const top = path.join(src, "tools-abc123");
374 mkdirSync(path.join(top, "hello"), { recursive: true });
375 writeFileSync(
376 path.join(top, "hello/action.yml"),
377 "name: hello\ninputs:\n name:\n default: anon\noutputs:\n said:\n description: x\nruns:\n using: 'node24'\n main: 'main.js'\n",
378 );
379 writeFileSync(
380 path.join(top, "hello/main.js"),
381 'require("fs").appendFileSync(process.env.GITHUB_OUTPUT, `said=hi ${process.env.INPUT_NAME}\\n`); console.log("remote action ran in", process.env.GITHUB_ACTION_REPOSITORY, process.env.GITHUB_ACTION_REF);',
382 );
383 const tarball = path.join(src, "a.tgz");
384 execFileSync("tar", ["-czf", tarball, "-C", src, "tools-abc123"]);
385 const hits: string[] = [];
386 const server = createServer((req, res) => {
387 hits.push(req.url ?? "");
388 if (req.url === "/acme/tools/tar.gz/v1") {
389 res.writeHead(200, { "content-type": "application/gzip" });
390 res.end(readFileSync(tarball));
391 } else {
392 res.writeHead(404);
393 res.end();
394 }
395 });
396 await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
397 const prev = process.env.DIFFINERY_ACTIONS_DOWNLOAD_URL;
398 process.env.DIFFINERY_ACTIONS_DOWNLOAD_URL = `http://127.0.0.1:${(server.address() as AddressInfo).port}/{owner}/{repo}/tar.gz/{ref}`;
399 try {
400 const c = await run(
401 makeSpec(api, [
402 { id: "h", uses: "acme/tools/hello@v1", with: { name: "bob" } },
403 { uses: "acme/tools/hello@v1" },
404 { run: 'echo "out=${{ steps.h.outputs.said }}"' },
405 { uses: "acme/missing@v9" },
406 ]),
407 );
408 expect(api.logText(0)).toContain("remote action ran in acme/tools v1");
409 expect(api.logText(2)).toContain("out=hi bob");
410 expect(hits.filter((h) => h === "/acme/tools/tar.gz/v1")).toHaveLength(1); // cached after first download
411 expect(c.conclusion).toBe("failure");
412 expect(c.failure?.step).toBe(3);
413 expect(c.failure?.message).toContain("Unable to resolve action 'acme/missing@v9'");
414 } finally {
415 if (prev === undefined) delete process.env.DIFFINERY_ACTIONS_DOWNLOAD_URL;
416 else process.env.DIFFINERY_ACTIONS_DOWNLOAD_URL = prev;
417 await new Promise<void>((r) => server.close(() => r()));
418 rmSync(src, { recursive: true, force: true });
419 }
420 });
421
422 it("rejects github-script with a clear error", async () => {
423 const c2 = await run(makeSpec(api, [{ uses: "actions/github-script@v7", with: { script: "1" } }]));
424 expect(c2.failure?.message).toContain("actions/github-script is not supported");
425 });
426
427 it("enforces step timeout-minutes", async () => {
428 const start = Date.now();
429 const c = await run(makeSpec(api, [{ "timeout-minutes": 0.02, run: "sleep 30" }, { if: "always()", run: "echo after-timeout" }]));
430 expect(Date.now() - start).toBeLessThan(15_000);
431 expect(c.conclusion).toBe("failure");
432 expect(c.failure?.message).toContain("timed out");
433 expect(api.logText(1)).toContain("after-timeout");
434 });
435
436 it("cancels on request: kills the process group and reports cancelled", async () => {
437 let runner: JobRunner | undefined;
438 const spec = makeSpec(api, [{ run: "echo started; (sleep 60 & wait)" }, { run: "echo never" }, { if: "always()", run: "echo never2" }]);
439 const p = run(spec, { onRunner: (r) => (runner = r) });
440 const t0 = Date.now();
441 while (!api.logText(0).includes("started") && Date.now() - t0 < 10_000) await new Promise((r) => setTimeout(r, 50));
442 runner!.cancel();
443 const c = await p;
444 expect(c.conclusion).toBe("cancelled");
445 expect(api.lastReport(0)?.conclusion).toBe("cancelled");
446 expect(api.lastReport(1)?.conclusion).toBe("cancelled");
447 expect(api.lastReport(2)?.conclusion).toBe("cancelled");
448 expect(api.completion?.conclusion).toBe("cancelled");
449 expect(Date.now() - t0).toBeLessThan(10_000);
450 });
451
452 it("never loses log lines when the log endpoint flakes", async () => {
453 api.failLogs = 2;
454 const c = await run(makeSpec(api, [{ run: "for i in $(seq 1 500); do echo \"n$i\"; done" }]));
455 expect(c.conclusion).toBe("success");
456 const nums = api.logs.filter((l) => l.step === 0 && /^n\d+$/.test(l.text)).map((l) => l.text);
457 expect(nums).toEqual(Array.from({ length: 500 }, (_, i) => `n${i + 1}`));
458 });
459
460 it("caches and restores via the runner cache endpoint, and round-trips artifacts", async () => {
461 const steps: StepSpec[] = [
462 { run: 'mkdir -p deps dist/sub && echo "${{ github.run_id }}" > deps/marker && echo a > dist/a.txt && echo b > dist/sub/b.txt' },
463 { id: "cache", uses: "actions/cache@v4", with: { path: "deps", key: "deps-${{ hashFiles('**/lock.txt') }}-v1", "restore-keys": "deps-" } },
464 { run: 'echo "hit=${{ steps.cache.outputs.cache-hit }}"; cat deps/marker' },
465 { uses: "actions/upload-artifact@v4", with: { name: "dist", path: "dist/" } },
466 { id: "dl", uses: "actions/download-artifact@v4", with: { name: "dist", path: "out" } },
467 { run: 'cat out/a.txt out/sub/b.txt; echo "dl=${{ steps.dl.outputs.download-path }}"' },
468 ];
469 mkdirSync(workspace(), { recursive: true });
470 writeFileSync(path.join(workspace(), "lock.txt"), "v1");
471 const c1 = await run(makeSpec(api, steps, { runId: "first" }));
472 expect(c1.conclusion, api.logText()).toBe("success");
473 expect(api.logText(2)).toContain("hit=false");
474 expect([...api.cache.keys()]).toHaveLength(1);
475 const key = [...api.cache.keys()][0]!;
476 expect(key).toMatch(/^deps-[0-9a-f]{64}-v1$/);
477 expect(api.artifacts.has("dist")).toBe(true);
478 expect(api.logText(5)).toMatch(/a\nb\ndl=.*\/out/);
479 // post step saved the cache
480 expect(api.steps.some((s) => s.report.name === "Post Run actions/cache@v4" && s.report.conclusion === "success")).toBe(true);
481
482 // second run: wipe the cached dir, it must come back from the cache
483 rmSync(path.join(workspace(), "deps"), { recursive: true, force: true });
484 api.logs = [];
485 api.steps = [];
486 const steps2 = [steps[1]!, steps[2]!];
487 const c2 = await run(makeSpec(api, steps2, { runId: "second" }));
488 expect(c2.conclusion, api.logText()).toBe("success");
489 expect(api.logText(1)).toContain("hit=true");
490 expect(api.logText(1)).toContain("first");
491 expect(api.steps.some((s) => s.report.name?.startsWith("Post"))).toBe(false);
492 });
493
494 it("checks out a repository with actions/checkout", async () => {
495 const origin = tmp("origin-");
496 const git = (...args: string[]) => execFileSync("git", args, { cwd: origin, env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" } }).toString().trim();
497 git("init", "-q", "-b", "main");
498 writeFileSync(path.join(origin, "README.md"), "first\n");
499 git("add", ".");
500 git("commit", "-q", "-m", "first");
501 writeFileSync(path.join(origin, "README.md"), "second\n");
502 git("commit", "-q", "-am", "second");
503 const sha = git("rev-parse", "HEAD");
504 git("tag", "v1");
505 const spec = makeSpec(
506 api,
507 [
508 { id: "co", uses: "actions/checkout@v4" },
509 { run: 'cat README.md; git rev-parse HEAD; git rev-parse --abbrev-ref HEAD; echo "commit=${{ steps.co.outputs.commit }}"; git rev-list --count HEAD' },
510 { uses: "actions/checkout@v4", with: { path: "full", "fetch-depth": "0", ref: "v1" } },
511 { run: "git -C full rev-list --count HEAD; cat full/README.md" },
512 ],
513 { sha, repo: { owner: "acme", name: "widgets", cloneUrl: `file://${origin}`, defaultBranch: "main" } },
514 );
515 const c = await run(spec);
516 expect(c.conclusion, api.logText()).toBe("success");
517 const out = api.logText(1);
518 expect(out).toContain("second");
519 expect(out).toContain(sha);
520 expect(out).toMatch(/\nmain\n/);
521 expect(out).toContain(`commit=${sha}`);
522 expect(out).toMatch(/\n1$/);
523 expect(api.logText(3)).toMatch(/2\nsecond/);
524 expect(existsSync(path.join(workspace(), ".git"))).toBe(true);
525 rmSync(origin, { recursive: true, force: true });
526 });
527
528 it("sets OIDC request variables when id-token is granted", async () => {
529 const c = await run(
530 makeSpec(api, [{ run: 'curl -s -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts" ; echo' }], { idToken: true }),
531 );
532 expect(c.conclusion).toBe("success");
533 expect(api.logText(0)).toContain('{"value":"oidc-for-sts"}');
534 });
535
536 it("evaluates job-level env and spec-level defaults.run", async () => {
537 mkdirSync(path.join(workspace(), "pkg"), { recursive: true });
538 const c = await run(
539 makeSpec(api, [{ run: 'echo "wd=$(basename "$(pwd)") shell=$0 e=$E"' }], {
540 defaults: { run: { shell: "sh -e {0}", "working-directory": "pkg" } },
541 env: { E: "${{ vars.V }}-${{ inputs.i }}-${{ needs.prev.outputs.o }}" },
542 vars: { V: "var" },
543 inputs: { i: "inp" },
544 needs: { prev: { result: "success", outputs: { o: "out" } } },
545 }),
546 );
547 expect(c.conclusion).toBe("success");
548 expect(api.logText(0)).toMatch(/wd=pkg shell=\S+\.sh e=var-inp-out/);
549 });
550});
551
552describe("agent binary (esbuild bundle)", () => {
553 it("runs a --spec job and an env-driven job", async () => {
554 const { build } = await import("esbuild");
555 const out = path.join(workRoot, "agent.mjs");
556 await build({
557 entryPoints: [path.join(__dirname, "../src/main.ts")],
558 bundle: true,
559 platform: "node",
560 target: "node22",
561 format: "esm",
562 outfile: out,
563 logLevel: "silent",
564 // keep in sync with the `build` script in package.json
565 banner: { js: "import { createRequire as __diffineryCreateRequire } from 'node:module'; const require = __diffineryCreateRequire(import.meta.url);" },
566 });
567 const spec = makeSpec(api, [{ run: 'echo "from bundle $GITHUB_JOB"' }], { outputs: { x: "y" } });
568 const specFile = path.join(workRoot, "spec.json");
569 writeFileSync(specFile, JSON.stringify(spec));
570 const env: NodeJS.ProcessEnv = { ...process.env, DIFFINERY_WORK_ROOT: path.join(workRoot, "w1") };
571 delete env.DIFFINERY_API_URL;
572 const r = await spawnAsync(process.execPath, [out, "--spec", specFile], env);
573 expect(r.status, r.stderr + r.stdout).toBe(0);
574 expect(api.completion).toMatchObject({ conclusion: "success", outputs: { x: "y" } });
575 expect(api.logText(0)).toContain("from bundle build");
576
577 // env mode: fetch the spec from the API
578 api.completion = null;
579 api.specs.set("job-2", { ...spec, jobId: "job-2", steps: [{ run: "exit 4" }] });
580 const r2 = await spawnAsync(process.execPath, [out], {
581 ...process.env,
582 DIFFINERY_WORK_ROOT: path.join(workRoot, "w2"),
583 DIFFINERY_API_URL: api.url,
584 DIFFINERY_JOB_ID: "job-2",
585 DIFFINERY_JOB_TOKEN: api.token,
586 });
587 expect(r2.status).toBe(1);
588 expect(api.completion).toMatchObject({ conclusion: "failure", failure: { step: 0 } });
589 expect(readFileSync(path.join(workRoot, "w2/_temp/_github_workflow/event.json"), "utf8")).toContain("pusher");
590
591 // SIGTERM: kill the running step's process group and report cancelled
592 api.completion = null;
593 api.steps = [];
594 api.specs.set("job-3", { ...spec, jobId: "job-3", steps: [{ run: "echo started; sleep 60" }, { run: "echo never" }] });
595 const child = spawn(process.execPath, [out], {
596 env: { ...process.env, DIFFINERY_WORK_ROOT: path.join(workRoot, "w3"), DIFFINERY_API_URL: api.url, DIFFINERY_JOB_ID: "job-3", DIFFINERY_JOB_TOKEN: api.token },
597 });
598 const exited = new Promise<number | null>((r) => child.on("close", (code) => r(code)));
599 const t0 = Date.now();
600 while (!api.logs.some((l) => l.text === "started") && Date.now() - t0 < 15_000) await new Promise((r) => setTimeout(r, 50));
601 child.kill("SIGTERM");
602 expect(await exited).toBe(1);
603 expect(Date.now() - t0).toBeLessThan(15_000);
604 expect(api.completion).toMatchObject({ conclusion: "cancelled" });
605 expect(api.lastReport(0)?.conclusion).toBe("cancelled");
606 expect(api.lastReport(1)?.conclusion).toBe("cancelled");
607 }, 60_000);
608});