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.

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