@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 +7 -6
- package/SKILL.md +30 -0
- package/dist/cli.js +34 -13
- package/dist/workflow/matrix-collapse.test.js +96 -0
- package/dist/workflow/workflow-parser.js +16 -0
- package/package.json +18 -5
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({
|
|
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({
|
|
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 =
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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.
|
|
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.
|
|
43
|
+
"dtu-github-actions": "0.5.0"
|
|
31
44
|
},
|
|
32
45
|
"devDependencies": {
|
|
33
46
|
"@types/dockerode": "^3.3.34",
|