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.

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