@redwoodjs/agent-ci 0.3.3 → 0.4.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 CHANGED
@@ -7,7 +7,7 @@ import { getNextLogNum } from "./output/logger.js";
7
7
  import { setWorkingDirectory, DEFAULT_WORKING_DIR, PROJECT_ROOT, } from "./output/working-directory.js";
8
8
  import { debugCli } from "./output/debug.js";
9
9
  import { executeLocalJob } from "./runner/local-job.js";
10
- import { getWorkflowTemplate, parseWorkflowSteps, parseWorkflowServices, parseWorkflowContainer, validateSecrets, parseMatrixDef, expandMatrixCombinations, isWorkflowRelevant, getChangedFiles, parseJobOutputDefs, parseJobIf, evaluateJobIf, parseFailFast, } from "./workflow/workflow-parser.js";
10
+ import { getWorkflowTemplate, parseWorkflowSteps, parseWorkflowServices, parseWorkflowContainer, validateSecrets, parseMatrixDef, expandMatrixCombinations, collapseMatrixToSingle, isWorkflowRelevant, getChangedFiles, parseJobOutputDefs, parseJobIf, evaluateJobIf, parseFailFast, } from "./workflow/workflow-parser.js";
11
11
  import { resolveJobOutputs } from "./runner/result-builder.js";
12
12
  import { createConcurrencyLimiter, getDefaultMaxConcurrentJobs } from "./output/concurrency.js";
13
13
  import { isWarmNodeModules, computeLockfileHash } from "./output/cleanup.js";
