@redwoodjs/agent-ci 0.3.4 → 0.5.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/README.md CHANGED
@@ -46,12 +46,13 @@ DOCKER_HOST=ssh://user@remote-server npx agent-ci run --workflow .github/workflo
46
46
 
47
47
  Run GitHub Actions workflow jobs locally.
48
48
 
49
- | Flag | Short | Description |
50
- | -------------------- | ----- | -------------------------------------------------------------- |
51
- | `--workflow <path>` | `-w` | Path to the workflow file |
52
- | `--all` | `-a` | Discover and run all relevant workflows for the current branch |
53
- | `--pause-on-failure` | `-p` | Pause on step failure for interactive debugging |
54
- | `--quiet` | `-q` | Suppress animated rendering (also enabled by `AI_AGENT=1`) |
49
+ | Flag | Short | Description |
50
+ | -------------------- | ----- | --------------------------------------------------------------------------------- |
51
+ | `--workflow <path>` | `-w` | Path to the workflow file |
52
+ | `--all` | `-a` | Discover and run all relevant workflows for the current branch |
53
+ | `--pause-on-failure` | `-p` | Pause on step failure for interactive debugging |
54
+ | `--quiet` | `-q` | Suppress animated rendering (also enabled by `AI_AGENT=1`) |
55
+ | `--no-matrix` | | Collapse all matrix combinations into a single job (uses first value of each key) |
55
56
 
56
57
  ### `agent-ci retry`
57
58
 
package/SKILL.md ADDED
@@ -0,0 +1,30 @@
1
+ ---
2
+ name: agent-ci
3
+ description: Run GitHub Actions workflows locally with pause-on-failure for AI-agent-driven CI iteration
4
+ keywords: [github-actions, local-ci, pause-on-failure, ai-agent, runner]
5
+ ---
6
+
7
+ ## What agent-ci does
8
+
9
+ Runs the official GitHub Actions runner binary locally (in Docker), emulating GitHub's cloud API.
10
+ Cache is bind-mounted (instant). When a step fails, the container pauses — you can fix and retry the failed step without restarting.
11
+
12
+ ## When to use agent-ci (not `act`)
13
+
14
+ - You want bit-for-bit compatibility with remote GitHub Actions
15
+ - You need pause-on-failure for AI agent debugging loops
16
+ - Cache round-trip speed matters
17
+
18
+ ## Key commands
19
+
20
+ - Run workflow: `npx @redwoodjs/agent-ci run --workflow <path>`
21
+ - Run all relevant workflows (those whose `on` triggers match the current branch/event, just like GitHub): `npx @redwoodjs/agent-ci run --all`
22
+ - Retry after fix: `npx @redwoodjs/agent-ci retry --name <runner>`
23
+ - Abort: `npx @redwoodjs/agent-ci abort --name <runner>`
24
+
25
+ ## Common mistakes
26
+
27
+ - Don't push to remote CI to test changes — use `npx @redwoodjs/agent-ci run` locally first
28
+ - Don't use `--from-start` when only the last step failed — use `retry` with no flags to re-run only the failed step
29
+ - The `AI_AGENT=1` env variable disables animated output for cleaner agent logs
30
+ - Use `--no-matrix` to collapse matrix jobs into a single run — your local machine is likely faster than GitHub's runners, so parallelizing across matrix combinations is unnecessary
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,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
+ });
@@ -247,6 +247,22 @@ export async function getWorkflowTemplate(filePath) {
247
247
  }
248
248
  return await convertWorkflowTemplate(result.context, result.value);
249
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
+ }
250
266
  /**
251
267
  * Compute the Cartesian product of a matrix definition.
252
268
  * Values are always coerced to strings.
package/package.json CHANGED
@@ -1,8 +1,20 @@
1
1
  {
2
2
  "name": "@redwoodjs/agent-ci",
3
- "version": "0.3.4",
4
- "description": "Local GitHub Actions runner",
5
- "keywords": [],
3
+ "version": "0.5.0",
4
+ "description": "Local GitHub Actions runner — pause on failure, ~0ms cache, official runner binary. Built for AI coding agents.",
5
+ "keywords": [
6
+ "act-alternative",
7
+ "ai-agent",
8
+ "ci",
9
+ "coding-agent",
10
+ "devtools",
11
+ "github-actions",
12
+ "local-ci",
13
+ "local-runner",
14
+ "pause-on-failure",
15
+ "runner",
16
+ "workflow"
17
+ ],
6
18
  "license": "FSL-1.1-MIT",
7
19
  "author": "",
8
20
  "repository": {
@@ -15,7 +27,8 @@
15
27
  },
16
28
  "files": [
17
29
  "dist",
18
- "shim.sh"
30
+ "shim.sh",
31
+ "SKILL.md"
19
32
  ],
20
33
  "type": "module",
21
34
  "publishConfig": {
@@ -27,7 +40,7 @@
27
40
  "log-update": "^7.2.0",
28
41
  "minimatch": "^10.2.1",
29
42
  "yaml": "^2.8.2",
30
- "dtu-github-actions": "0.3.4"
43
+ "dtu-github-actions": "0.5.0"
31
44
  },
32
45
  "devDependencies": {
33
46
  "@types/dockerode": "^3.3.34",