@redwoodjs/agent-ci 0.7.1 → 0.8.1

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,310 @@
1
+ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import { parseRemoteRef, isShaRef, remoteCachePath, prefetchRemoteWorkflows, } from "./remote-workflow-fetch.js";
6
+ describe("parseRemoteRef", () => {
7
+ it("parses owner/repo/path@ref", () => {
8
+ const ref = parseRemoteRef("redwoodjs/actions/.github/workflows/lint.yml@main");
9
+ expect(ref).toEqual({
10
+ owner: "redwoodjs",
11
+ repo: "actions",
12
+ path: ".github/workflows/lint.yml",
13
+ ref: "main",
14
+ raw: "redwoodjs/actions/.github/workflows/lint.yml@main",
15
+ });
16
+ });
17
+ it("parses SHA refs", () => {
18
+ const ref = parseRemoteRef("org/repo/.github/workflows/ci.yml@abc123def456abc123def456abc123def456abc1");
19
+ expect(ref).not.toBeNull();
20
+ expect(ref.ref).toBe("abc123def456abc123def456abc123def456abc1");
21
+ });
22
+ it("parses deeply nested paths", () => {
23
+ const ref = parseRemoteRef("org/repo/some/deep/path/workflow.yml@v1");
24
+ expect(ref).not.toBeNull();
25
+ expect(ref.path).toBe("some/deep/path/workflow.yml");
26
+ });
27
+ it("returns null for local refs", () => {
28
+ expect(parseRemoteRef("./.github/workflows/lint.yml")).toBeNull();
29
+ });
30
+ it("returns null for missing @ref", () => {
31
+ expect(parseRemoteRef("org/repo/.github/workflows/lint.yml")).toBeNull();
32
+ });
33
+ it("returns null for owner/repo@ref (no path)", () => {
34
+ expect(parseRemoteRef("org/repo@v1")).toBeNull();
35
+ });
36
+ it("returns null for empty ref after @", () => {
37
+ expect(parseRemoteRef("org/repo/path@")).toBeNull();
38
+ });
39
+ });
40
+ describe("isShaRef", () => {
41
+ it("returns true for 40-char hex", () => {
42
+ expect(isShaRef("abc123def456abc123def456abc123def456abc1")).toBe(true);
43
+ });
44
+ it("returns true for uppercase hex", () => {
45
+ expect(isShaRef("ABC123DEF456ABC123DEF456ABC123DEF456ABC1")).toBe(true);
46
+ });
47
+ it("returns false for short strings", () => {
48
+ expect(isShaRef("abc123")).toBe(false);
49
+ });
50
+ it("returns false for tags", () => {
51
+ expect(isShaRef("v1.0.0")).toBe(false);
52
+ });
53
+ it("returns false for branch names", () => {
54
+ expect(isShaRef("main")).toBe(false);
55
+ });
56
+ });
57
+ describe("remoteCachePath", () => {
58
+ it("builds expected path", () => {
59
+ const ref = parseRemoteRef("org/repo/.github/workflows/lint.yml@v1");
60
+ const result = remoteCachePath("/cache", ref);
61
+ expect(result).toBe("/cache/org__repo@v1/.github/workflows/lint.yml");
62
+ });
63
+ it("sanitizes special characters in ref", () => {
64
+ const ref = parseRemoteRef("org/repo/.github/workflows/ci.yml@refs/heads/main");
65
+ const result = remoteCachePath("/cache", ref);
66
+ expect(result).toContain("org__repo@refs-heads-main");
67
+ });
68
+ });
69
+ describe("prefetchRemoteWorkflows", () => {
70
+ let tmpDir;
71
+ let cacheDir;
72
+ const originalFetch = globalThis.fetch;
73
+ beforeEach(() => {
74
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "remote-wf-test-"));
75
+ cacheDir = path.join(tmpDir, "cache");
76
+ fs.mkdirSync(cacheDir, { recursive: true });
77
+ });
78
+ afterEach(() => {
79
+ globalThis.fetch = originalFetch;
80
+ vi.restoreAllMocks();
81
+ if (tmpDir) {
82
+ fs.rmSync(tmpDir, { recursive: true, force: true });
83
+ }
84
+ });
85
+ function writeWorkflow(content) {
86
+ const wf = path.join(tmpDir, "workflow.yml");
87
+ fs.writeFileSync(wf, content);
88
+ return wf;
89
+ }
90
+ function mockFetchSuccess(yamlContent) {
91
+ const base64Content = Buffer.from(yamlContent).toString("base64");
92
+ globalThis.fetch = vi.fn().mockResolvedValue({
93
+ ok: true,
94
+ json: () => Promise.resolve({ content: base64Content, encoding: "base64" }),
95
+ });
96
+ }
97
+ it("returns empty map when no remote refs", async () => {
98
+ const wf = writeWorkflow(`
99
+ jobs:
100
+ build:
101
+ runs-on: ubuntu-latest
102
+ steps:
103
+ - run: echo build
104
+ `);
105
+ const result = await prefetchRemoteWorkflows(wf, cacheDir);
106
+ expect(result.size).toBe(0);
107
+ });
108
+ it("fetches remote workflow and writes to cache", async () => {
109
+ const remoteYaml = `
110
+ on: workflow_call
111
+ jobs:
112
+ lint:
113
+ runs-on: ubuntu-latest
114
+ steps:
115
+ - run: echo lint
116
+ `;
117
+ mockFetchSuccess(remoteYaml);
118
+ const wf = writeWorkflow(`
119
+ jobs:
120
+ lint:
121
+ uses: org/repo/.github/workflows/lint.yml@v1
122
+ `);
123
+ const result = await prefetchRemoteWorkflows(wf, cacheDir);
124
+ expect(result.size).toBe(1);
125
+ expect(result.has("org/repo/.github/workflows/lint.yml@v1")).toBe(true);
126
+ // Verify cached file was written
127
+ const cachedPath = result.get("org/repo/.github/workflows/lint.yml@v1");
128
+ expect(fs.existsSync(cachedPath)).toBe(true);
129
+ expect(fs.readFileSync(cachedPath, "utf-8")).toBe(remoteYaml);
130
+ });
131
+ it("uses cache for SHA refs on subsequent calls", async () => {
132
+ const sha = "abc123def456abc123def456abc123def456abc1";
133
+ const remoteYaml = `
134
+ on: workflow_call
135
+ jobs:
136
+ lint:
137
+ runs-on: ubuntu-latest
138
+ steps:
139
+ - run: echo lint
140
+ `;
141
+ mockFetchSuccess(remoteYaml);
142
+ const wf = writeWorkflow(`
143
+ jobs:
144
+ lint:
145
+ uses: org/repo/.github/workflows/lint.yml@${sha}
146
+ `);
147
+ // First call fetches
148
+ await prefetchRemoteWorkflows(wf, cacheDir);
149
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
150
+ // Second call uses cache (SHA ref is immutable)
151
+ const result = await prefetchRemoteWorkflows(wf, cacheDir);
152
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1); // not called again
153
+ expect(result.size).toBe(1);
154
+ });
155
+ it("re-fetches for tag/branch refs even when cached", async () => {
156
+ const remoteYaml = `
157
+ on: workflow_call
158
+ jobs:
159
+ lint:
160
+ runs-on: ubuntu-latest
161
+ steps:
162
+ - run: echo lint
163
+ `;
164
+ mockFetchSuccess(remoteYaml);
165
+ const wf = writeWorkflow(`
166
+ jobs:
167
+ lint:
168
+ uses: org/repo/.github/workflows/lint.yml@main
169
+ `);
170
+ // First call fetches
171
+ await prefetchRemoteWorkflows(wf, cacheDir);
172
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
173
+ // Second call also fetches (branch ref is mutable)
174
+ await prefetchRemoteWorkflows(wf, cacheDir);
175
+ expect(globalThis.fetch).toHaveBeenCalledTimes(2);
176
+ });
177
+ it("throws on 404 response", async () => {
178
+ globalThis.fetch = vi.fn().mockResolvedValue({
179
+ ok: false,
180
+ status: 404,
181
+ });
182
+ const wf = writeWorkflow(`
183
+ jobs:
184
+ lint:
185
+ uses: org/repo/.github/workflows/nonexistent.yml@v1
186
+ `);
187
+ await expect(prefetchRemoteWorkflows(wf, cacheDir)).rejects.toThrow(/Remote workflow fetch failed/);
188
+ });
189
+ it("throws on 401 with auth hint", async () => {
190
+ globalThis.fetch = vi.fn().mockResolvedValue({
191
+ ok: false,
192
+ status: 401,
193
+ });
194
+ const wf = writeWorkflow(`
195
+ jobs:
196
+ lint:
197
+ uses: org/private-repo/.github/workflows/lint.yml@v1
198
+ `);
199
+ await expect(prefetchRemoteWorkflows(wf, cacheDir)).rejects.toThrow(/--github-token/);
200
+ });
201
+ it("sends Authorization header when githubToken is provided", async () => {
202
+ const remoteYaml = `
203
+ on: workflow_call
204
+ jobs:
205
+ lint:
206
+ runs-on: ubuntu-latest
207
+ steps:
208
+ - run: echo lint
209
+ `;
210
+ mockFetchSuccess(remoteYaml);
211
+ const wf = writeWorkflow(`
212
+ jobs:
213
+ lint:
214
+ uses: org/repo/.github/workflows/lint.yml@v1
215
+ `);
216
+ await prefetchRemoteWorkflows(wf, cacheDir, "ghp_test123");
217
+ const fetchCall = globalThis.fetch.mock.calls[0];
218
+ expect(fetchCall[1].headers["Authorization"]).toBe("token ghp_test123");
219
+ });
220
+ it("does not send Authorization header when no token provided", async () => {
221
+ const remoteYaml = `
222
+ on: workflow_call
223
+ jobs:
224
+ lint:
225
+ runs-on: ubuntu-latest
226
+ steps:
227
+ - run: echo lint
228
+ `;
229
+ mockFetchSuccess(remoteYaml);
230
+ const wf = writeWorkflow(`
231
+ jobs:
232
+ lint:
233
+ uses: org/repo/.github/workflows/lint.yml@v1
234
+ `);
235
+ await prefetchRemoteWorkflows(wf, cacheDir);
236
+ const fetchCall = globalThis.fetch.mock.calls[0];
237
+ expect(fetchCall[1].headers["Authorization"]).toBeUndefined();
238
+ });
239
+ it("throws on 403 with auth hint mentioning --github-token and AGENT_CI_GITHUB_TOKEN", async () => {
240
+ globalThis.fetch = vi.fn().mockResolvedValue({
241
+ ok: false,
242
+ status: 403,
243
+ });
244
+ const wf = writeWorkflow(`
245
+ jobs:
246
+ lint:
247
+ uses: org/private-repo/.github/workflows/lint.yml@v1
248
+ `);
249
+ await expect(prefetchRemoteWorkflows(wf, cacheDir)).rejects.toThrow(/--github-token/);
250
+ await expect(prefetchRemoteWorkflows(wf, cacheDir)).rejects.toThrow(/AGENT_CI_GITHUB_TOKEN/);
251
+ });
252
+ it("succeeds fetching a public remote workflow without auth", async () => {
253
+ const remoteYaml = `
254
+ on: workflow_call
255
+ jobs:
256
+ lint:
257
+ runs-on: ubuntu-latest
258
+ steps:
259
+ - run: echo lint
260
+ `;
261
+ mockFetchSuccess(remoteYaml);
262
+ const wf = writeWorkflow(`
263
+ jobs:
264
+ lint:
265
+ uses: org/public-repo/.github/workflows/lint.yml@v1
266
+ `);
267
+ // No githubToken passed — simulates public repo access without auth
268
+ const result = await prefetchRemoteWorkflows(wf, cacheDir);
269
+ expect(result.size).toBe(1);
270
+ // Verify no Authorization header was sent
271
+ const fetchCall = globalThis.fetch.mock.calls[0];
272
+ expect(fetchCall[1].headers["Authorization"]).toBeUndefined();
273
+ // Verify the cached file was written correctly
274
+ const cachedPath = result.get("org/public-repo/.github/workflows/lint.yml@v1");
275
+ expect(fs.existsSync(cachedPath)).toBe(true);
276
+ expect(fs.readFileSync(cachedPath, "utf-8")).toBe(remoteYaml);
277
+ });
278
+ it("fetches multiple remote refs in parallel", async () => {
279
+ const remoteYaml = `
280
+ on: workflow_call
281
+ jobs:
282
+ job:
283
+ runs-on: ubuntu-latest
284
+ steps:
285
+ - run: echo hello
286
+ `;
287
+ mockFetchSuccess(remoteYaml);
288
+ const wf = writeWorkflow(`
289
+ jobs:
290
+ lint:
291
+ uses: org/repo/.github/workflows/lint.yml@v1
292
+ test:
293
+ uses: org/repo/.github/workflows/test.yml@v1
294
+ `);
295
+ const result = await prefetchRemoteWorkflows(wf, cacheDir);
296
+ expect(result.size).toBe(2);
297
+ expect(globalThis.fetch).toHaveBeenCalledTimes(2);
298
+ });
299
+ it("skips local refs", async () => {
300
+ mockFetchSuccess("unused");
301
+ const wf = writeWorkflow(`
302
+ jobs:
303
+ lint:
304
+ uses: ./.github/workflows/lint.yml
305
+ `);
306
+ const result = await prefetchRemoteWorkflows(wf, cacheDir);
307
+ expect(result.size).toBe(0);
308
+ expect(globalThis.fetch).not.toHaveBeenCalled();
309
+ });
310
+ });
@@ -0,0 +1,134 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { parse as parseYaml } from "yaml";
4
+ const MAX_REUSABLE_DEPTH = 4;
5
+ /**
6
+ * Expand reusable workflow jobs (`uses: ./.github/workflows/...`) into concrete
7
+ * job entries that can be scheduled alongside regular jobs.
8
+ *
9
+ * Local refs (starting with `./`) are resolved relative to repoRoot.
10
+ * Remote refs are resolved via the remoteCache map (pre-fetched by
11
+ * prefetchRemoteWorkflows). Nesting is supported up to 4 levels deep
12
+ * (matching GitHub Actions' limit). Cycles are detected and rejected.
13
+ */
14
+ export function expandReusableJobs(workflowPath, repoRoot, remoteCache) {
15
+ return expandReusableJobsInternal(workflowPath, repoRoot, remoteCache, 0, new Set());
16
+ }
17
+ function expandReusableJobsInternal(workflowPath, repoRoot, remoteCache, depth, visitedPaths) {
18
+ if (depth > MAX_REUSABLE_DEPTH) {
19
+ throw new Error(`Reusable workflow nesting depth exceeds maximum of ${MAX_REUSABLE_DEPTH}: ${workflowPath}`);
20
+ }
21
+ const resolvedPath = path.resolve(workflowPath);
22
+ if (visitedPaths.has(resolvedPath)) {
23
+ throw new Error(`Cycle detected in reusable workflows: ${resolvedPath} is already in the call chain`);
24
+ }
25
+ visitedPaths.add(resolvedPath);
26
+ const raw = parseYaml(fs.readFileSync(workflowPath, "utf-8"));
27
+ const jobs = raw?.jobs ?? {};
28
+ const entries = [];
29
+ // Track which caller job IDs map to which inlined terminal job IDs,
30
+ // so we can rewire downstream `needs:` references.
31
+ const callerToTerminals = new Map();
32
+ for (const [jobId, jobDef] of Object.entries(jobs)) {
33
+ const uses = jobDef?.uses;
34
+ if (typeof uses === "string") {
35
+ // This is a reusable workflow call
36
+ let calledPath;
37
+ if (uses.startsWith("./")) {
38
+ calledPath = path.resolve(repoRoot, uses);
39
+ }
40
+ else {
41
+ const cached = remoteCache?.get(uses);
42
+ if (!cached) {
43
+ throw new Error(`Remote reusable workflow not resolved: job "${jobId}" uses ${uses}`);
44
+ }
45
+ calledPath = cached;
46
+ }
47
+ if (!fs.existsSync(calledPath)) {
48
+ throw new Error(`Reusable workflow file not found: ${calledPath} (referenced by job "${jobId}")`);
49
+ }
50
+ // Extract caller inputs (raw `with:` values)
51
+ const callerWith = jobDef.with
52
+ ? Object.fromEntries(Object.entries(jobDef.with).map(([k, v]) => [k, String(v)]))
53
+ : undefined;
54
+ // Extract input defaults and output defs from the called workflow's on.workflow_call
55
+ const calledRaw = parseYaml(fs.readFileSync(calledPath, "utf-8"));
56
+ const wcInputs = (calledRaw.on || calledRaw.true)?.workflow_call?.inputs;
57
+ const inputDefaults = wcInputs && typeof wcInputs === "object"
58
+ ? Object.fromEntries(Object.entries(wcInputs)
59
+ .filter(([, def]) => def?.default != null)
60
+ .map(([k, def]) => [k, String(def.default)]))
61
+ : undefined;
62
+ const wcOutputs = (calledRaw.on || calledRaw.true)?.workflow_call?.outputs;
63
+ const workflowCallOutputDefs = wcOutputs && typeof wcOutputs === "object"
64
+ ? Object.fromEntries(Object.entries(wcOutputs)
65
+ .filter(([, def]) => def?.value != null)
66
+ .map(([k, def]) => [k, String(def.value)]))
67
+ : undefined;
68
+ // Recursively expand the called workflow
69
+ const calledEntries = expandReusableJobsInternal(calledPath, repoRoot, remoteCache, depth + 1, visitedPaths);
70
+ const callerNeeds = parseNeeds(jobDef?.needs);
71
+ // Prefix all entry IDs and needs with the caller job ID,
72
+ // and attach inputs/outputs metadata
73
+ const prefixed = calledEntries.map((entry) => ({
74
+ id: `${jobId}/${entry.id}`,
75
+ workflowPath: entry.workflowPath,
76
+ sourceTaskName: entry.sourceTaskName,
77
+ needs: entry.needs.length === 0 ? callerNeeds : entry.needs.map((n) => `${jobId}/${n}`),
78
+ inputs: callerWith,
79
+ inputDefaults: inputDefaults && Object.keys(inputDefaults).length > 0 ? inputDefaults : undefined,
80
+ workflowCallOutputDefs: workflowCallOutputDefs && Object.keys(workflowCallOutputDefs).length > 0
81
+ ? workflowCallOutputDefs
82
+ : undefined,
83
+ callerJobId: jobId,
84
+ }));
85
+ // Compute terminals among the prefixed entries
86
+ const prefixedIds = new Set(prefixed.map((e) => e.id));
87
+ const depended = new Set();
88
+ for (const entry of prefixed) {
89
+ for (const n of entry.needs) {
90
+ if (prefixedIds.has(n)) {
91
+ depended.add(n);
92
+ }
93
+ }
94
+ }
95
+ const terminals = prefixed.filter((e) => !depended.has(e.id)).map((e) => e.id);
96
+ callerToTerminals.set(jobId, terminals);
97
+ entries.push(...prefixed);
98
+ }
99
+ else {
100
+ // Regular job — has `steps:` or `runs-on:`
101
+ entries.push({
102
+ id: jobId,
103
+ workflowPath,
104
+ sourceTaskName: jobId,
105
+ needs: parseNeeds(jobDef?.needs),
106
+ });
107
+ }
108
+ }
109
+ // Rewire downstream dependencies: any job that `needs: [callerJobId]`
110
+ // should now depend on the terminal jobs of the inlined sub-graph
111
+ for (const entry of entries) {
112
+ entry.needs = entry.needs.flatMap((dep) => {
113
+ const terminals = callerToTerminals.get(dep);
114
+ if (terminals && terminals.length > 0) {
115
+ return terminals;
116
+ }
117
+ return [dep];
118
+ });
119
+ }
120
+ visitedPaths.delete(resolvedPath);
121
+ return entries;
122
+ }
123
+ function parseNeeds(needs) {
124
+ if (!needs) {
125
+ return [];
126
+ }
127
+ if (typeof needs === "string") {
128
+ return [needs];
129
+ }
130
+ if (Array.isArray(needs)) {
131
+ return needs.map(String);
132
+ }
133
+ return [];
134
+ }