@@ -45,6 +45,7 @@ async function run() {
45
45
  let workflow;
46
46
  let pauseOnFailure = false;
47
47
  let runAll = false;
48
+ let noMatrix = false;
48
49
  for (let i = 1; i < args.length; i++) {
49
50
  if ((args[i] === "--workflow" || args[i] === "-w") && args[i + 1]) {
50
51
  workflow = args[i + 1];
@@ -59,6 +60,9 @@ async function run() {
59
60
  else if (args[i] === "--quiet" || args[i] === "-q") {
60
61
  setQuietMode(true);
61
62
  }
63
+ else if (args[i] === "--no-matrix") {
64
+ noMatrix = true;
65
+ }
62
66
  else if (!args[i].startsWith("-")) {
63
67
  sha = args[i];
64
68
  }
@@ -119,7 +123,12 @@ async function run() {
119
123
  console.log(`[Agent CI] No relevant workflows found for branch '${branch}'.`);
120
124
  process.exit(0);
121
125
  }
122
- const results = await runWorkflows({ workflowPaths: relevant, sha, pauseOnFailure });
126
+ const results = await runWorkflows({
127
+ workflowPaths: relevant,
128
+ sha,
129
+ pauseOnFailure,
130
+ noMatrix,
131
+ });
123
132
  printSummary(results);
124
133
  const anyFailed = results.some((r) => !r.succeeded);
125
134
  process.exit(anyFailed ? 1 : 0);
@@ -146,7 +155,12 @@ async function run() {
146
155
  ];
147
156
  workflowPath = pathsToTry.find((p) => fs.existsSync(p)) || pathsToTry[1];
148
157
  }
149
- const results = await runWorkflows({ workflowPaths: [workflowPath], sha, pauseOnFailure });
158
+ const results = await runWorkflows({
159
+ workflowPaths: [workflowPath],
160
+ sha,
161
+ pauseOnFailure,
162
+ noMatrix,
163
+ });
150
164
  printSummary(results);
151
165
  if (results.some((r) => !r.succeeded)) {
152
166
  process.exit(1);
@@ -223,7 +237,7 @@ async function run() {
223
237
  // Single entry point for both `--workflow` and `--all`.
224
238
  // One workflow = --all with a single entry.
225
239
  async function runWorkflows(options) {
226
- const { workflowPaths, sha, pauseOnFailure } = options;
240
+ const { workflowPaths, sha, pauseOnFailure, noMatrix = false } = options;
227
241
  // Create the run state store — single source of truth for all progress
228
242
  const runId = `run-${Date.now()}`;
229
243
  const storeFilePath = path.join(getWorkingDirectory(), "runs", runId, "run-state.json");
@@ -309,6 +323,7 @@ async function runWorkflows(options) {
309
323
  workflowPath: workflowPaths[0],
310
324
  sha,
311
325
  pauseOnFailure,
326
+ noMatrix,
312
327
  store,
313
328
  });
314
329
  allResults.push(...results);
@@ -332,12 +347,13 @@ async function runWorkflows(options) {
332
347
  workflowPath: workflowPaths[0],
333
348
  sha,
334
349
  pauseOnFailure,
350
+ noMatrix,
335
351
  store,
336
352
  });
337
353
  allResults.push(...firstResults);
338
354
  const settled = await Promise.allSettled(workflowPaths
339
355
  .slice(1)
340
- .map((wf) => handleWorkflow({ workflowPath: wf, sha, pauseOnFailure, store })));
356
+ .map((wf) => handleWorkflow({ workflowPath: wf, sha, pauseOnFailure, noMatrix, store })));
341
357
  for (const s of settled) {
342
358
  if (s.status === "fulfilled") {
343
359
  allResults.push(...s.value);
@@ -348,7 +364,7 @@ async function runWorkflows(options) {
348
364
  }
349
365
  }
350
366
  else {
351
- const settled = await Promise.allSettled(workflowPaths.map((wf) => handleWorkflow({ workflowPath: wf, sha, pauseOnFailure, store })));
367
+ const settled = await Promise.allSettled(workflowPaths.map((wf) => handleWorkflow({ workflowPath: wf, sha, pauseOnFailure, noMatrix, store })));
352
368
  for (const s of settled) {
353
369
  if (s.status === "fulfilled") {
354
370
  allResults.push(...s.value);
@@ -380,7 +396,7 @@ async function runWorkflows(options) {
380
396
  // Processes a single workflow file: parses jobs, handles matrix expansion,
381
397
  // wave scheduling, warm-cache serialization, and concurrency limiting.
382
398
  async function handleWorkflow(options) {
383
- const { sha, pauseOnFailure, store } = options;
399
+ const { sha, pauseOnFailure, noMatrix = false, store } = options;
384
400
  let workflowPath = options.workflowPath;
385
401
  try {
386
402
  if (!fs.existsSync(workflowPath)) {
@@ -406,17 +422,21 @@ async function handleWorkflow(options) {
406
422
  const id = job.id.toString();
407
423
  const matrixDef = await parseMatrixDef(workflowPath, id);
408
424
  if (matrixDef) {
409
- const combos = expandMatrixCombinations(matrixDef);
425
+ const combos = noMatrix
426
+ ? collapseMatrixToSingle(matrixDef)
427
+ : expandMatrixCombinations(matrixDef);
410
428
  const total = combos.length;
411
429
  for (let ci = 0; ci < combos.length; ci++) {
412
430
  expandedJobs.push({
413
431
  workflowPath,
414
432
  taskName: id,
415
- matrixContext: {
416
- ...combos[ci],
417
- __job_total: String(total),
418
- __job_index: String(ci),
419
- },
433
+ matrixContext: noMatrix
434
+ ? combos[ci]
435
+ : {
436
+ ...combos[ci],
437
+ __job_total: String(total),
438
+ __job_index: String(ci),
439
+ },
420
440
  });
421
441
  }
422
442
  }
@@ -697,6 +717,7 @@ function printUsage() {
697
717
  console.log(" -a, --all Discover and run all relevant workflows");
698
718
  console.log(" -p, --pause-on-failure Pause on step failure for interactive debugging");
699
719
  console.log(" -q, --quiet Suppress animated rendering (also enabled by AI_AGENT=1)");
720
+ console.log(" --no-matrix Collapse all matrix combinations into a single job (uses first value of each key)");
700
721
  }
701
722
  function resolveRepoRoot() {
702
723
  let repoRoot = process.cwd();
@@ -0,0 +1,17 @@
1
+ /**
2
+ * ESM loader hook that adds `type: "json"` to bare JSON imports.
3
+ *
4
+ * Node 22+ enforces import attributes for JSON modules, but
5
+ * @actions/workflow-parser imports .json files without them.
6
+ * Registering this hook via `module.register()` before the
7
+ * dynamic import transparently patches the missing attribute.
8
+ */
9
+ export async function load(url, context, nextLoad) {
10
+ if (url.endsWith(".json") && context.importAttributes?.type !== "json") {
11
+ return nextLoad(url, {
12
+ ...context,
13
+ importAttributes: { ...context.importAttributes, type: "json" },
14
+ });
15
+ }
16
+ return nextLoad(url, context);
17
+ }
@@ -0,0 +1,96 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { collapseMatrixToSingle, expandMatrixCombinations } from "./workflow-parser.js";
3
+ // ─── collapseMatrixToSingle ───────────────────────────────────────────────────
4
+ //
5
+ // These tests cover the --no-matrix flag behavior: when the flag is active,
6
+ // all matrix combinations are collapsed into a single job that carries the
7
+ // first value of each matrix key plus __job_total="1" and __job_index="0".
8
+ //
9
+ // The Developer must export `collapseMatrixToSingle` from workflow-parser.ts
10
+ // with the signature:
11
+ // collapseMatrixToSingle(matrixDef: Record<string, any[]>): Record<string, string>[]
12
+ describe("collapseMatrixToSingle", () => {
13
+ // ── Core collapse behavior ──────────────────────────────────────────────────
14
+ it("returns exactly one job regardless of how many matrix values exist", () => {
15
+ const result = collapseMatrixToSingle({
16
+ shard: ["1", "2", "3", "4", "5", "6", "7", "8"],
17
+ });
18
+ expect(result).toHaveLength(1);
19
+ });
20
+ it("uses the first value of a single-key matrix", () => {
21
+ const result = collapseMatrixToSingle({
22
+ shard: ["1", "2", "3", "4", "5", "6", "7", "8"],
23
+ });
24
+ expect(result[0].shard).toBe("1");
25
+ });
26
+ it("sets __job_total to '1' on the collapsed job", () => {
27
+ const result = collapseMatrixToSingle({ shard: ["1", "2", "3"] });
28
+ expect(result[0].__job_total).toBe("1");
29
+ });
30
+ it("sets __job_index to '0' on the collapsed job", () => {
31
+ const result = collapseMatrixToSingle({ shard: ["1", "2", "3"] });
32
+ expect(result[0].__job_index).toBe("0");
33
+ });
34
+ // ── Multi-key matrix ────────────────────────────────────────────────────────
35
+ it("collapses a multi-key matrix: all keys present, each set to its first value", () => {
36
+ const result = collapseMatrixToSingle({
37
+ browser: ["chrome", "firefox", "safari"],
38
+ shard: ["1", "2", "3", "4"],
39
+ });
40
+ expect(result).toHaveLength(1);
41
+ expect(result[0].browser).toBe("chrome");
42
+ expect(result[0].shard).toBe("1");
43
+ });
44
+ it("sets __job_total and __job_index to '1'/'0' regardless of combination count across multiple keys", () => {
45
+ // 3 × 3 = 9 combinations without collapse; must still produce one job
46
+ const result = collapseMatrixToSingle({
47
+ os: ["ubuntu", "macos", "windows"],
48
+ node: ["18", "20", "22"],
49
+ });
50
+ expect(result).toHaveLength(1);
51
+ expect(result[0].__job_total).toBe("1");
52
+ expect(result[0].__job_index).toBe("0");
53
+ });
54
+ // ── Degenerate / edge cases ─────────────────────────────────────────────────
55
+ it("handles a matrix that already has exactly one combination (degenerate case)", () => {
56
+ const result = collapseMatrixToSingle({ shard: ["1"] });
57
+ expect(result).toHaveLength(1);
58
+ expect(result[0].shard).toBe("1");
59
+ expect(result[0].__job_total).toBe("1");
60
+ expect(result[0].__job_index).toBe("0");
61
+ });
62
+ it("does not throw when a matrix key has an empty value list", () => {
63
+ expect(() => collapseMatrixToSingle({ shard: [] })).not.toThrow();
64
+ });
65
+ it("coerces numeric matrix values to strings", () => {
66
+ // Matrix values in YAML may be parsed as numbers (e.g. shard: [1, 2, 3])
67
+ const result = collapseMatrixToSingle({ shard: [1, 2, 3] });
68
+ expect(result[0].shard).toBe("1");
69
+ });
70
+ });
71
+ // ─── Default expansion unaffected when --no-matrix is absent ─────────────────
72
+ //
73
+ // Without --no-matrix, the normal `expandMatrixCombinations` path must continue
74
+ // to produce one job per combination. These tests guard against regressions.
75
+ describe("expandMatrixCombinations (default path, no --no-matrix)", () => {
76
+ it("expands a single-key matrix into one job per value", () => {
77
+ const result = expandMatrixCombinations({ shard: ["1", "2", "3"] });
78
+ expect(result).toHaveLength(3);
79
+ expect(result.map((r) => r.shard)).toEqual(["1", "2", "3"]);
80
+ });
81
+ it("expands a multi-key matrix into the full cartesian product", () => {
82
+ const result = expandMatrixCombinations({
83
+ browser: ["chrome", "firefox"],
84
+ shard: ["1", "2"],
85
+ });
86
+ // 2 × 2 = 4 combinations
87
+ expect(result).toHaveLength(4);
88
+ });
89
+ it("does not include __job_total or __job_index in expanded jobs", () => {
90
+ const result = expandMatrixCombinations({ shard: ["1", "2"] });
91
+ for (const job of result) {
92
+ expect(job).not.toHaveProperty("__job_total");
93
+ expect(job).not.toHaveProperty("__job_index");
94
+ }
95
+ });
96
+ });
@@ -4,10 +4,22 @@ import crypto from "crypto";
4
4
  import { execSync } from "child_process";
5
5
  import { minimatch } from "minimatch";
6
6
  import { parse as parseYaml } from "yaml";
7
- // @actions/workflow-parser imports JSON without `type: json` assertion,
8
- // which fails on Node.js v22+. Lazy-import it only in the two functions
9
- // that actually need it (getWorkflowTemplate, parseWorkflowSteps).
7
+ // @actions/workflow-parser imports .json files without `with { type: "json" }`,
8
+ // which Node 22+ rejects. Register a custom ESM loader hook that transparently
9
+ // adds the missing attribute before we dynamically import the module.
10
+ import { register } from "node:module";
11
+ let hookRegistered = false;
10
12
  async function loadWorkflowParser() {
13
+ if (!hookRegistered) {
14
+ hookRegistered = true;
15
+ try {
16
+ register("../hooks/json-loader.js", import.meta.url);
17
+ }
18
+ catch {
19
+ // In test environments (Vitest), the hook file may not resolve and
20
+ // Vite already handles JSON imports via its inline config.
21
+ }
22
+ }
11
23
  return await import("@actions/workflow-parser");
12
24
  }
13
25
  /**
@@ -235,6 +247,22 @@ export async function getWorkflowTemplate(filePath) {
235
247
  }
236
248
  return await convertWorkflowTemplate(result.context, result.value);
237
249
  }
250
+ /**
251
+ * Collapse a matrix definition to a single job using the first value of each key.
252
+ * Sets __job_total to "1" and __job_index to "0".
253
+ * Used by the --no-matrix flag to run a matrix workflow as a single container.
254
+ */
255
+ export function collapseMatrixToSingle(matrixDef) {
256
+ const combo = {};
257
+ for (const [key, values] of Object.entries(matrixDef)) {
258
+ if (values.length > 0) {
259
+ combo[key] = String(values[0]);
260
+ }
261
+ }
262
+ combo.__job_total = "1";
263
+ combo.__job_index = "0";
264
+ return [combo];
265
+ }
238
266
  /**
239
267
  * Compute the Cartesian product of a matrix definition.
240
268
  * Values are always coerced to strings.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redwoodjs/agent-ci",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "description": "Local GitHub Actions runner",
5
5
  "keywords": [],
6
6
  "license": "FSL-1.1-MIT",
@@ -27,7 +27,7 @@
27
27
  "log-update": "^7.2.0",
28
28
  "minimatch": "^10.2.1",
29
29
  "yaml": "^2.8.2",
30
- "dtu-github-actions": "0.3.3"
30
+ "dtu-github-actions": "0.4.0"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/dockerode": "^3.3.34",