@redwoodjs/agent-ci 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,655 @@
1
+ import { describe, it, expect, afterEach } from "vitest";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import { expandReusableJobs } from "./reusable-workflow.js";
6
+ describe("expandReusableJobs", () => {
7
+ let tmpDir;
8
+ function setup() {
9
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "reusable-wf-test-"));
10
+ // Create .github/workflows structure
11
+ const wfDir = path.join(tmpDir, ".github", "workflows");
12
+ fs.mkdirSync(wfDir, { recursive: true });
13
+ // Create a fake .git so resolveRepoRoot can find it
14
+ fs.mkdirSync(path.join(tmpDir, ".git"), { recursive: true });
15
+ return wfDir;
16
+ }
17
+ afterEach(() => {
18
+ if (tmpDir) {
19
+ fs.rmSync(tmpDir, { recursive: true, force: true });
20
+ }
21
+ });
22
+ it("returns regular jobs unchanged when no reusable calls", () => {
23
+ const wfDir = setup();
24
+ const wf = path.join(wfDir, "ci.yml");
25
+ fs.writeFileSync(wf, `
26
+ jobs:
27
+ build:
28
+ runs-on: ubuntu-latest
29
+ steps:
30
+ - run: echo build
31
+ test:
32
+ needs: build
33
+ runs-on: ubuntu-latest
34
+ steps:
35
+ - run: echo test
36
+ `);
37
+ const entries = expandReusableJobs(wf, tmpDir);
38
+ expect(entries).toEqual([
39
+ { id: "build", workflowPath: wf, sourceTaskName: "build", needs: [] },
40
+ { id: "test", workflowPath: wf, sourceTaskName: "test", needs: ["build"] },
41
+ ]);
42
+ });
43
+ it("expands a simple reusable workflow call (one caller → one called job)", () => {
44
+ const wfDir = setup();
45
+ const calledWf = path.join(wfDir, "lint.yml");
46
+ fs.writeFileSync(calledWf, `
47
+ on: workflow_call
48
+ jobs:
49
+ lint:
50
+ runs-on: ubuntu-latest
51
+ steps:
52
+ - run: echo lint
53
+ `);
54
+ const callerWf = path.join(wfDir, "ci.yml");
55
+ fs.writeFileSync(callerWf, `
56
+ jobs:
57
+ lint:
58
+ uses: ./.github/workflows/lint.yml
59
+ `);
60
+ const entries = expandReusableJobs(callerWf, tmpDir);
61
+ expect(entries).toHaveLength(1);
62
+ expect(entries[0]).toMatchObject({
63
+ id: "lint/lint",
64
+ workflowPath: calledWf,
65
+ sourceTaskName: "lint",
66
+ needs: [],
67
+ callerJobId: "lint",
68
+ });
69
+ });
70
+ it("expands multi-job called workflow with internal needs graph", () => {
71
+ const wfDir = setup();
72
+ const calledWf = path.join(wfDir, "test.yml");
73
+ fs.writeFileSync(calledWf, `
74
+ on: workflow_call
75
+ jobs:
76
+ setup:
77
+ runs-on: ubuntu-latest
78
+ steps:
79
+ - run: echo setup
80
+ unit:
81
+ needs: setup
82
+ runs-on: ubuntu-latest
83
+ steps:
84
+ - run: echo unit
85
+ integration:
86
+ needs: setup
87
+ runs-on: ubuntu-latest
88
+ steps:
89
+ - run: echo integration
90
+ `);
91
+ const callerWf = path.join(wfDir, "ci.yml");
92
+ fs.writeFileSync(callerWf, `
93
+ jobs:
94
+ test:
95
+ uses: ./.github/workflows/test.yml
96
+ `);
97
+ const entries = expandReusableJobs(callerWf, tmpDir);
98
+ expect(entries).toHaveLength(3);
99
+ const byId = Object.fromEntries(entries.map((e) => [e.id, e]));
100
+ // setup is entry-point — inherits caller's needs (none)
101
+ expect(byId["test/setup"].needs).toEqual([]);
102
+ // unit and integration depend on setup (prefixed)
103
+ expect(byId["test/unit"].needs).toEqual(["test/setup"]);
104
+ expect(byId["test/integration"].needs).toEqual(["test/setup"]);
105
+ // All point to the called workflow
106
+ expect(byId["test/setup"].workflowPath).toBe(calledWf);
107
+ expect(byId["test/unit"].sourceTaskName).toBe("unit");
108
+ });
109
+ it("rewires downstream deps to terminal jobs of inlined sub-graph", () => {
110
+ const wfDir = setup();
111
+ const calledWf = path.join(wfDir, "lint.yml");
112
+ fs.writeFileSync(calledWf, `
113
+ on: workflow_call
114
+ jobs:
115
+ setup:
116
+ runs-on: ubuntu-latest
117
+ steps:
118
+ - run: echo setup
119
+ check:
120
+ needs: setup
121
+ runs-on: ubuntu-latest
122
+ steps:
123
+ - run: echo check
124
+ `);
125
+ const callerWf = path.join(wfDir, "ci.yml");
126
+ fs.writeFileSync(callerWf, `
127
+ jobs:
128
+ lint:
129
+ uses: ./.github/workflows/lint.yml
130
+ deploy:
131
+ needs: lint
132
+ runs-on: ubuntu-latest
133
+ steps:
134
+ - run: echo deploy
135
+ `);
136
+ const entries = expandReusableJobs(callerWf, tmpDir);
137
+ const deploy = entries.find((e) => e.id === "deploy");
138
+ // deploy should depend on the terminal job of the lint sub-graph
139
+ expect(deploy.needs).toEqual(["lint/check"]);
140
+ });
141
+ it("handles mixed regular + reusable jobs", () => {
142
+ const wfDir = setup();
143
+ const calledWf = path.join(wfDir, "lint.yml");
144
+ fs.writeFileSync(calledWf, `
145
+ on: workflow_call
146
+ jobs:
147
+ lint:
148
+ runs-on: ubuntu-latest
149
+ steps:
150
+ - run: echo lint
151
+ `);
152
+ const callerWf = path.join(wfDir, "ci.yml");
153
+ fs.writeFileSync(callerWf, `
154
+ jobs:
155
+ build:
156
+ runs-on: ubuntu-latest
157
+ steps:
158
+ - run: echo build
159
+ lint:
160
+ uses: ./.github/workflows/lint.yml
161
+ test:
162
+ needs: [build, lint]
163
+ runs-on: ubuntu-latest
164
+ steps:
165
+ - run: echo test
166
+ `);
167
+ const entries = expandReusableJobs(callerWf, tmpDir);
168
+ expect(entries).toHaveLength(3);
169
+ const byId = Object.fromEntries(entries.map((e) => [e.id, e]));
170
+ expect(byId["build"].workflowPath).toBe(callerWf);
171
+ expect(byId["lint/lint"].workflowPath).toBe(calledWf);
172
+ // test depends on build (regular) and lint/lint (terminal of inlined sub-graph)
173
+ expect(byId["test"].needs).toEqual(["build", "lint/lint"]);
174
+ });
175
+ it("throws on unresolved remote uses refs", () => {
176
+ const wfDir = setup();
177
+ const callerWf = path.join(wfDir, "ci.yml");
178
+ fs.writeFileSync(callerWf, `
179
+ jobs:
180
+ build:
181
+ runs-on: ubuntu-latest
182
+ steps:
183
+ - run: echo build
184
+ lint:
185
+ uses: some-org/some-repo/.github/workflows/lint.yml@main
186
+ `);
187
+ expect(() => expandReusableJobs(callerWf, tmpDir)).toThrow(/Remote reusable workflow not resolved/);
188
+ });
189
+ it("expands remote uses refs when remoteCache is provided", () => {
190
+ const wfDir = setup();
191
+ // Create a file to act as the cached remote workflow
192
+ const cachedWf = path.join(wfDir, "cached-remote-lint.yml");
193
+ fs.writeFileSync(cachedWf, `
194
+ on: workflow_call
195
+ jobs:
196
+ lint:
197
+ runs-on: ubuntu-latest
198
+ steps:
199
+ - run: echo lint
200
+ `);
201
+ const callerWf = path.join(wfDir, "ci.yml");
202
+ fs.writeFileSync(callerWf, `
203
+ jobs:
204
+ build:
205
+ runs-on: ubuntu-latest
206
+ steps:
207
+ - run: echo build
208
+ lint:
209
+ uses: some-org/some-repo/.github/workflows/lint.yml@main
210
+ `);
211
+ const remoteCache = new Map();
212
+ remoteCache.set("some-org/some-repo/.github/workflows/lint.yml@main", cachedWf);
213
+ const entries = expandReusableJobs(callerWf, tmpDir, remoteCache);
214
+ expect(entries).toHaveLength(2);
215
+ const byId = Object.fromEntries(entries.map((e) => [e.id, e]));
216
+ expect(byId["build"].workflowPath).toBe(callerWf);
217
+ expect(byId["lint/lint"].workflowPath).toBe(cachedWf);
218
+ expect(byId["lint/lint"].sourceTaskName).toBe("lint");
219
+ });
220
+ it("expands 2-level nested reusable workflows", () => {
221
+ const wfDir = setup();
222
+ const leafWf = path.join(wfDir, "leaf.yml");
223
+ fs.writeFileSync(leafWf, `
224
+ on: workflow_call
225
+ jobs:
226
+ job:
227
+ runs-on: ubuntu-latest
228
+ steps:
229
+ - run: echo leaf
230
+ `);
231
+ const innerWf = path.join(wfDir, "inner.yml");
232
+ fs.writeFileSync(innerWf, `
233
+ on: workflow_call
234
+ jobs:
235
+ nested:
236
+ uses: ./.github/workflows/leaf.yml
237
+ `);
238
+ const callerWf = path.join(wfDir, "ci.yml");
239
+ fs.writeFileSync(callerWf, `
240
+ jobs:
241
+ outer:
242
+ uses: ./.github/workflows/inner.yml
243
+ `);
244
+ const entries = expandReusableJobs(callerWf, tmpDir);
245
+ expect(entries).toHaveLength(1);
246
+ expect(entries[0]).toMatchObject({
247
+ id: "outer/nested/job",
248
+ workflowPath: leafWf,
249
+ sourceTaskName: "job",
250
+ needs: [],
251
+ });
252
+ });
253
+ it("expands 2-level nested workflows with internal needs and downstream deps", () => {
254
+ const wfDir = setup();
255
+ const leafWf = path.join(wfDir, "b.yml");
256
+ fs.writeFileSync(leafWf, `
257
+ on: workflow_call
258
+ jobs:
259
+ leaf:
260
+ runs-on: ubuntu-latest
261
+ steps:
262
+ - run: echo leaf
263
+ `);
264
+ const midWf = path.join(wfDir, "a.yml");
265
+ fs.writeFileSync(midWf, `
266
+ on: workflow_call
267
+ jobs:
268
+ inner:
269
+ uses: ./.github/workflows/b.yml
270
+ post:
271
+ needs: inner
272
+ runs-on: ubuntu-latest
273
+ steps:
274
+ - run: echo post
275
+ `);
276
+ const callerWf = path.join(wfDir, "ci.yml");
277
+ fs.writeFileSync(callerWf, `
278
+ jobs:
279
+ outer:
280
+ uses: ./.github/workflows/a.yml
281
+ deploy:
282
+ needs: outer
283
+ runs-on: ubuntu-latest
284
+ steps:
285
+ - run: echo deploy
286
+ `);
287
+ const entries = expandReusableJobs(callerWf, tmpDir);
288
+ const byId = Object.fromEntries(entries.map((e) => [e.id, e]));
289
+ expect(byId["outer/inner/leaf"].needs).toEqual([]);
290
+ expect(byId["outer/post"].needs).toEqual(["outer/inner/leaf"]);
291
+ expect(byId["deploy"].needs).toEqual(["outer/post"]);
292
+ });
293
+ it("expands 3-level nested reusable workflows with chained composite IDs", () => {
294
+ const wfDir = setup();
295
+ const l3 = path.join(wfDir, "l3.yml");
296
+ fs.writeFileSync(l3, `
297
+ on: workflow_call
298
+ jobs:
299
+ leaf:
300
+ runs-on: ubuntu-latest
301
+ steps:
302
+ - run: echo leaf
303
+ `);
304
+ const l2 = path.join(wfDir, "l2.yml");
305
+ fs.writeFileSync(l2, `
306
+ on: workflow_call
307
+ jobs:
308
+ l3:
309
+ uses: ./.github/workflows/l3.yml
310
+ `);
311
+ const l1 = path.join(wfDir, "l1.yml");
312
+ fs.writeFileSync(l1, `
313
+ on: workflow_call
314
+ jobs:
315
+ l2:
316
+ uses: ./.github/workflows/l2.yml
317
+ `);
318
+ const callerWf = path.join(wfDir, "ci.yml");
319
+ fs.writeFileSync(callerWf, `
320
+ jobs:
321
+ l1:
322
+ uses: ./.github/workflows/l1.yml
323
+ `);
324
+ const entries = expandReusableJobs(callerWf, tmpDir);
325
+ expect(entries).toHaveLength(1);
326
+ expect(entries[0]).toMatchObject({
327
+ id: "l1/l2/l3/leaf",
328
+ workflowPath: l3,
329
+ sourceTaskName: "leaf",
330
+ needs: [],
331
+ });
332
+ });
333
+ it("expands 4-level nested reusable workflows (GitHub max depth)", () => {
334
+ const wfDir = setup();
335
+ const l4 = path.join(wfDir, "l4.yml");
336
+ fs.writeFileSync(l4, `
337
+ on: workflow_call
338
+ jobs:
339
+ leaf:
340
+ runs-on: ubuntu-latest
341
+ steps:
342
+ - run: echo leaf
343
+ `);
344
+ const l3 = path.join(wfDir, "l3.yml");
345
+ fs.writeFileSync(l3, `
346
+ on: workflow_call
347
+ jobs:
348
+ l4:
349
+ uses: ./.github/workflows/l4.yml
350
+ `);
351
+ const l2 = path.join(wfDir, "l2.yml");
352
+ fs.writeFileSync(l2, `
353
+ on: workflow_call
354
+ jobs:
355
+ l3:
356
+ uses: ./.github/workflows/l3.yml
357
+ `);
358
+ const l1 = path.join(wfDir, "l1.yml");
359
+ fs.writeFileSync(l1, `
360
+ on: workflow_call
361
+ jobs:
362
+ l2:
363
+ uses: ./.github/workflows/l2.yml
364
+ `);
365
+ const callerWf = path.join(wfDir, "ci.yml");
366
+ fs.writeFileSync(callerWf, `
367
+ jobs:
368
+ l1:
369
+ uses: ./.github/workflows/l1.yml
370
+ `);
371
+ const entries = expandReusableJobs(callerWf, tmpDir);
372
+ expect(entries).toHaveLength(1);
373
+ expect(entries[0]).toMatchObject({
374
+ id: "l1/l2/l3/l4/leaf",
375
+ workflowPath: l4,
376
+ sourceTaskName: "leaf",
377
+ needs: [],
378
+ });
379
+ });
380
+ it("throws when nesting depth exceeds 4", () => {
381
+ const wfDir = setup();
382
+ const l5 = path.join(wfDir, "l5.yml");
383
+ fs.writeFileSync(l5, `
384
+ on: workflow_call
385
+ jobs:
386
+ leaf:
387
+ runs-on: ubuntu-latest
388
+ steps:
389
+ - run: echo leaf
390
+ `);
391
+ const l4 = path.join(wfDir, "l4.yml");
392
+ fs.writeFileSync(l4, `
393
+ on: workflow_call
394
+ jobs:
395
+ l5:
396
+ uses: ./.github/workflows/l5.yml
397
+ `);
398
+ const l3 = path.join(wfDir, "l3.yml");
399
+ fs.writeFileSync(l3, `
400
+ on: workflow_call
401
+ jobs:
402
+ l4:
403
+ uses: ./.github/workflows/l4.yml
404
+ `);
405
+ const l2 = path.join(wfDir, "l2.yml");
406
+ fs.writeFileSync(l2, `
407
+ on: workflow_call
408
+ jobs:
409
+ l3:
410
+ uses: ./.github/workflows/l3.yml
411
+ `);
412
+ const l1 = path.join(wfDir, "l1.yml");
413
+ fs.writeFileSync(l1, `
414
+ on: workflow_call
415
+ jobs:
416
+ l2:
417
+ uses: ./.github/workflows/l2.yml
418
+ `);
419
+ const callerWf = path.join(wfDir, "ci.yml");
420
+ fs.writeFileSync(callerWf, `
421
+ jobs:
422
+ l1:
423
+ uses: ./.github/workflows/l1.yml
424
+ `);
425
+ expect(() => expandReusableJobs(callerWf, tmpDir)).toThrow(/nesting depth exceeds maximum of 4/);
426
+ });
427
+ it("throws on cyclic reusable workflow references", () => {
428
+ const wfDir = setup();
429
+ const aWf = path.join(wfDir, "a.yml");
430
+ const bWf = path.join(wfDir, "b.yml");
431
+ fs.writeFileSync(aWf, `
432
+ on: workflow_call
433
+ jobs:
434
+ call-b:
435
+ uses: ./.github/workflows/b.yml
436
+ `);
437
+ fs.writeFileSync(bWf, `
438
+ on: workflow_call
439
+ jobs:
440
+ call-a:
441
+ uses: ./.github/workflows/a.yml
442
+ `);
443
+ const callerWf = path.join(wfDir, "ci.yml");
444
+ fs.writeFileSync(callerWf, `
445
+ jobs:
446
+ start:
447
+ uses: ./.github/workflows/a.yml
448
+ `);
449
+ expect(() => expandReusableJobs(callerWf, tmpDir)).toThrow(/Cycle detected/);
450
+ });
451
+ it("allows the same workflow to be reused by sibling jobs (not a cycle)", () => {
452
+ const wfDir = setup();
453
+ const sharedWf = path.join(wfDir, "shared.yml");
454
+ fs.writeFileSync(sharedWf, `
455
+ on: workflow_call
456
+ jobs:
457
+ run:
458
+ runs-on: ubuntu-latest
459
+ steps:
460
+ - run: echo shared
461
+ `);
462
+ const callerWf = path.join(wfDir, "ci.yml");
463
+ fs.writeFileSync(callerWf, `
464
+ jobs:
465
+ lint:
466
+ uses: ./.github/workflows/shared.yml
467
+ test:
468
+ uses: ./.github/workflows/shared.yml
469
+ `);
470
+ const entries = expandReusableJobs(callerWf, tmpDir);
471
+ expect(entries).toHaveLength(2);
472
+ const byId = Object.fromEntries(entries.map((e) => [e.id, e]));
473
+ expect(byId["lint/run"]).toBeDefined();
474
+ expect(byId["test/run"]).toBeDefined();
475
+ });
476
+ it("inherits caller needs for entry-point jobs in called workflow", () => {
477
+ const wfDir = setup();
478
+ const calledWf = path.join(wfDir, "test.yml");
479
+ fs.writeFileSync(calledWf, `
480
+ on: workflow_call
481
+ jobs:
482
+ test:
483
+ runs-on: ubuntu-latest
484
+ steps:
485
+ - run: echo test
486
+ `);
487
+ const callerWf = path.join(wfDir, "ci.yml");
488
+ fs.writeFileSync(callerWf, `
489
+ jobs:
490
+ build:
491
+ runs-on: ubuntu-latest
492
+ steps:
493
+ - run: echo build
494
+ test:
495
+ needs: build
496
+ uses: ./.github/workflows/test.yml
497
+ `);
498
+ const entries = expandReusableJobs(callerWf, tmpDir);
499
+ const testJob = entries.find((e) => e.id === "test/test");
500
+ // Entry-point job inherits caller's needs
501
+ expect(testJob.needs).toEqual(["build"]);
502
+ });
503
+ it("throws when called workflow file not found", () => {
504
+ const wfDir = setup();
505
+ const callerWf = path.join(wfDir, "ci.yml");
506
+ fs.writeFileSync(callerWf, `
507
+ jobs:
508
+ build:
509
+ runs-on: ubuntu-latest
510
+ steps:
511
+ - run: echo build
512
+ lint:
513
+ uses: ./.github/workflows/nonexistent.yml
514
+ `);
515
+ expect(() => expandReusableJobs(callerWf, tmpDir)).toThrow(/Reusable workflow file not found/);
516
+ });
517
+ it("rewires multiple downstream deps when caller has multiple terminal jobs", () => {
518
+ const wfDir = setup();
519
+ const calledWf = path.join(wfDir, "checks.yml");
520
+ fs.writeFileSync(calledWf, `
521
+ on: workflow_call
522
+ jobs:
523
+ lint:
524
+ runs-on: ubuntu-latest
525
+ steps:
526
+ - run: echo lint
527
+ typecheck:
528
+ runs-on: ubuntu-latest
529
+ steps:
530
+ - run: echo typecheck
531
+ `);
532
+ const callerWf = path.join(wfDir, "ci.yml");
533
+ fs.writeFileSync(callerWf, `
534
+ jobs:
535
+ checks:
536
+ uses: ./.github/workflows/checks.yml
537
+ deploy:
538
+ needs: checks
539
+ runs-on: ubuntu-latest
540
+ steps:
541
+ - run: echo deploy
542
+ `);
543
+ const entries = expandReusableJobs(callerWf, tmpDir);
544
+ const deploy = entries.find((e) => e.id === "deploy");
545
+ // Both lint and typecheck are terminal — deploy depends on both
546
+ expect(deploy.needs).toEqual(expect.arrayContaining(["checks/lint", "checks/typecheck"]));
547
+ expect(deploy.needs).toHaveLength(2);
548
+ });
549
+ it("extracts caller with: values as inputs on inlined entries", () => {
550
+ const wfDir = setup();
551
+ const calledWf = path.join(wfDir, "test.yml");
552
+ fs.writeFileSync(calledWf, `
553
+ on:
554
+ workflow_call:
555
+ inputs:
556
+ node-version:
557
+ default: '18'
558
+ environment:
559
+ required: true
560
+ jobs:
561
+ test:
562
+ runs-on: ubuntu-latest
563
+ steps:
564
+ - run: echo test
565
+ `);
566
+ const callerWf = path.join(wfDir, "ci.yml");
567
+ fs.writeFileSync(callerWf, `
568
+ jobs:
569
+ test:
570
+ uses: ./.github/workflows/test.yml
571
+ with:
572
+ node-version: '20'
573
+ environment: staging
574
+ `);
575
+ const entries = expandReusableJobs(callerWf, tmpDir);
576
+ expect(entries).toHaveLength(1);
577
+ const entry = entries[0];
578
+ expect(entry.inputs).toEqual({ "node-version": "20", environment: "staging" });
579
+ expect(entry.inputDefaults).toEqual({ "node-version": "18" });
580
+ expect(entry.callerJobId).toBe("test");
581
+ });
582
+ it("extracts workflowCallOutputDefs from called workflow", () => {
583
+ const wfDir = setup();
584
+ const calledWf = path.join(wfDir, "build.yml");
585
+ fs.writeFileSync(calledWf, `
586
+ on:
587
+ workflow_call:
588
+ outputs:
589
+ artifact-url:
590
+ value: \${{ jobs.build.outputs.url }}
591
+ version:
592
+ value: \${{ jobs.build.outputs.version }}
593
+ jobs:
594
+ build:
595
+ runs-on: ubuntu-latest
596
+ steps:
597
+ - run: echo build
598
+ `);
599
+ const callerWf = path.join(wfDir, "ci.yml");
600
+ fs.writeFileSync(callerWf, `
601
+ jobs:
602
+ build:
603
+ uses: ./.github/workflows/build.yml
604
+ `);
605
+ const entries = expandReusableJobs(callerWf, tmpDir);
606
+ expect(entries).toHaveLength(1);
607
+ expect(entries[0].workflowCallOutputDefs).toEqual({
608
+ "artifact-url": "${{ jobs.build.outputs.url }}",
609
+ version: "${{ jobs.build.outputs.version }}",
610
+ });
611
+ });
612
+ it("preserves raw expressions in with: values (does not expand)", () => {
613
+ const wfDir = setup();
614
+ const calledWf = path.join(wfDir, "deploy.yml");
615
+ fs.writeFileSync(calledWf, `
616
+ on:
617
+ workflow_call:
618
+ inputs:
619
+ sha:
620
+ required: true
621
+ jobs:
622
+ deploy:
623
+ runs-on: ubuntu-latest
624
+ steps:
625
+ - run: echo deploy
626
+ `);
627
+ const callerWf = path.join(wfDir, "ci.yml");
628
+ fs.writeFileSync(callerWf, `
629
+ jobs:
630
+ deploy:
631
+ uses: ./.github/workflows/deploy.yml
632
+ with:
633
+ sha: \${{ github.sha }}
634
+ `);
635
+ const entries = expandReusableJobs(callerWf, tmpDir);
636
+ // Raw expression is preserved, not expanded at this stage
637
+ expect(entries[0].inputs).toEqual({ sha: "${{ github.sha }}" });
638
+ });
639
+ it("sets callerJobId, inputs, and inputDefaults as undefined for regular jobs", () => {
640
+ const wfDir = setup();
641
+ const wf = path.join(wfDir, "ci.yml");
642
+ fs.writeFileSync(wf, `
643
+ jobs:
644
+ build:
645
+ runs-on: ubuntu-latest
646
+ steps:
647
+ - run: echo build
648
+ `);
649
+ const entries = expandReusableJobs(wf, tmpDir);
650
+ expect(entries[0].callerJobId).toBeUndefined();
651
+ expect(entries[0].inputs).toBeUndefined();
652
+ expect(entries[0].inputDefaults).toBeUndefined();
653
+ expect(entries[0].workflowCallOutputDefs).toBeUndefined();
654
+ });
655
+ });