@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.
- package/dist/cli.js +414 -298
- package/dist/commit-status.js +1 -1
- package/dist/config.js +42 -15
- package/dist/config.test.js +157 -0
- package/dist/docker/container-config.js +7 -5
- package/dist/docker/container-config.test.js +45 -2
- package/dist/docker/docker-socket.js +119 -0
- package/dist/docker/docker-socket.test.js +117 -0
- package/dist/output/cleanup.js +42 -6
- package/dist/output/cleanup.test.js +15 -0
- package/dist/runner/directory-setup.js +2 -3
- package/dist/runner/local-job.js +51 -19
- package/dist/runner/local-job.test.js +43 -0
- package/dist/runner/result-builder.js +2 -1
- package/dist/runner/workspace.js +3 -2
- package/dist/workflow/remote-workflow-fetch.js +131 -0
- package/dist/workflow/remote-workflow-fetch.test.js +233 -0
- package/dist/workflow/reusable-workflow.js +134 -0
- package/dist/workflow/reusable-workflow.test.js +655 -0
- package/dist/workflow/workflow-parser.js +33 -20
- package/dist/workflow/workflow-parser.test.js +95 -2
- package/package.json +2 -2
|
@@ -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
|
+
});
|