| 1 | import { execFileSync, spawn } from "node:child_process"; |
| 2 | import { createHash, randomBytes } from "node:crypto"; |
| 3 | import { createServer } from "node:http"; |
| 4 | import type { AddressInfo } from "node:net"; |
| 5 | import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; |
| 6 | import path from "node:path"; |
| 7 | import { afterEach, beforeEach, describe, expect, it } from "vitest"; |
| 8 | import type { JobSpec, StepSpec } from "@diffinery/shared"; |
| 9 | import { apiBaseFor, runAgentJob } from "../src/agent.js"; |
| 10 | import type { JobRunner } from "../src/job.js"; |
| 11 | import { makeSpec, startFakeApi, tmp, type FakeApi } from "./helpers.js"; |
| 12 | |
| 13 | let api: FakeApi; |
| 14 | let workRoot: string; |
| 15 | |
| 16 | beforeEach(async () => { |
| 17 | api = await startFakeApi(); |
| 18 | workRoot = tmp(); |
| 19 | }); |
| 20 | afterEach(async () => { |
| 21 | await api.close(); |
| 22 | rmSync(workRoot, { recursive: true, force: true }); |
| 23 | }); |
| 24 | |
| 25 | function 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 | |
| 36 | const workspace = () => path.join(workRoot, "widgets"); |
| 37 | |
| 38 | /** Async spawn: the fake API lives in this process, so the event loop must stay free. */ |
| 39 | function 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 | |
| 54 | describe("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 |
| 259 | description: composite greet |
| 260 | inputs: |
| 261 | who: |
| 262 | description: who |
| 263 | required: true |
| 264 | punct: |
| 265 | default: "!" |
| 266 | outputs: |
| 267 | message: |
| 268 | description: msg |
| 269 | value: \${{ steps.say.outputs.msg }} |
| 270 | runs: |
| 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 |
| 294 | inputs: |
| 295 | my-input: |
| 296 | default: default-val |
| 297 | repo: |
| 298 | default: \${{ github.repository }} |
| 299 | outputs: |
| 300 | result: |
| 301 | description: r |
| 302 | runs: |
| 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"); |
| 317 | console.log("input=" + process.env["INPUT_MY-INPUT"] + " repo=" + process.env.INPUT_REPO + " prestate=" + process.env.STATE_fromPre); |
| 318 | fs.appendFileSync(process.env.GITHUB_OUTPUT, "result=from-node-" + process.env["INPUT_MY-INPUT"] + "\\n"); |
| 319 | fs.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 | |
| 793 | describe("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 | }); |