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.

1031 lines · 50.8 KBRaw History
1import { execFileSync, spawn } from "node:child_process";
2import { createHash, randomBytes } from "node:crypto";
3import { createServer } from "node:http";
4import type { AddressInfo } from "node:net";
5import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6import path from "node:path";
7import { afterEach, beforeEach, describe, expect, it } from "vitest";
8import type { JobSpec, StepSpec } from "@diffinery/shared";
9import { apiBaseFor, runAgentJob } from "../src/agent.js";
10import type { JobRunner } from "../src/job.js";
11import { actionDownload } from "../src/steps/actions.js";
12import { makeSpec, startFakeApi, tmp, type FakeApi } from "./helpers.js";
13
14let api: FakeApi;
15let workRoot: string;
16
17beforeEach(async () => {
18 api = await startFakeApi();
19 workRoot = tmp();
20});
21afterEach(async () => {
22 await api.close();
23 rmSync(workRoot, { recursive: true, force: true });
24});
25
26function run(spec: JobSpec, extra: { onRunner?: (r: JobRunner) => void } = {}) {
27 return runAgentJob(spec, {
28 apiBase: apiBaseFor(spec.apiUrl, spec.jobId),
29 workRoot,
30 toolCache: path.join(workRoot, "_tool"),
31 logIntervalMs: 50,
32 echoLogs: false,
33 ...extra,
34 });
35}
36
37const workspace = () => path.join(workRoot, "widgets");
38
39/** Async spawn: the fake API lives in this process, so the event loop must stay free. */
40function spawnAsync(cmd: string, args: string[], env: NodeJS.ProcessEnv): Promise<{ status: number | null; stdout: string; stderr: string }> {
41 return new Promise((resolve) => {
42 const child = spawn(cmd, args, { env });
43 let stdout = "";
44 let stderr = "";
45 child.stdout.on("data", (d) => (stdout += d));
46 child.stderr.on("data", (d) => (stderr += d));
47 const t = setTimeout(() => child.kill("SIGKILL"), 30_000);
48 child.on("close", (status) => {
49 clearTimeout(t);
50 resolve({ status, stdout, stderr });
51 });
52 });
53}
54
55describe("job runner end-to-end", () => {
56 it("propagates env, outputs, path, summaries and job outputs", async () => {
57 const spec = makeSpec(
58 api,
59 [
60 {
61 id: "one",
62 name: "Step ${{ github.job }} one",
63 run: [
64 'echo "hello from step one"',
65 'echo "FOO=bar" >> "$GITHUB_ENV"',
66 '{ echo "MULTI<<EOF"; echo "line1"; echo "line2"; echo "EOF"; } >> "$GITHUB_ENV"',
67 'echo "greeting=hi there" >> "$GITHUB_OUTPUT"',
68 'mkdir -p "$RUNNER_TEMP/mybin" "$GITHUB_WORKSPACE/sub"',
69 `printf '#!/bin/sh\\necho mytool-ran\\n' > "$RUNNER_TEMP/mybin/mytool"`,
70 'chmod +x "$RUNNER_TEMP/mybin/mytool"',
71 'echo "$RUNNER_TEMP/mybin" >> "$GITHUB_PATH"',
72 'echo "::set-output name=legacy::old-style"',
73 'echo "# Summary one" >> "$GITHUB_STEP_SUMMARY"',
74 ].join("\n"),
75 },
76 {
77 id: "two",
78 env: { STEP_VAR: "${{ steps.one.outputs.greeting }}", FOO: "${{ env.FOO }}-override" },
79 run: [
80 'echo "foo=$FOO"',
81 'echo "multi=$(echo "$MULTI" | tr "\\n" ",")"',
82 "mytool",
83 'echo "step_var=$STEP_VAR job_var=$JOB_VAR matrix=${{ matrix.os }}"',
84 'echo "ci=$CI actions=$GITHUB_ACTIONS diffinery=$DIFFINERY repo=$GITHUB_REPOSITORY ref_name=$GITHUB_REF_NAME type=$GITHUB_REF_TYPE"',
85 'echo "server=$GITHUB_SERVER_URL api=$GITHUB_API_URL os=$RUNNER_OS job=$GITHUB_JOB actor=$GITHUB_ACTOR"',
86 'echo "event=$(cat "$GITHUB_EVENT_PATH" | tr -d " \\n")"',
87 'echo "## Summary two" >> "$GITHUB_STEP_SUMMARY"',
88 ].join("\n"),
89 },
90 { name: "conditional", if: "steps.one.outputs.greeting == 'hi there'", run: 'echo "conditional ran with $FOO"' },
91 { name: "never", if: "steps.one.outputs.greeting == 'nope'", run: 'echo "SHOULD NOT RUN"' },
92 { name: "wd", "working-directory": "sub", run: 'echo "pwd=$(pwd)"' },
93 { name: "py", shell: "python", run: 'import os\nprint("py-foo=" + os.environ["FOO"])' },
94 { name: "custom", shell: "sh -e {0}", run: 'echo "custom shell ok"' },
95 ],
96 {
97 env: { JOB_VAR: "${{ github.repository }}-x" },
98 matrix: { os: "linux" },
99 outputs: { greeting: "${{ steps.one.outputs.greeting }}", legacy: "${{ steps.one.outputs.legacy }}", combo: "${{ steps.two.outcome }}/${{ env.FOO }}" },
100 },
101 );
102 const c = await run(spec);
103 const log = api.logText();
104 expect(c.conclusion, log).toBe("success");
105 expect(api.completion).toEqual(c);
106 expect(c.outputs).toEqual({ greeting: "hi there", legacy: "old-style", combo: "success/bar" });
107 expect(c.summary).toContain("# Summary one");
108 expect(c.summary).toContain("## Summary two");
109
110 const s1 = api.logText(1);
111 expect(s1).toContain("foo=bar-override");
112 expect(s1).toContain("multi=line1,line2,");
113 expect(s1).toContain("mytool-ran");
114 expect(s1).toContain("step_var=hi there job_var=acme/widgets-x matrix=linux");
115 expect(s1).toContain("ci=true actions=true diffinery=true repo=acme/widgets ref_name=main type=branch");
116 expect(s1).toContain(`server=${api.url} api=${api.url}/api/v3 os=Linux job=build actor=octo`);
117 expect(s1).toContain('event={"ref":"refs/heads/main","pusher":{"name":"octo"}}');
118 expect(api.logText(2)).toContain("conditional ran with bar");
119 expect(log).not.toContain("SHOULD NOT RUN");
120 expect(api.logText(4)).toContain(`pwd=${path.join(workspace(), "sub")}`);
121 expect(api.logText(5)).toContain("py-foo=bar");
122 expect(api.logText(6)).toContain("custom shell ok");
123
124 expect(api.lastReport(0)).toMatchObject({ status: "completed", conclusion: "success", name: "Step build one" });
125 expect(api.lastReport(3)).toMatchObject({ status: "completed", conclusion: "skipped" });
126 expect(api.steps.find((s) => s.index === 0 && s.report.status === "in_progress")).toBeTruthy();
127 expect(api.steps.find((s) => s.index === 3 && s.report.status === "in_progress")).toBeFalsy();
128 // all requests authenticated with the job token
129 expect(api.requests.every((r) => r.auth === `Bearer ${api.token}`)).toBe(true);
130 });
131
132 it("handles failures, continue-on-error, failure() and always()", async () => {
133 const spec = makeSpec(api, [
134 { id: "ok", run: "echo ok" },
135 { id: "soft", "continue-on-error": true, run: "echo soft-failing; exit 3" },
136 { name: "after-soft", run: 'echo "after soft ${{ steps.soft.outcome }} ${{ steps.soft.conclusion }} ${{ job.status }}"' },
137 { id: "bad", name: "Compile", run: 'echo "compiling..."\necho "::error file=src/a.ts,line=4::type mismatch"\nexit 1' },
138 { name: "skipped-by-failure", run: "echo SHOULD-NOT-RUN" },
139 { name: "on-failure", if: "failure()", run: 'echo "failure handler: ${{ steps.bad.outcome }} ${{ job.status }}"' },
140 { name: "always", if: "${{ always() }}", run: "echo always-ran" },
141 { name: "on-success", if: "success()", run: "echo SHOULD-NOT-RUN-2" },
142 { name: "cancelled-only", if: "cancelled()", run: "echo SHOULD-NOT-RUN-3" },
143 ]);
144 const c = await run(spec);
145 expect(c.conclusion).toBe("failure");
146 expect(c.failure).toEqual({ step: 3, message: "src/a.ts:4: type mismatch" });
147 expect(api.lastReport(1)?.conclusion).toBe("success");
148 expect(api.logText(2)).toContain("after soft failure success success");
149 expect(api.lastReport(3)?.conclusion).toBe("failure");
150 expect(api.lastReport(4)?.conclusion).toBe("skipped");
151 expect(api.logText(5)).toContain("failure handler: failure failure");
152 expect(api.lastReport(5)?.conclusion).toBe("success");
153 expect(api.logText(6)).toContain("always-ran");
154 expect(api.lastReport(7)?.conclusion).toBe("skipped");
155 expect(api.lastReport(8)?.conclusion).toBe("skipped");
156 expect(api.logText()).not.toMatch(/SHOULD-NOT-RUN/);
157 expect(api.logText(3)).toContain("##[error]src/a.ts:4: type mismatch");
158 expect(api.logText(3)).toContain("##[error]Process completed with exit code 1.");
159 });
160
161 it("summarises failures without annotations from the step's last log lines", async () => {
162 const spec = makeSpec(api, [{ run: 'for i in $(seq 1 30); do echo "line $i"; done\necho "fatal: it broke" >&2\nexit 7' }]);
163 const c = await run(spec);
164 expect(c.conclusion).toBe("failure");
165 expect(c.failure?.step).toBe(0);
166 expect(c.failure?.message).toMatch(/^Process completed with exit code 7\./);
167 expect(c.failure?.message).toContain("fatal: it broke");
168 expect(c.failure?.message).toContain("line 30");
169 expect(c.failure?.message).not.toContain("line 5\n");
170 });
171
172 it("masks secrets (incl. multi-line and ::add-mask::) in logs and outputs", async () => {
173 const spec = makeSpec(
174 api,
175 [
176 {
177 id: "s",
178 env: { TOKEN: "${{ secrets.GITHUB_TOKEN }}", PEM: "${{ secrets.PEM }}" },
179 run: [
180 'echo "key=${{ secrets.API_KEY }}"',
181 'echo "token=$TOKEN"',
182 'echo "$PEM"',
183 'V=dynamic; echo "::add-mask::${V}-secret-42"',
184 'echo "now ${V}-secret-42 is hidden"',
185 'echo "leak=${{ secrets.API_KEY }}" >> "$GITHUB_OUTPUT"',
186 'echo "fine=visible" >> "$GITHUB_OUTPUT"',
187 ].join("\n"),
188 },
189 ],
190 {
191 secrets: { API_KEY: "s3cr3t-value", PEM: "-----BEGIN-----\nabcdefghijklmnop\n-----END-----" },
192 outputs: { leak: "${{ steps.s.outputs.leak }}", fine: "${{ steps.s.outputs.fine }}" },
193 },
194 );
195 const c = await run(spec);
196 expect(c.conclusion).toBe("success");
197 const log = api.logText();
198 expect(log).not.toContain("s3cr3t-value");
199 expect(log).not.toContain(api.token);
200 expect(log).not.toContain("abcdefghijklmnop");
201 expect(log).not.toContain("dynamic-secret-42");
202 expect(log).toContain("key=***");
203 expect(log).toContain("token=***");
204 expect(log).toContain("now *** is hidden");
205 expect(c.outputs).toEqual({ fine: "visible" });
206 });
207
208 it("interprets workflow commands", async () => {
209 const spec = makeSpec(
210 api,
211 [
212 {
213 run: [
214 'echo "::group::My Group"',
215 "echo inside",
216 'echo "::endgroup::"',
217 'echo "::warning title=Heads up::be careful"',
218 'echo "::notice::multi%0Aline"',
219 'echo "::debug::hidden debug"',
220 'T=pause123; echo "::stop-commands::$T"',
221 'echo "::warning::not a command"',
222 'echo "::$T::"',
223 'echo "::warning::real again"',
224 'echo "::save-state name=x::y"',
225 ].join("\n"),
226 },
227 ],
228 {},
229 );
230 const c = await run(spec);
231 expect(c.conclusion).toBe("success");
232 const lines = api.logs.filter((l) => l.step === 0).map((l) => l.text);
233 const after = lines.slice(lines.indexOf("##[endgroup]") + 1);
234 expect(after).toEqual([
235 "##[group]My Group",
236 "inside",
237 "##[endgroup]",
238 "##[warning]Heads up: be careful",
239 "##[notice]multi",
240 "line",
241 "::warning::not a command",
242 "##[warning]real again",
243 ]);
244 });
245
246 it("shows ::debug:: when ACTIONS_STEP_DEBUG is set", async () => {
247 const spec = makeSpec(api, [{ run: 'echo "::debug::now visible"; echo "dbg=$RUNNER_DEBUG"' }], { secrets: { ACTIONS_STEP_DEBUG: "true" } });
248 await run(spec);
249 expect(api.logText(0)).toContain("##[debug]now visible");
250 expect(api.logText(0)).toContain("dbg=1");
251 });
252
253 it("runs composite and node local actions with pre/post and state", async () => {
254 const ws = workspace();
255 mkdirSync(path.join(ws, ".github/actions/greet"), { recursive: true });
256 mkdirSync(path.join(ws, ".github/actions/nodey"), { recursive: true });
257 writeFileSync(
258 path.join(ws, ".github/actions/greet/action.yml"),
259 `name: Greet
260description: composite greet
261inputs:
262 who:
263 description: who
264 required: true
265 punct:
266 default: "!"
267outputs:
268 message:
269 description: msg
270 value: \${{ steps.say.outputs.msg }}
271runs:
272 using: composite
273 steps:
274 - id: say
275 shell: bash
276 run: |
277 echo "Hello \${{ inputs.who }}\${{ inputs.punct }}"
278 echo "msg=Hello \${{ inputs.who }}" >> "$GITHUB_OUTPUT"
279 echo "action path is $GITHUB_ACTION_PATH"
280 echo "inherited=$OUTER"
281 - name: nested if
282 if: \${{ inputs.who == 'nobody' }}
283 shell: bash
284 run: echo "SHOULD-NOT-RUN"
285 - shell: bash
286 run: echo "COMPOSITE_ENV=set" >> $GITHUB_ENV
287 - uses: ./.github/actions/nodey
288 with:
289 my-input: from-composite
290`,
291 );
292 writeFileSync(
293 path.join(ws, ".github/actions/nodey/action.yml"),
294 `name: nodey
295inputs:
296 my-input:
297 default: default-val
298 repo:
299 default: \${{ github.repository }}
300outputs:
301 result:
302 description: r
303runs:
304 using: node20
305 pre: pre.js
306 main: index.js
307 post: post.js
308 post-if: success()
309`,
310 );
311 writeFileSync(
312 path.join(ws, ".github/actions/nodey/pre.js"),
313 `require("fs").appendFileSync(process.env.GITHUB_STATE, "fromPre=pre-state\\n"); console.log("pre ran");`,
314 );
315 writeFileSync(
316 path.join(ws, ".github/actions/nodey/index.js"),
317 `const fs = require("fs");
318console.log("input=" + process.env["INPUT_MY-INPUT"] + " repo=" + process.env.INPUT_REPO + " prestate=" + process.env.STATE_fromPre);
319fs.appendFileSync(process.env.GITHUB_OUTPUT, "result=from-node-" + process.env["INPUT_MY-INPUT"] + "\\n");
320fs.appendFileSync(process.env.GITHUB_STATE, "saved=state-" + process.env["INPUT_MY-INPUT"] + "\\n");
321`,
322 );
323 writeFileSync(path.join(ws, ".github/actions/nodey/post.js"), `console.log("post ran with state " + process.env.STATE_saved + "/" + process.env.STATE_fromPre);`);
324
325 const spec = makeSpec(api, [
326 { uses: "./.github/actions/greet", id: "greet", with: { who: "World" }, env: { OUTER: "outer-env" } },
327 { run: 'echo "composite said ${{ steps.greet.outputs.message }} env=$COMPOSITE_ENV"' },
328 { uses: "./.github/actions/nodey", id: "nodey", with: { "my-input": "hello" } },
329 { run: 'echo "node said ${{ steps.nodey.outputs.result }}"' },
330 ]);
331 const c = await run(spec);
332 const log = api.logText();
333 expect(c.conclusion, log).toBe("success");
334 const s0 = api.logText(0);
335 expect(s0).toContain("Hello World!");
336 expect(s0).toContain(`action path is ${path.join(ws, ".github/actions/greet")}`);
337 expect(s0).toContain("inherited=outer-env");
338 expect(s0).toContain("input=from-composite repo=acme/widgets prestate=pre-state");
339 expect(log).not.toContain("SHOULD-NOT-RUN");
340 expect(api.logText(1)).toContain("composite said Hello World env=set");
341 expect(api.logText(2)).toContain("pre ran");
342 expect(api.logText(2)).toContain("input=hello repo=acme/widgets prestate=pre-state");
343 expect(api.logText(3)).toContain("node said from-node-hello");
344 // post steps run in reverse order after the main steps
345 expect(api.logText(4)).toContain("post ran with state state-hello/pre-state");
346 expect(api.logText(5)).toContain("post ran with state state-from-composite/pre-state");
347 expect(api.lastReport(4)).toMatchObject({ status: "completed", conclusion: "success", name: "Post Run ./.github/actions/nodey" });
348 });
349
350 it("fails composite steps and reports the inner error", async () => {
351 const ws = workspace();
352 mkdirSync(path.join(ws, "act"), { recursive: true });
353 writeFileSync(
354 path.join(ws, "act/action.yaml"),
355 `runs:
356 using: composite
357 steps:
358 - run: echo "::error::inner broke"; exit 2
359 shell: bash
360 - run: echo "inner-cleanup"
361 if: always()
362 shell: bash
363`,
364 );
365 const c = await run(makeSpec(api, [{ uses: "./act" }, { run: "echo after", if: "always()" }]));
366 expect(c.conclusion).toBe("failure");
367 expect(c.failure).toEqual({ step: 0, message: "inner broke" });
368 expect(api.logText(0)).toContain("inner-cleanup");
369 expect(api.logText(1)).toContain("after");
370 });
371
372 it("downloads and runs remote actions (owner/repo/path@ref)", async () => {
373 // fake codeload: serves a tarball with a top-level folder, like GitHub does
374 const src = tmp("remote-action-");
375 const top = path.join(src, "tools-abc123");
376 mkdirSync(path.join(top, "hello"), { recursive: true });
377 writeFileSync(
378 path.join(top, "hello/action.yml"),
379 "name: hello\ninputs:\n name:\n default: anon\noutputs:\n said:\n description: x\nruns:\n using: 'node24'\n main: 'main.js'\n",
380 );
381 writeFileSync(
382 path.join(top, "hello/main.js"),
383 '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);',
384 );
385 const tarball = path.join(src, "a.tgz");
386 execFileSync("tar", ["-czf", tarball, "-C", src, "tools-abc123"]);
387 const hits: string[] = [];
388 const server = createServer((req, res) => {
389 hits.push(req.url ?? "");
390 if (req.url === "/acme/tools/tar.gz/v1") {
391 res.writeHead(200, { "content-type": "application/gzip" });
392 res.end(readFileSync(tarball));
393 } else {
394 res.writeHead(404);
395 res.end();
396 }
397 });
398 await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
399 const prev = process.env.DIFFINERY_ACTIONS_DOWNLOAD_URL;
400 process.env.DIFFINERY_ACTIONS_DOWNLOAD_URL = `http://127.0.0.1:${(server.address() as AddressInfo).port}/{owner}/{repo}/tar.gz/{ref}`;
401 try {
402 const c = await run(
403 makeSpec(api, [
404 { id: "h", uses: "acme/tools/hello@v1", with: { name: "bob" } },
405 { uses: "acme/tools/hello@v1" },
406 { run: 'echo "out=${{ steps.h.outputs.said }}"' },
407 { uses: "acme/missing@v9" },
408 ]),
409 );
410 expect(api.logText(0)).toContain("remote action ran in acme/tools v1");
411 expect(api.logText(2)).toContain("out=hi bob");
412 expect(hits.filter((h) => h === "/acme/tools/tar.gz/v1")).toHaveLength(1); // cached after first download
413 expect(c.conclusion).toBe("failure");
414 expect(c.failure?.step).toBe(3);
415 expect(c.failure?.message).toContain("Unable to resolve action 'acme/missing@v9'");
416 } finally {
417 if (prev === undefined) delete process.env.DIFFINERY_ACTIONS_DOWNLOAD_URL;
418 else process.env.DIFFINERY_ACTIONS_DOWNLOAD_URL = prev;
419 await new Promise<void>((r) => server.close(() => r()));
420 rmSync(src, { recursive: true, force: true });
421 }
422 });
423
424 it("reports actions it got from GitHub, and uses Diffinery's copy when GitHub is down", async () => {
425 const src = tmp("mirror-action-");
426 mkdirSync(path.join(src, "tools-abc123"), { recursive: true });
427 writeFileSync(path.join(src, "tools-abc123/action.yml"), "name: hello\nruns:\n using: node20\n main: main.js\n");
428 writeFileSync(path.join(src, "tools-abc123/main.js"), 'console.log("action ran:", process.env.GITHUB_ACTION_REPOSITORY, process.env.GITHUB_ACTION_REF);');
429 const tarball = path.join(src, "a.tgz");
430 execFileSync("tar", ["-czf", tarball, "-C", src, "tools-abc123"]);
431 // A fake codeload that serves acme/up and is down for everything else.
432 const hits: string[] = [];
433 const server = createServer((req, res) => {
434 hits.push(req.url ?? "");
435 if (req.url === "/acme/up/tar.gz/v1") {
436 res.writeHead(200, { "content-type": "application/gzip" });
437 res.end(readFileSync(tarball));
438 } else {
439 res.writeHead(503);
440 res.end();
441 }
442 });
443 await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
444 const prev = { ...actionDownload };
445 Object.assign(actionDownload, { codeload: `http://127.0.0.1:${(server.address() as AddressInfo).port}`, git: "http://127.0.0.1:9", backoffMs: 1 });
446 api.mirror.set("acme/down@v2", readFileSync(tarball));
447 try {
448 const c = await run(makeSpec(api, [{ uses: "acme/up@v1" }, { uses: "acme/down@v2" }, { uses: "acme/gone@v3" }]));
449 expect(api.logText(0)).toContain("action ran: acme/up v1");
450 expect(api.logText(1)).toContain("using Diffinery's copy of 'acme/down@v2', downloaded from GitHub at 2026-10-25T12:00:00.000Z");
451 expect(api.logText(1)).toContain("action ran: acme/down v2");
452 expect(hits.filter((h) => h === "/acme/down/tar.gz/v2")).toHaveLength(4); // GitHub first, with its retries
453 expect(c.conclusion).toBe("failure");
454 expect(c.failure?.step).toBe(2);
455 expect(c.failure?.message).toContain("Failed to download action 'acme/gone@v3': HTTP 503");
456 // Only what came from GitHub is reported (Diffinery then fetches it from GitHub itself).
457 expect(api.mirrorReports).toEqual(["acme/up@v1"]);
458 } finally {
459 Object.assign(actionDownload, prev);
460 await new Promise<void>((r) => server.close(() => r()));
461 rmSync(src, { recursive: true, force: true });
462 }
463 });
464
465 it("runs github-script, and built-ins it cannot mirror, as the real actions", async () => {
466 // fake codeload serving one node action for every repository
467 const src = tmp("real-action-");
468 mkdirSync(path.join(src, "top"), { recursive: true });
469 writeFileSync(path.join(src, "top/action.yml"), "name: real\nruns:\n using: node20\n main: main.js\n");
470 writeFileSync(
471 path.join(src, "top/main.js"),
472 'const e = process.env; console.log(`real ${e.GITHUB_ACTION_REPOSITORY}@${e.GITHUB_ACTION_REF} server=${e.GITHUB_SERVER_URL} results=${e.ACTIONS_RESULTS_URL} v2=${e.ACTIONS_CACHE_SERVICE_V2} token=${e.ACTIONS_RUNTIME_TOKEN ? "set" : "unset"} image=${e.ImageOS}`); require("fs").appendFileSync(e.GITHUB_OUTPUT, "artifact-url=https://github.com/acme/widgets/actions/runs/1/artifacts/7\\n");',
473 );
474 const tarball = path.join(src, "a.tgz");
475 execFileSync("tar", ["-czf", tarball, "-C", src, "top"]);
476 const hits: string[] = [];
477 const server = createServer((req, res) => {
478 hits.push(req.url ?? "");
479 res.writeHead(200, { "content-type": "application/gzip" });
480 res.end(readFileSync(tarball));
481 });
482 await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
483 const prev = process.env.DIFFINERY_ACTIONS_DOWNLOAD_URL;
484 process.env.DIFFINERY_ACTIONS_DOWNLOAD_URL = `http://127.0.0.1:${(server.address() as AddressInfo).port}/{owner}/{repo}/{ref}`;
485 try {
486 const c = await run(
487 makeSpec(api, [
488 { uses: "actions/github-script@v7", with: { script: "return 1" } },
489 { uses: "actions/cache@v4", env: { DIFFINERY_BUILTINS: "false" }, with: { path: "x", key: "k" } },
490 { uses: "actions/setup-node@v4", with: { "node-version": "20", "some-future-input": "x" } },
491 { uses: "actions/checkout@main" },
492 { id: "up", uses: "actions/upload-artifact@v4", with: { name: "a", path: "x", "unknown-input": "1" } },
493 { run: 'echo "url=${{ steps.up.outputs.artifact-url }}"' },
494 ]),
495 );
496 expect(c.conclusion, api.logText()).toBe("success");
497 expect(api.logText(0)).toContain(`real actions/github-script@v7 server=${api.url} results=${api.url}/ v2=true token=set image=ubuntu24`);
498 expect(api.logText(0)).not.toContain(api.runtimeToken);
499 expect(api.logText(1)).toContain("real actions/cache@v4");
500 expect(api.logText(2)).toContain("real actions/setup-node@v4");
501 expect(api.logText(3)).toContain("real actions/checkout@main");
502 // @actions/artifact refuses GHES, so the artifact actions see github.com and their URLs are mapped back
503 expect(api.logText(4)).toContain("real actions/upload-artifact@v4 server=https://github.com");
504 expect(api.logText(5)).toContain(`url=${api.url}/acme/widgets/actions/runs/1/artifacts/7`);
505 expect(hits).toEqual(["/actions/github-script/v7", "/actions/cache/v4", "/actions/setup-node/v4", "/actions/checkout/main", "/actions/upload-artifact/v4"]);
506 } finally {
507 if (prev === undefined) delete process.env.DIFFINERY_ACTIONS_DOWNLOAD_URL;
508 else process.env.DIFFINERY_ACTIONS_DOWNLOAD_URL = prev;
509 await new Promise<void>((r) => server.close(() => r()));
510 rmSync(src, { recursive: true, force: true });
511 }
512 });
513
514 it("enforces step timeout-minutes", async () => {
515 const start = Date.now();
516 const c = await run(makeSpec(api, [{ "timeout-minutes": 0.02, run: "sleep 30" }, { if: "always()", run: "echo after-timeout" }]));
517 expect(Date.now() - start).toBeLessThan(15_000);
518 expect(c.conclusion).toBe("failure");
519 expect(c.failure?.message).toContain("timed out");
520 expect(api.logText(1)).toContain("after-timeout");
521 });
522
523 const cancelOnce = async (steps: StepSpec[], cancel: (r: JobRunner) => void) => {
524 let runner: JobRunner | undefined;
525 const p = run(makeSpec(api, steps), { onRunner: (r) => (runner = r) });
526 const t0 = Date.now();
527 while (!api.logText(0).includes("started") && Date.now() - t0 < 10_000) await new Promise((r) => setTimeout(r, 50));
528 cancel(runner!);
529 return { c: await p, ms: Date.now() - t0 };
530 };
531
532 it("cancels on request: kills the process group, then runs only the steps that run after a cancel", async () => {
533 const { c, ms } = await cancelOnce(
534 [
535 { run: "echo started; (sleep 60 & wait)" },
536 { run: "echo never" },
537 { if: "failure()", run: "echo never-failure" },
538 { if: "always()", run: "echo always-ran" },
539 { if: "${{ cancelled() }}", run: 'echo "cancelled-ran $JOB_STATUS"', env: { JOB_STATUS: "${{ job.status }}" } },
540 { if: "!cancelled()", run: "echo never-not-cancelled" },
541 ],
542 (r) => r.cancel(),
543 );
544 expect(c.conclusion).toBe("cancelled");
545 expect(c.failure?.message).toBe("The job was cancelled.");
546 expect(api.lastReport(0)?.conclusion).toBe("cancelled");
547 expect(api.lastReport(1)?.conclusion).toBe("cancelled");
548 expect(api.lastReport(2)?.conclusion).toBe("cancelled");
549 expect(api.lastReport(3)?.conclusion).toBe("success");
550 expect(api.lastReport(4)?.conclusion).toBe("success");
551 expect(api.lastReport(5)?.conclusion).toBe("cancelled");
552 expect(api.logText(3)).toContain("always-ran");
553 expect(api.logText(4)).toContain("cancelled-ran cancelled");
554 expect(api.logText()).not.toContain("never");
555 expect(api.completion?.conclusion).toBe("cancelled");
556 expect(ms).toBeLessThan(10_000);
557 });
558
559 it("a second cancel stops the steps that run after a cancel", async () => {
560 const steps: StepSpec[] = [{ run: "echo started; sleep 60" }, { if: "always()", run: "echo cleanup-started; sleep 60" }, { if: "always()", run: "echo never" }];
561 let runner: JobRunner | undefined;
562 const p = run(makeSpec(api, steps), { onRunner: (r) => (runner = r) });
563 const t0 = Date.now();
564 while (!api.logText(0).includes("started") && Date.now() - t0 < 10_000) await new Promise((r) => setTimeout(r, 50));
565 runner!.cancel();
566 const t1 = Date.now();
567 while (!api.logText(1).includes("cleanup-started") && Date.now() - t1 < 15_000) await new Promise((r) => setTimeout(r, 50));
568 expect(api.logText(1)).toContain("cleanup-started");
569 runner!.cancel();
570 const c = await p;
571 expect(c.conclusion).toBe("cancelled");
572 expect(api.lastReport(1)?.conclusion).toBe("cancelled");
573 expect(api.lastReport(2)?.conclusion).toBe("cancelled");
574 expect(api.logText(2)).not.toContain("never");
575 expect(Date.now() - t1).toBeLessThan(15_000);
576 });
577
578 it("a process a step starts after the job was stopped is stopped at once", async () => {
579 let runner: JobRunner | undefined;
580 const p = run(makeSpec(api, [{ run: "echo started; sleep 60" }, { if: "always()", run: "echo cleanup-started; sleep 60" }]), { onRunner: (r) => (runner = r) });
581 const t0 = Date.now();
582 while (!api.logText(0).includes("started") && Date.now() - t0 < 10_000) await new Promise((r) => setTimeout(r, 50));
583 // Both cancels before the cleanup step's process starts: it must not run its 60 seconds.
584 runner!.cancel();
585 runner!.cancel();
586 const c = await p;
587 expect(c.conclusion).toBe("cancelled");
588 expect(api.lastReport(1)?.conclusion).toBe("cancelled");
589 expect(Date.now() - t0).toBeLessThan(20_000);
590 });
591
592 it("stops the steps that run after a cancel once their grace runs out", async () => {
593 let runner: JobRunner | undefined;
594 const p = run(makeSpec(api, [{ run: "echo started; sleep 60" }, { if: "always()", run: "echo cleanup-started; sleep 60" }, { if: "always()", run: "echo never" }]), { onRunner: (r) => (runner = r) });
595 const t0 = Date.now();
596 while (!api.logText(0).includes("started") && Date.now() - t0 < 10_000) await new Promise((r) => setTimeout(r, 50));
597 runner!.cancel("cancelled", 1500);
598 const c = await p;
599 expect(c.conclusion).toBe("cancelled");
600 expect(api.logText(1)).toContain("cleanup-started");
601 expect(api.lastReport(1)?.conclusion).toBe("cancelled");
602 expect(api.lastReport(2)?.conclusion).toBe("cancelled");
603 expect(api.logText()).toContain("took longer than");
604 expect(Date.now() - t0).toBeLessThan(15_000);
605 });
606
607 it("runs always() steps and post steps after the job's timeout", async () => {
608 const c = await run(makeSpec(api, [{ run: "echo started; sleep 60" }, { run: "echo never" }, { if: "always()", run: "echo after-job-timeout" }], { timeoutMinutes: 0.03 }));
609 expect(c.conclusion).toBe("failure");
610 expect(c.failure?.message).toContain("maximum execution time");
611 expect(api.lastReport(1)?.conclusion).toBe("cancelled");
612 expect(api.lastReport(2)?.conclusion).toBe("success");
613 expect(api.logText(2)).toContain("after-job-timeout");
614 });
615
616 it("never loses log lines when the log endpoint flakes", async () => {
617 api.failLogs = 2;
618 const c = await run(makeSpec(api, [{ run: "for i in $(seq 1 500); do echo \"n$i\"; done" }]));
619 expect(c.conclusion).toBe("success");
620 const nums = api.logs.filter((l) => l.step === 0 && /^n\d+$/.test(l.text)).map((l) => l.text);
621 expect(nums).toEqual(Array.from({ length: 500 }, (_, i) => `n${i + 1}`));
622 });
623
624 it("caches and restores via the cache service, and round-trips artifacts", async () => {
625 const steps: StepSpec[] = [
626 { run: 'mkdir -p deps dist/sub && echo "${{ github.run_id }}" > deps/marker && echo a > dist/a.txt && echo b > dist/sub/b.txt' },
627 { id: "cache", uses: "actions/cache@v4", with: { path: "deps", key: "deps-${{ hashFiles('**/lock.txt') }}-v1", "restore-keys": "deps-" } },
628 { run: 'echo "hit=${{ steps.cache.outputs.cache-hit }}"; cat deps/marker' },
629 { uses: "actions/upload-artifact@v4", with: { name: "dist", path: "dist/" } },
630 { id: "dl", uses: "actions/download-artifact@v4", with: { name: "dist", path: "out" } },
631 { run: 'cat out/a.txt out/sub/b.txt; echo "dl=${{ steps.dl.outputs.download-path }}"' },
632 ];
633 mkdirSync(workspace(), { recursive: true });
634 writeFileSync(path.join(workspace(), "lock.txt"), "v1");
635 const c1 = await run(makeSpec(api, steps, { runId: "first" }));
636 expect(c1.conclusion, api.logText()).toBe("success");
637 expect(api.logText(2)).toMatch(/\nhit=\n/); // like actions/cache, a miss leaves cache-hit unset
638 expect([...api.cache.keys()]).toHaveLength(1);
639 const key = [...api.cache.keys()][0]!;
640 expect(key).toMatch(/^deps-[0-9a-f]{64}-v1$/);
641 expect(api.artifacts.get("dist")?.mimeType).toBe("application/zip");
642 expect(api.artifacts.get("dist")?.hash).toMatch(/^sha256:[0-9a-f]{64}$/);
643 expect(api.logText(5)).toMatch(/a\nb\ndl=.*\/out/);
644 // post step saved the cache
645 expect(api.steps.some((s) => s.report.name === "Post Run actions/cache@v4" && s.report.conclusion === "success")).toBe(true);
646
647 // second run: wipe the cached dir, it must come back from the cache
648 rmSync(path.join(workspace(), "deps"), { recursive: true, force: true });
649 api.logs = [];
650 api.steps = [];
651 const steps2 = [steps[1]!, steps[2]!];
652 const c2 = await run(makeSpec(api, steps2, { runId: "second" }));
653 expect(c2.conclusion, api.logText()).toBe("success");
654 expect(api.logText(1)).toContain("hit=true");
655 expect(api.logText(1)).toContain("first");
656 // like actions/cache, the post step runs but does not save over an exact hit
657 expect(api.logText()).toContain(`Cache hit occurred on the primary key ${key}, not saving cache.`);
658 expect([...api.cache.keys()]).toEqual([key]);
659 });
660
661 it("streams a large cache entry in ranges, falls back to a full download, and leaves nothing half restored", async () => {
662 const steps: StepSpec[] = [
663 { id: "cache", uses: "actions/cache@v4", with: { path: "deps", key: "big-v1" } },
664 { run: 'echo "hit=${{ steps.cache.outputs.cache-hit }}"; cat deps/marker; sha256sum deps/big.bin | cut -c1-16' },
665 ];
666 // Random data doesn't compress, so the archive spans several 8 MiB ranges.
667 const big = randomBytes(20 * 1024 * 1024);
668 const sum = createHash("sha256").update(big).digest("hex").slice(0, 16);
669 const fill = () => {
670 mkdirSync(path.join(workspace(), "deps"), { recursive: true });
671 writeFileSync(path.join(workspace(), "deps", "marker"), "cached");
672 writeFileSync(path.join(workspace(), "deps", "big.bin"), big);
673 };
674 fill();
675 expect((await run(makeSpec(api, steps, { runId: "save" }))).conclusion, api.logText()).toBe("success");
676 expect(api.cache.get("big-v1")!.length).toBeGreaterThan(16 * 1024 * 1024);
677
678 const again = async (changes: number) => {
679 rmSync(path.join(workspace(), "deps"), { recursive: true, force: true });
680 api.logs = [];
681 api.steps = [];
682 api.blobGets = [];
683 api.blobChanges = changes;
684 return run(makeSpec(api, [steps[0]!, { run: 'set +e; echo "hit=${{ steps.cache.outputs.cache-hit }}"; ls deps 2>&1; cat deps/marker; sha256sum deps/big.bin | cut -c1-16; true' }], { runId: `r${changes}` }));
685 };
686 // Streamed: ranges, more than one, all of the same blob.
687 expect((await again(0)).conclusion, api.logText()).toBe("success");
688 expect(api.blobGets.length).toBeGreaterThan(2);
689 expect(api.blobGets.every((r) => r.startsWith("bytes="))).toBe(true);
690 expect(api.logText(1)).toContain("hit=true");
691 expect(api.logText(1)).toContain(sum);
692
693 // The blob changes under the stream once: the restore starts over from a full download.
694 expect((await again(1)).conclusion, api.logText()).toBe("success");
695 expect(api.logText(0)).toMatch(/Streaming the cache entry failed \(download failed: the blob changed while it was being downloaded\); downloading it in full/);
696 expect(api.logText(1)).toContain(sum);
697
698 // It keeps changing: the restore fails, and what the stream extracted is gone again.
699 expect((await again(1000)).conclusion, api.logText()).toBe("success");
700 expect(api.logText(0)).toContain("Failed to restore");
701 expect(api.logText(1)).toMatch(/hit=\n/);
702 expect(existsSync(path.join(workspace(), "deps"))).toBe(false);
703 });
704
705 it("checks out a repository with actions/checkout", async () => {
706 const origin = tmp("origin-");
707 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();
708 git("init", "-q", "-b", "main");
709 writeFileSync(path.join(origin, "README.md"), "first\n");
710 git("add", ".");
711 git("commit", "-q", "-m", "first");
712 writeFileSync(path.join(origin, "README.md"), "second\n");
713 git("commit", "-q", "-am", "second");
714 const sha = git("rev-parse", "HEAD");
715 git("tag", "v1");
716 const spec = makeSpec(
717 api,
718 [
719 { id: "co", uses: "actions/checkout@v4" },
720 { 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' },
721 { uses: "actions/checkout@v4", with: { path: "full", "fetch-depth": "0", ref: "v1" } },
722 { run: "git -C full rev-list --count HEAD; cat full/README.md" },
723 ],
724 { sha, repo: { owner: "acme", name: "widgets", cloneUrl: `file://${origin}`, defaultBranch: "main" } },
725 );
726 const c = await run(spec);
727 expect(c.conclusion, api.logText()).toBe("success");
728 const out = api.logText(1);
729 expect(out).toContain("second");
730 expect(out).toContain(sha);
731 expect(out).toMatch(/\nmain\n/);
732 expect(out).toContain(`commit=${sha}`);
733 expect(out).toMatch(/\n1$/);
734 expect(api.logText(3)).toMatch(/2\nsecond/);
735 expect(existsSync(path.join(workspace(), ".git"))).toBe(true);
736 rmSync(origin, { recursive: true, force: true });
737 });
738
739 it("checks out with LFS, submodules and sparse-checkout", async () => {
740 const root = tmp("checkout-features-");
741 const gitIn = (cwd: string, ...args: string[]) =>
742 execFileSync("git", ["-c", "protocol.file.allow=always", ...args], { cwd, env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" } }).toString().trim();
743 const sub = path.join(root, "sub");
744 const origin = path.join(root, "origin");
745 mkdirSync(sub);
746 gitIn(sub, "init", "-q", "-b", "main");
747 writeFileSync(path.join(sub, "lib.txt"), "from submodule\n");
748 gitIn(sub, "add", ".");
749 gitIn(sub, "commit", "-q", "-m", "sub");
750 mkdirSync(path.join(origin, "a"), { recursive: true });
751 mkdirSync(path.join(origin, "b"));
752 gitIn(origin, "init", "-q", "-b", "main");
753 gitIn(origin, "lfs", "install", "--local");
754 gitIn(origin, "lfs", "track", "*.bin");
755 writeFileSync(path.join(origin, "a/x.txt"), "x\n");
756 writeFileSync(path.join(origin, "b/y.txt"), "y\n");
757 writeFileSync(path.join(origin, "top.txt"), "top\n");
758 writeFileSync(path.join(origin, "big.bin"), "real lfs content\n");
759 gitIn(origin, "submodule", "add", "-q", `file://${sub}`, "vendor/sub");
760 gitIn(origin, "add", ".");
761 gitIn(origin, "commit", "-q", "-m", "init");
762 const sha = gitIn(origin, "rev-parse", "HEAD");
763 const spec = makeSpec(
764 api,
765 [
766 { uses: "actions/checkout@v4", with: { path: "full", lfs: "true", submodules: "recursive", "persist-credentials": "false" } },
767 { run: "cat full/big.bin full/vendor/sub/lib.txt full/b/y.txt" },
768 { uses: "actions/checkout@v4", with: { path: "plain" } },
769 { run: "head -n 1 plain/big.bin; ls plain/vendor/sub | wc -l" },
770 { uses: "actions/checkout@v4", with: { path: "sparse", "sparse-checkout": "a" } },
771 { run: "ls sparse; ls sparse/a" },
772 { uses: "actions/checkout@v4", with: { path: "nocone", "sparse-checkout": "b/y.txt", "sparse-checkout-cone-mode": "false" } },
773 { run: "find nocone -type f -not -path '*/.git/*' | sort" },
774 ],
775 {
776 sha,
777 repo: { owner: "acme", name: "widgets", cloneUrl: `file://${origin}`, defaultBranch: "main" },
778 env: { GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: "protocol.file.allow", GIT_CONFIG_VALUE_0: "always" },
779 },
780 );
781 try {
782 const c = await run(spec);
783 expect(c.conclusion, api.logText()).toBe("success");
784 expect(api.logText(1)).toContain("real lfs content\nfrom submodule\ny");
785 expect(api.logText(3)).toContain("version https://git-lfs.github.com/spec/v1");
786 expect(api.logText(3)).toMatch(/\n0$/);
787 expect(api.logText(5)).toMatch(/\na\nbig\.bin\ntop\.txt\nx\.txt$/); // cone mode keeps root files, drops b/ and vendor/
788 expect(api.logText(7)).toMatch(/\nnocone\/b\/y\.txt$/);
789 } finally {
790 rmSync(root, { recursive: true, force: true });
791 }
792 });
793
794 it("keeps the job token out of .git/config unless persist-credentials, even when checkout fails", async () => {
795 const root = tmp("http-git-");
796 const origin = path.join(root, "acme/widgets.git");
797 mkdirSync(origin, { recursive: true });
798 const seed = path.join(root, "seed");
799 const g = (cwd: string, ...args: string[]) => execFileSync("git", args, { cwd, env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" } }).toString().trim();
800 g(origin, "init", "-q", "--bare", "-b", "main");
801 mkdirSync(seed);
802 g(seed, "init", "-q", "-b", "main");
803 writeFileSync(path.join(seed, "README.md"), "hello\n");
804 g(seed, "add", ".");
805 g(seed, "commit", "-q", "-m", "first");
806 g(seed, "push", "-q", origin, "main");
807 const sha = g(seed, "rev-parse", "HEAD");
808 // git's smart HTTP through `git http-backend`, recording the credentials it is sent
809 const auths: string[] = [];
810 const server = createServer((req, res) => {
811 auths.push(req.headers.authorization ?? "");
812 const u = new URL(req.url ?? "/", "http://x");
813 const cgi = spawn("git", ["http-backend"], {
814 env: { ...process.env, GIT_PROJECT_ROOT: root, GIT_HTTP_EXPORT_ALL: "1", PATH_INFO: u.pathname, QUERY_STRING: u.search.slice(1), REQUEST_METHOD: req.method ?? "GET", CONTENT_TYPE: req.headers["content-type"] ?? "", HTTP_GIT_PROTOCOL: String(req.headers["git-protocol"] ?? "") },
815 });
816 req.pipe(cgi.stdin);
817 let buf = Buffer.alloc(0);
818 cgi.stdout.on("data", (d: Buffer) => (buf = Buffer.concat([buf, d])));
819 cgi.on("close", () => {
820 const sep = buf.indexOf("\r\n\r\n");
821 const head = buf.subarray(0, sep).toString();
822 const headers: Record<string, string> = {};
823 let status = 200;
824 for (const line of head.split("\r\n")) {
825 const [k, ...v] = line.split(":");
826 if (k!.toLowerCase() === "status") status = Number(v.join(":").trim().split(" ")[0]);
827 else headers[k!] = v.join(":").trim();
828 }
829 res.writeHead(status, headers);
830 res.end(buf.subarray(sep + 4));
831 });
832 });
833 await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
834 const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
835 try {
836 const headers = "git config --get-regexp 'http\\..*\\.extraheader' | wc -l";
837 const spec = makeSpec(
838 api,
839 [
840 { uses: "actions/checkout@v4", with: { path: "a", "persist-credentials": "false" } },
841 { run: `echo "a=$(cd a && ${headers})"; cat a/README.md` },
842 { uses: "actions/checkout@v4", with: { path: "b" } },
843 { run: `echo "b=$(cd b && ${headers})"` },
844 { uses: "actions/checkout@v4", "continue-on-error": true, with: { path: "c", ref: "no-such-branch" } },
845 { run: `echo "c=$(cd c && ${headers})"` },
846 ],
847 { sha, repo: { owner: "acme", name: "widgets", cloneUrl: `${url}/acme/widgets.git`, defaultBranch: "main" } },
848 );
849 const c = await run(spec);
850 expect(c.conclusion, api.logText()).toBe("success");
851 // the token reached the server while fetching...
852 expect(auths.filter((a) => a.toLowerCase().startsWith("basic ")).length).toBeGreaterThan(0);
853 expect(api.logText(1)).toContain("hello");
854 // ...but stays in .git/config only when persisted, and not after a failed checkout
855 expect(api.logText(1)).toContain("a=0");
856 expect(api.logText(3)).toContain("b=1");
857 expect(api.logText(4)).toContain("Failed to fetch ref 'no-such-branch'");
858 expect(api.logText(5)).toContain("c=0");
859 } finally {
860 await new Promise<void>((r) => server.close(() => r()));
861 rmSync(root, { recursive: true, force: true });
862 }
863 });
864
865 it("gives the runtime services' variables to actions, not to run steps, and never the runner's own token", async () => {
866 const c = await runAgentJob(makeSpec(api, [{ run: 'echo "rt=${ACTIONS_RUNTIME_TOKEN:-unset} results=${ACTIONS_RESULTS_URL:-unset} gh=${DIFFINERY_GITHUB_TOKEN:-unset} job=${DIFFINERY_JOB_TOKEN:-unset}"' }]), {
867 apiBase: apiBaseFor(api.url, "job-1"),
868 workRoot,
869 toolCache: path.join(workRoot, "_tool"),
870 logIntervalMs: 50,
871 echoLogs: false,
872 baseEnv: { ...process.env, DIFFINERY_GITHUB_TOKEN: "ghp_operator", DIFFINERY_JOB_TOKEN: "djt_x" },
873 });
874 expect(c.conclusion, api.logText()).toBe("success");
875 expect(api.logText(0)).toContain("rt=unset results=unset gh=unset job=unset");
876 });
877
878 it("sets OIDC request variables when id-token is granted", async () => {
879 const c = await run(
880 makeSpec(api, [{ run: 'curl -s -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts" ; echo' }], { idToken: true }),
881 );
882 expect(c.conclusion).toBe("success");
883 expect(api.logText(0)).toContain('{"value":"oidc-for-sts"}');
884 });
885
886 it("evaluates job-level env and spec-level defaults.run", async () => {
887 mkdirSync(path.join(workspace(), "pkg"), { recursive: true });
888 const c = await run(
889 makeSpec(api, [{ run: 'echo "wd=$(basename "$(pwd)") shell=$0 e=$E"' }], {
890 defaults: { run: { shell: "sh -e {0}", "working-directory": "pkg" } },
891 env: { E: "${{ vars.V }}-${{ inputs.i }}-${{ needs.prev.outputs.o }}" },
892 vars: { V: "var" },
893 inputs: { i: "inp" },
894 needs: { prev: { result: "success", outputs: { o: "out" } } },
895 }),
896 );
897 expect(c.conclusion).toBe("success");
898 expect(api.logText(0)).toMatch(/wd=pkg shell=\S+\.sh e=var-inp-out/);
899 });
900
901 it("lets workflow env override CI and DIFFINERY but not GITHUB_* or RUNNER_*", async () => {
902 const c = await run(
903 makeSpec(api, [{ env: { CI: "step" }, run: 'echo "d=$DIFFINERY ci=$CI sha=$GITHUB_SHA os=$RUNNER_OS"' }], {
904 env: { DIFFINERY: "https://diffinery.example", GITHUB_SHA: "nope", RUNNER_OS: "nope" },
905 }),
906 );
907 expect(c.conclusion).toBe("success");
908 expect(api.logText(0)).toMatch(/d=https:\/\/diffinery\.example ci=step sha=[0-9a-f]{40} os=Linux/);
909 });
910});
911
912describe("agent binary (esbuild bundle)", () => {
913 it("runs a --spec job and an env-driven job", async () => {
914 const { build } = await import("esbuild");
915 const out = path.join(workRoot, "agent.mjs");
916 await build({
917 entryPoints: [path.join(__dirname, "../src/main.ts")],
918 bundle: true,
919 platform: "node",
920 target: "node22",
921 format: "esm",
922 outfile: out,
923 logLevel: "silent",
924 // keep in sync with the `build` script in package.json
925 banner: { js: "import { createRequire as __diffineryCreateRequire } from 'node:module'; const require = __diffineryCreateRequire(import.meta.url);" },
926 });
927 const spec = makeSpec(api, [{ run: 'echo "from bundle $GITHUB_JOB"' }], { outputs: { x: "y" } });
928 const specFile = path.join(workRoot, "spec.json");
929 writeFileSync(specFile, JSON.stringify(spec));
930 const env: NodeJS.ProcessEnv = { ...process.env, DIFFINERY_WORK_ROOT: path.join(workRoot, "w1") };
931 delete env.DIFFINERY_API_URL;
932 const r = await spawnAsync(process.execPath, [out, "--spec", specFile], env);
933 expect(r.status, r.stderr + r.stdout).toBe(0);
934 expect(api.completion).toMatchObject({ conclusion: "success", outputs: { x: "y" } });
935 expect(api.logText(0)).toContain("from bundle build");
936
937 // env mode: fetch the spec from the API, saying where the runner runs (its image and container)
938 api.completion = null;
939 api.specs.set("job-2", { ...spec, jobId: "job-2", steps: [{ run: "exit 4" }] });
940 writeFileSync(path.join(workRoot, "image-tag"), "diffinery-runner:abc123\n");
941 const r2 = await spawnAsync(process.execPath, [out], {
942 ...process.env,
943 CLOUDFLARE_LOCATION: "fra06",
944 CLOUDFLARE_REGION: "EEUR",
945 CLOUDFLARE_DURABLE_OBJECT_ID: "d0",
946 DIFFINERY_WORK_ROOT: path.join(workRoot, "w2"),
947 DIFFINERY_API_URL: api.url,
948 DIFFINERY_JOB_ID: "job-2",
949 DIFFINERY_JOB_TOKEN: api.token,
950 });
951 expect(r2.status).toBe(1);
952 const runner = JSON.parse(api.requests.find((q) => q.path === "/runner/jobs/job-2")?.runner ?? "{}");
953 expect(runner).toMatchObject({ image: "diffinery-runner:abc123", location: "fra06, EEUR", instance: "d0" });
954 expect(api.logText()).toContain("Runner: image diffinery-runner:abc123, in fra06, EEUR, container d0");
955 // A hosted runner also names its host's processor and, at the end, the share of its vCPUs' time the host took.
956 if (existsSync("/proc/cpuinfo")) {
957 expect(api.logText()).toContain(`Host: ${runner.cpu}`);
958 expect(api.logText()).toMatch(/Host: \d+% of the vCPUs' time went to other work on the host \(steal\)/);
959 }
960 expect(api.completion).toMatchObject({ conclusion: "failure", failure: { step: 0 } });
961 expect(readFileSync(path.join(workRoot, "w2/_temp/_github_workflow/event.json"), "utf8")).toContain("pusher");
962
963 const startJob = (id: string, steps: StepSpec[]) => {
964 api.completion = null;
965 api.steps = [];
966 api.logs = [];
967 api.specs.set(id, { ...spec, jobId: id, steps });
968 const child = spawn(process.execPath, [out], {
969 env: { ...process.env, DIFFINERY_WORK_ROOT: path.join(workRoot, id), DIFFINERY_API_URL: api.url, DIFFINERY_JOB_ID: id, DIFFINERY_JOB_TOKEN: api.token },
970 });
971 const exited = new Promise<number | null>((r) => child.on("close", (code) => r(code)));
972 return { child, exited };
973 };
974 const waitForLog = async (text: string) => {
975 const t0 = Date.now();
976 while (!api.logs.some((l) => l.text === text) && Date.now() - t0 < 15_000) await new Promise((r) => setTimeout(r, 50));
977 };
978
979 // One SIGTERM (a container rollout): the job keeps running and finishes.
980 const drain = startJob("job-3", [{ run: "echo started; sleep 2" }, { run: "echo after" }]);
981 await waitForLog("started");
982 drain.child.kill("SIGTERM");
983 expect(await drain.exited).toBe(0);
984 expect(api.completion).toMatchObject({ conclusion: "success" });
985 expect(api.completion).not.toHaveProperty("replaced");
986 expect(api.logText()).toContain("The platform is stopping this runner");
987 expect(api.logText(1)).toContain("after");
988
989 // A second SIGTERM: kill the running step's process group and report cancelled.
990 const t0 = Date.now();
991 const stop = startJob("job-4", [{ run: "echo started; sleep 60" }, { run: "echo never" }]);
992 await waitForLog("started");
993 stop.child.kill("SIGTERM");
994 await new Promise((r) => setTimeout(r, 200));
995 stop.child.kill("SIGTERM");
996 expect(await stop.exited).toBe(1);
997 expect(Date.now() - t0).toBeLessThan(15_000);
998 expect(api.completion).toMatchObject({ conclusion: "cancelled", replaced: true });
999 expect(api.lastReport(0)?.conclusion).toBe("cancelled");
1000 expect(api.lastReport(1)?.conclusion).toBe("cancelled");
1001
1002 // SIGINT (Diffinery cancelling the job): cancel at once, with no drain.
1003 const t1 = Date.now();
1004 const cancel = startJob("job-5", [{ run: "echo started; sleep 60" }]);
1005 await waitForLog("started");
1006 cancel.child.kill("SIGINT");
1007 expect(await cancel.exited).toBe(1);
1008 expect(Date.now() - t1).toBeLessThan(15_000);
1009 expect(api.completion).toMatchObject({ conclusion: "cancelled" });
1010 expect(api.completion).not.toHaveProperty("replaced");
1011 expect(api.logText()).not.toContain("being replaced");
1012
1013 // A rollout's SIGTERM reaches every process in the container, steps included: the
1014 // step fails with a message that names the rollout, the rest of the job is cancelled
1015 // without waiting out the drain, and the result says the runner was replaced, so
1016 // Diffinery can start the job again on a new runner.
1017 const t2 = Date.now();
1018 const rollout = startJob("job-6", [{ run: "echo started $$; sleep 60" }, { run: "echo never" }]);
1019 while (!/started \d+/.test(api.logText()) && Date.now() - t2 < 15_000) await new Promise((r) => setTimeout(r, 50));
1020 const pid = Number(/started (\d+)/.exec(api.logText())?.[1]);
1021 rollout.child.kill("SIGTERM");
1022 await new Promise((r) => setTimeout(r, 200));
1023 process.kill(-pid, "SIGTERM");
1024 expect(await rollout.exited).toBe(1);
1025 expect(Date.now() - t2).toBeLessThan(15_000);
1026 expect(api.completion).toMatchObject({ conclusion: "cancelled", replaced: true });
1027 expect(api.lastReport(0)?.conclusion).toBe("cancelled");
1028 expect(api.lastReport(1)?.conclusion).toBe("cancelled");
1029 expect(api.logText()).toContain("The platform stopped this step's processes while stopping the runner");
1030 }, 60_000);
1031});