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