| 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("rejects github-script with a clear error", async () => { |
| 423 | const c2 = await run(makeSpec(api, [{ uses: "actions/github-script@v7", with: { script: "1" } }])); |
| 424 | expect(c2.failure?.message).toContain("actions/github-script is not supported"); |
| 425 | }); |
| 426 | |
| 427 | it("enforces step timeout-minutes", async () => { |
| 428 | const start = Date.now(); |
| 429 | const c = await run(makeSpec(api, [{ "timeout-minutes": 0.02, run: "sleep 30" }, { if: "always()", run: "echo after-timeout" }])); |
| 430 | expect(Date.now() - start).toBeLessThan(15_000); |
| 431 | expect(c.conclusion).toBe("failure"); |
| 432 | expect(c.failure?.message).toContain("timed out"); |
| 433 | expect(api.logText(1)).toContain("after-timeout"); |
| 434 | }); |
| 435 | |
| 436 | it("cancels on request: kills the process group and reports cancelled", async () => { |
| 437 | let runner: JobRunner | undefined; |
| 438 | const spec = makeSpec(api, [{ run: "echo started; (sleep 60 & wait)" }, { run: "echo never" }, { if: "always()", run: "echo never2" }]); |
| 439 | const p = run(spec, { onRunner: (r) => (runner = r) }); |
| 440 | const t0 = Date.now(); |
| 441 | while (!api.logText(0).includes("started") && Date.now() - t0 < 10_000) await new Promise((r) => setTimeout(r, 50)); |
| 442 | runner!.cancel(); |
| 443 | const c = await p; |
| 444 | expect(c.conclusion).toBe("cancelled"); |
| 445 | expect(api.lastReport(0)?.conclusion).toBe("cancelled"); |
| 446 | expect(api.lastReport(1)?.conclusion).toBe("cancelled"); |
| 447 | expect(api.lastReport(2)?.conclusion).toBe("cancelled"); |
| 448 | expect(api.completion?.conclusion).toBe("cancelled"); |
| 449 | expect(Date.now() - t0).toBeLessThan(10_000); |
| 450 | }); |
| 451 | |
| 452 | it("never loses log lines when the log endpoint flakes", async () => { |
| 453 | api.failLogs = 2; |
| 454 | const c = await run(makeSpec(api, [{ run: "for i in $(seq 1 500); do echo \"n$i\"; done" }])); |
| 455 | expect(c.conclusion).toBe("success"); |
| 456 | const nums = api.logs.filter((l) => l.step === 0 && /^n\d+$/.test(l.text)).map((l) => l.text); |
| 457 | expect(nums).toEqual(Array.from({ length: 500 }, (_, i) => `n${i + 1}`)); |
| 458 | }); |
| 459 | |
| 460 | it("caches and restores via the runner cache endpoint, and round-trips artifacts", async () => { |
| 461 | const steps: StepSpec[] = [ |
| 462 | { run: 'mkdir -p deps dist/sub && echo "${{ github.run_id }}" > deps/marker && echo a > dist/a.txt && echo b > dist/sub/b.txt' }, |
| 463 | { id: "cache", uses: "actions/cache@v4", with: { path: "deps", key: "deps-${{ hashFiles('**/lock.txt') }}-v1", "restore-keys": "deps-" } }, |
| 464 | { run: 'echo "hit=${{ steps.cache.outputs.cache-hit }}"; cat deps/marker' }, |
| 465 | { uses: "actions/upload-artifact@v4", with: { name: "dist", path: "dist/" } }, |
| 466 | { id: "dl", uses: "actions/download-artifact@v4", with: { name: "dist", path: "out" } }, |
| 467 | { run: 'cat out/a.txt out/sub/b.txt; echo "dl=${{ steps.dl.outputs.download-path }}"' }, |
| 468 | ]; |
| 469 | mkdirSync(workspace(), { recursive: true }); |
| 470 | writeFileSync(path.join(workspace(), "lock.txt"), "v1"); |
| 471 | const c1 = await run(makeSpec(api, steps, { runId: "first" })); |
| 472 | expect(c1.conclusion, api.logText()).toBe("success"); |
| 473 | expect(api.logText(2)).toContain("hit=false"); |
| 474 | expect([...api.cache.keys()]).toHaveLength(1); |
| 475 | const key = [...api.cache.keys()][0]!; |
| 476 | expect(key).toMatch(/^deps-[0-9a-f]{64}-v1$/); |
| 477 | expect(api.artifacts.has("dist")).toBe(true); |
| 478 | expect(api.logText(5)).toMatch(/a\nb\ndl=.*\/out/); |
| 479 | // post step saved the cache |
| 480 | expect(api.steps.some((s) => s.report.name === "Post Run actions/cache@v4" && s.report.conclusion === "success")).toBe(true); |
| 481 | |
| 482 | // second run: wipe the cached dir, it must come back from the cache |
| 483 | rmSync(path.join(workspace(), "deps"), { recursive: true, force: true }); |
| 484 | api.logs = []; |
| 485 | api.steps = []; |
| 486 | const steps2 = [steps[1]!, steps[2]!]; |
| 487 | const c2 = await run(makeSpec(api, steps2, { runId: "second" })); |
| 488 | expect(c2.conclusion, api.logText()).toBe("success"); |
| 489 | expect(api.logText(1)).toContain("hit=true"); |
| 490 | expect(api.logText(1)).toContain("first"); |
| 491 | expect(api.steps.some((s) => s.report.name?.startsWith("Post"))).toBe(false); |
| 492 | }); |
| 493 | |
| 494 | it("checks out a repository with actions/checkout", async () => { |
| 495 | const origin = tmp("origin-"); |
| 496 | 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(); |
| 497 | git("init", "-q", "-b", "main"); |
| 498 | writeFileSync(path.join(origin, "README.md"), "first\n"); |
| 499 | git("add", "."); |
| 500 | git("commit", "-q", "-m", "first"); |
| 501 | writeFileSync(path.join(origin, "README.md"), "second\n"); |
| 502 | git("commit", "-q", "-am", "second"); |
| 503 | const sha = git("rev-parse", "HEAD"); |
| 504 | git("tag", "v1"); |
| 505 | const spec = makeSpec( |
| 506 | api, |
| 507 | [ |
| 508 | { id: "co", uses: "actions/checkout@v4" }, |
| 509 | { 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' }, |
| 510 | { uses: "actions/checkout@v4", with: { path: "full", "fetch-depth": "0", ref: "v1" } }, |
| 511 | { run: "git -C full rev-list --count HEAD; cat full/README.md" }, |
| 512 | ], |
| 513 | { sha, repo: { owner: "acme", name: "widgets", cloneUrl: `file://${origin}`, defaultBranch: "main" } }, |
| 514 | ); |
| 515 | const c = await run(spec); |
| 516 | expect(c.conclusion, api.logText()).toBe("success"); |
| 517 | const out = api.logText(1); |
| 518 | expect(out).toContain("second"); |
| 519 | expect(out).toContain(sha); |
| 520 | expect(out).toMatch(/\nmain\n/); |
| 521 | expect(out).toContain(`commit=${sha}`); |
| 522 | expect(out).toMatch(/\n1$/); |
| 523 | expect(api.logText(3)).toMatch(/2\nsecond/); |
| 524 | expect(existsSync(path.join(workspace(), ".git"))).toBe(true); |
| 525 | rmSync(origin, { recursive: true, force: true }); |
| 526 | }); |
| 527 | |
| 528 | it("sets OIDC request variables when id-token is granted", async () => { |
| 529 | const c = await run( |
| 530 | makeSpec(api, [{ run: 'curl -s -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts" ; echo' }], { idToken: true }), |
| 531 | ); |
| 532 | expect(c.conclusion).toBe("success"); |
| 533 | expect(api.logText(0)).toContain('{"value":"oidc-for-sts"}'); |
| 534 | }); |
| 535 | |
| 536 | it("evaluates job-level env and spec-level defaults.run", async () => { |
| 537 | mkdirSync(path.join(workspace(), "pkg"), { recursive: true }); |
| 538 | const c = await run( |
| 539 | makeSpec(api, [{ run: 'echo "wd=$(basename "$(pwd)") shell=$0 e=$E"' }], { |
| 540 | defaults: { run: { shell: "sh -e {0}", "working-directory": "pkg" } }, |
| 541 | env: { E: "${{ vars.V }}-${{ inputs.i }}-${{ needs.prev.outputs.o }}" }, |
| 542 | vars: { V: "var" }, |
| 543 | inputs: { i: "inp" }, |
| 544 | needs: { prev: { result: "success", outputs: { o: "out" } } }, |
| 545 | }), |
| 546 | ); |
| 547 | expect(c.conclusion).toBe("success"); |
| 548 | expect(api.logText(0)).toMatch(/wd=pkg shell=\S+\.sh e=var-inp-out/); |
| 549 | }); |
| 550 | }); |
| 551 | |
| 552 | describe("agent binary (esbuild bundle)", () => { |
| 553 | it("runs a --spec job and an env-driven job", async () => { |
| 554 | const { build } = await import("esbuild"); |
| 555 | const out = path.join(workRoot, "agent.mjs"); |
| 556 | await build({ |
| 557 | entryPoints: [path.join(__dirname, "../src/main.ts")], |
| 558 | bundle: true, |
| 559 | platform: "node", |
| 560 | target: "node22", |
| 561 | format: "esm", |
| 562 | outfile: out, |
| 563 | logLevel: "silent", |
| 564 | // keep in sync with the `build` script in package.json |
| 565 | banner: { js: "import { createRequire as __diffineryCreateRequire } from 'node:module'; const require = __diffineryCreateRequire(import.meta.url);" }, |
| 566 | }); |
| 567 | const spec = makeSpec(api, [{ run: 'echo "from bundle $GITHUB_JOB"' }], { outputs: { x: "y" } }); |
| 568 | const specFile = path.join(workRoot, "spec.json"); |
| 569 | writeFileSync(specFile, JSON.stringify(spec)); |
| 570 | const env: NodeJS.ProcessEnv = { ...process.env, DIFFINERY_WORK_ROOT: path.join(workRoot, "w1") }; |
| 571 | delete env.DIFFINERY_API_URL; |
| 572 | const r = await spawnAsync(process.execPath, [out, "--spec", specFile], env); |
| 573 | expect(r.status, r.stderr + r.stdout).toBe(0); |
| 574 | expect(api.completion).toMatchObject({ conclusion: "success", outputs: { x: "y" } }); |
| 575 | expect(api.logText(0)).toContain("from bundle build"); |
| 576 | |
| 577 | // env mode: fetch the spec from the API |
| 578 | api.completion = null; |
| 579 | api.specs.set("job-2", { ...spec, jobId: "job-2", steps: [{ run: "exit 4" }] }); |
| 580 | const r2 = await spawnAsync(process.execPath, [out], { |
| 581 | ...process.env, |
| 582 | DIFFINERY_WORK_ROOT: path.join(workRoot, "w2"), |
| 583 | DIFFINERY_API_URL: api.url, |
| 584 | DIFFINERY_JOB_ID: "job-2", |
| 585 | DIFFINERY_JOB_TOKEN: api.token, |
| 586 | }); |
| 587 | expect(r2.status).toBe(1); |
| 588 | expect(api.completion).toMatchObject({ conclusion: "failure", failure: { step: 0 } }); |
| 589 | expect(readFileSync(path.join(workRoot, "w2/_temp/_github_workflow/event.json"), "utf8")).toContain("pusher"); |
| 590 | |
| 591 | const startJob = (id: string, steps: StepSpec[]) => { |
| 592 | api.completion = null; |
| 593 | api.steps = []; |
| 594 | api.logs = []; |
| 595 | api.specs.set(id, { ...spec, jobId: id, steps }); |
| 596 | const child = spawn(process.execPath, [out], { |
| 597 | env: { ...process.env, DIFFINERY_WORK_ROOT: path.join(workRoot, id), DIFFINERY_API_URL: api.url, DIFFINERY_JOB_ID: id, DIFFINERY_JOB_TOKEN: api.token }, |
| 598 | }); |
| 599 | const exited = new Promise<number | null>((r) => child.on("close", (code) => r(code))); |
| 600 | return { child, exited }; |
| 601 | }; |
| 602 | const waitForLog = async (text: string) => { |
| 603 | const t0 = Date.now(); |
| 604 | while (!api.logs.some((l) => l.text === text) && Date.now() - t0 < 15_000) await new Promise((r) => setTimeout(r, 50)); |
| 605 | }; |
| 606 | |
| 607 | // One SIGTERM (a container rollout): the job keeps running and finishes. |
| 608 | const drain = startJob("job-3", [{ run: "echo started; sleep 2" }, { run: "echo after" }]); |
| 609 | await waitForLog("started"); |
| 610 | drain.child.kill("SIGTERM"); |
| 611 | expect(await drain.exited).toBe(0); |
| 612 | expect(api.completion).toMatchObject({ conclusion: "success" }); |
| 613 | expect(api.logText()).toContain("being replaced by a new version"); |
| 614 | expect(api.logText(1)).toContain("after"); |
| 615 | |
| 616 | // A second SIGTERM: kill the running step's process group and report cancelled. |
| 617 | const t0 = Date.now(); |
| 618 | const stop = startJob("job-4", [{ run: "echo started; sleep 60" }, { run: "echo never" }]); |
| 619 | await waitForLog("started"); |
| 620 | stop.child.kill("SIGTERM"); |
| 621 | await new Promise((r) => setTimeout(r, 200)); |
| 622 | stop.child.kill("SIGTERM"); |
| 623 | expect(await stop.exited).toBe(1); |
| 624 | expect(Date.now() - t0).toBeLessThan(15_000); |
| 625 | expect(api.completion).toMatchObject({ conclusion: "cancelled" }); |
| 626 | expect(api.lastReport(0)?.conclusion).toBe("cancelled"); |
| 627 | expect(api.lastReport(1)?.conclusion).toBe("cancelled"); |
| 628 | }, 60_000); |
| 629 | }); |