@redwoodjs/agent-ci 0.8.0 → 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.
package/README.md
CHANGED
|
@@ -64,13 +64,15 @@ When using a remote daemon (`DOCKER_HOST=ssh://...`), `host-gateway` resolves re
|
|
|
64
64
|
|
|
65
65
|
Run GitHub Actions workflow jobs locally.
|
|
66
66
|
|
|
67
|
-
| Flag
|
|
68
|
-
|
|
|
69
|
-
| `--workflow <path>`
|
|
70
|
-
| `--all`
|
|
71
|
-
| `--pause-on-failure`
|
|
72
|
-
| `--quiet`
|
|
73
|
-
| `--no-matrix`
|
|
67
|
+
| Flag | Short | Description |
|
|
68
|
+
| -------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
69
|
+
| `--workflow <path>` | `-w` | Path to the workflow file |
|
|
70
|
+
| `--all` | `-a` | Discover and run all relevant workflows for the current branch |
|
|
71
|
+
| `--pause-on-failure` | `-p` | Pause on step failure for interactive debugging |
|
|
72
|
+
| `--quiet` | `-q` | Suppress animated rendering (also enabled by `AI_AGENT=1`) |
|
|
73
|
+
| `--no-matrix` | | Collapse all matrix combinations into a single job (uses first value of each key) |
|
|
74
|
+
| `--github-token [<token>]` | | GitHub token for fetching remote reusable workflows (auto-resolves via `gh auth token` if no value given). Also available as `AGENT_CI_GITHUB_TOKEN` env var |
|
|
75
|
+
| `--commit-status` | | Post a GitHub commit status after the run (requires `--github-token`) |
|
|
74
76
|
|
|
75
77
|
### `agent-ci retry`
|
|
76
78
|
|
package/dist/cli.js
CHANGED
|
@@ -49,6 +49,8 @@ async function run() {
|
|
|
49
49
|
let pauseOnFailure = false;
|
|
50
50
|
let runAll = false;
|
|
51
51
|
let noMatrix = false;
|
|
52
|
+
let githubToken;
|
|
53
|
+
let commitStatus = false;
|
|
52
54
|
for (let i = 1; i < args.length; i++) {
|
|
53
55
|
if ((args[i] === "--workflow" || args[i] === "-w") && args[i + 1]) {
|
|
54
56
|
workflow = args[i + 1];
|
|
@@ -66,10 +68,37 @@ async function run() {
|
|
|
66
68
|
else if (args[i] === "--no-matrix") {
|
|
67
69
|
noMatrix = true;
|
|
68
70
|
}
|
|
71
|
+
else if (args[i] === "--commit-status") {
|
|
72
|
+
commitStatus = true;
|
|
73
|
+
}
|
|
74
|
+
else if (args[i] === "--github-token") {
|
|
75
|
+
// If the next arg looks like a token value (not another flag), use it.
|
|
76
|
+
// Otherwise, auto-resolve via `gh auth token`.
|
|
77
|
+
if (args[i + 1] && !args[i + 1].startsWith("-")) {
|
|
78
|
+
githubToken = args[i + 1];
|
|
79
|
+
i++;
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
try {
|
|
83
|
+
githubToken = execSync("gh auth token", {
|
|
84
|
+
encoding: "utf-8",
|
|
85
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
86
|
+
}).trim();
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
console.error("[Agent CI] Error: --github-token requires `gh` CLI to be installed and authenticated, or pass a token value: --github-token <value>");
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
69
94
|
else if (!args[i].startsWith("-")) {
|
|
70
95
|
sha = args[i];
|
|
71
96
|
}
|
|
72
97
|
}
|
|
98
|
+
// Also accept AGENT_CI_GITHUB_TOKEN env var (CLI flag takes precedence)
|
|
99
|
+
if (!githubToken && process.env.AGENT_CI_GITHUB_TOKEN) {
|
|
100
|
+
githubToken = process.env.AGENT_CI_GITHUB_TOKEN;
|
|
101
|
+
}
|
|
73
102
|
let workingDir = process.env.AGENT_CI_WORKING_DIR;
|
|
74
103
|
if (workingDir) {
|
|
75
104
|
if (!path.isAbsolute(workingDir)) {
|
|
@@ -131,11 +160,14 @@ async function run() {
|
|
|
131
160
|
sha,
|
|
132
161
|
pauseOnFailure,
|
|
133
162
|
noMatrix,
|
|
163
|
+
githubToken,
|
|
134
164
|
});
|
|
135
165
|
if (results.length > 0) {
|
|
136
166
|
printSummary(results);
|
|
137
167
|
}
|
|
138
|
-
|
|
168
|
+
if (commitStatus) {
|
|
169
|
+
postCommitStatus(results, sha, githubToken);
|
|
170
|
+
}
|
|
139
171
|
const anyFailed = results.length === 0 || results.some((r) => !r.succeeded);
|
|
140
172
|
process.exit(anyFailed ? 1 : 0);
|
|
141
173
|
}
|
|
@@ -166,11 +198,14 @@ async function run() {
|
|
|
166
198
|
sha,
|
|
167
199
|
pauseOnFailure,
|
|
168
200
|
noMatrix,
|
|
201
|
+
githubToken,
|
|
169
202
|
});
|
|
170
203
|
if (results.length > 0) {
|
|
171
204
|
printSummary(results);
|
|
172
205
|
}
|
|
173
|
-
|
|
206
|
+
if (commitStatus) {
|
|
207
|
+
postCommitStatus(results, sha, githubToken);
|
|
208
|
+
}
|
|
174
209
|
if (results.length === 0 || results.some((r) => !r.succeeded)) {
|
|
175
210
|
process.exit(1);
|
|
176
211
|
}
|
|
@@ -246,7 +281,7 @@ async function run() {
|
|
|
246
281
|
// Single entry point for both `--workflow` and `--all`.
|
|
247
282
|
// One workflow = --all with a single entry.
|
|
248
283
|
async function runWorkflows(options) {
|
|
249
|
-
const { workflowPaths, sha, pauseOnFailure, noMatrix = false } = options;
|
|
284
|
+
const { workflowPaths, sha, pauseOnFailure, noMatrix = false, githubToken } = options;
|
|
250
285
|
// Suppress EventEmitter MaxListenersExceeded warnings when running many
|
|
251
286
|
// parallel jobs (each job adds SIGINT/SIGTERM listeners).
|
|
252
287
|
process.setMaxListeners(0);
|
|
@@ -346,6 +381,7 @@ async function runWorkflows(options) {
|
|
|
346
381
|
pauseOnFailure,
|
|
347
382
|
noMatrix,
|
|
348
383
|
store,
|
|
384
|
+
githubToken,
|
|
349
385
|
});
|
|
350
386
|
allResults.push(...results);
|
|
351
387
|
}
|
|
@@ -377,6 +413,7 @@ async function runWorkflows(options) {
|
|
|
377
413
|
noMatrix,
|
|
378
414
|
store,
|
|
379
415
|
baseRunNum: runNums[0],
|
|
416
|
+
githubToken,
|
|
380
417
|
});
|
|
381
418
|
allResults.push(...firstResults);
|
|
382
419
|
const settled = await Promise.allSettled(workflowPaths.slice(1).map((wf, i) => handleWorkflow({
|
|
@@ -386,6 +423,7 @@ async function runWorkflows(options) {
|
|
|
386
423
|
noMatrix,
|
|
387
424
|
store,
|
|
388
425
|
baseRunNum: runNums[i + 1],
|
|
426
|
+
githubToken,
|
|
389
427
|
})));
|
|
390
428
|
for (const s of settled) {
|
|
391
429
|
if (s.status === "fulfilled") {
|
|
@@ -404,6 +442,7 @@ async function runWorkflows(options) {
|
|
|
404
442
|
noMatrix,
|
|
405
443
|
store,
|
|
406
444
|
baseRunNum: runNums[i],
|
|
445
|
+
githubToken,
|
|
407
446
|
})));
|
|
408
447
|
for (const s of settled) {
|
|
409
448
|
if (s.status === "fulfilled") {
|
|
@@ -438,7 +477,7 @@ async function runWorkflows(options) {
|
|
|
438
477
|
// Processes a single workflow file: parses jobs, handles matrix expansion,
|
|
439
478
|
// wave scheduling, warm-cache serialization, and concurrency limiting.
|
|
440
479
|
async function handleWorkflow(options) {
|
|
441
|
-
const { sha, pauseOnFailure, noMatrix = false, store } = options;
|
|
480
|
+
const { sha, pauseOnFailure, noMatrix = false, store, githubToken } = options;
|
|
442
481
|
let workflowPath = options.workflowPath;
|
|
443
482
|
if (!fs.existsSync(workflowPath)) {
|
|
444
483
|
throw new Error(`Workflow file not found: ${workflowPath}`);
|
|
@@ -458,7 +497,7 @@ async function handleWorkflow(options) {
|
|
|
458
497
|
config.GITHUB_REPO = githubRepo;
|
|
459
498
|
const [owner, name] = githubRepo.split("/");
|
|
460
499
|
const remoteCacheDir = path.resolve(getWorkingDirectory(), "cache", "remote-workflows");
|
|
461
|
-
const remoteCache = await prefetchRemoteWorkflows(workflowPath, remoteCacheDir);
|
|
500
|
+
const remoteCache = await prefetchRemoteWorkflows(workflowPath, remoteCacheDir, githubToken);
|
|
462
501
|
const expandedEntries = expandReusableJobs(workflowPath, repoRoot, remoteCache);
|
|
463
502
|
if (expandedEntries.length === 0) {
|
|
464
503
|
debugCli(`[Agent CI] No jobs found in workflow: ${path.basename(workflowPath)}`);
|
|
@@ -877,6 +916,10 @@ function printUsage() {
|
|
|
877
916
|
console.log(" -p, --pause-on-failure Pause on step failure for interactive debugging");
|
|
878
917
|
console.log(" -q, --quiet Suppress animated rendering (also enabled by AI_AGENT=1)");
|
|
879
918
|
console.log(" --no-matrix Collapse all matrix combinations into a single job (uses first value of each key)");
|
|
919
|
+
console.log(" --github-token [<token>] GitHub token for fetching remote reusable workflows");
|
|
920
|
+
console.log(" (auto-resolves via `gh auth token` if no value given)");
|
|
921
|
+
console.log(" Or set: AGENT_CI_GITHUB_TOKEN env var");
|
|
922
|
+
console.log(" --commit-status Post a GitHub commit status after the run (requires --github-token)");
|
|
880
923
|
}
|
|
881
924
|
function resolveRepoRoot() {
|
|
882
925
|
let repoRoot = process.cwd();
|
package/dist/commit-status.js
CHANGED
|
@@ -2,9 +2,13 @@ import { execSync } from "child_process";
|
|
|
2
2
|
import { config } from "./config.js";
|
|
3
3
|
/**
|
|
4
4
|
* Post a GitHub commit status via the `gh` CLI.
|
|
5
|
-
*
|
|
5
|
+
* Only called when --commit-status is passed. Requires a GitHub token.
|
|
6
6
|
*/
|
|
7
|
-
export function postCommitStatus(results, sha) {
|
|
7
|
+
export function postCommitStatus(results, sha, githubToken) {
|
|
8
|
+
if (!githubToken) {
|
|
9
|
+
console.warn("[Agent CI] --commit-status requires a GitHub token. Use --github-token or set AGENT_CI_GITHUB_TOKEN.");
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
8
12
|
// Check if gh CLI is available
|
|
9
13
|
try {
|
|
10
14
|
execSync("which gh", { stdio: "ignore" });
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { execSync } from "node:child_process";
|
|
4
3
|
import { parse as parseYaml } from "yaml";
|
|
5
4
|
/**
|
|
6
5
|
* Parse a remote reusable workflow ref string.
|
|
@@ -37,27 +36,6 @@ export function remoteCachePath(cacheDir, ref) {
|
|
|
37
36
|
const sanitizedRef = ref.ref.replace(/[^a-zA-Z0-9._-]/g, "-");
|
|
38
37
|
return path.join(cacheDir, `${ref.owner}__${ref.repo}@${sanitizedRef}`, ref.path);
|
|
39
38
|
}
|
|
40
|
-
/**
|
|
41
|
-
* Resolve a GitHub token for API access.
|
|
42
|
-
* Tries `gh auth token` first (real user credentials), then falls back to
|
|
43
|
-
* GITHUB_TOKEN env var. This ordering matters because agent-ci injects a
|
|
44
|
-
* fake token as GITHUB_TOKEN for the runner context.
|
|
45
|
-
*/
|
|
46
|
-
function resolveGitHubToken() {
|
|
47
|
-
try {
|
|
48
|
-
const token = execSync("gh auth token", {
|
|
49
|
-
encoding: "utf-8",
|
|
50
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
51
|
-
}).trim();
|
|
52
|
-
if (token) {
|
|
53
|
-
return token;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
catch {
|
|
57
|
-
// gh not installed or not authenticated
|
|
58
|
-
}
|
|
59
|
-
return process.env.GITHUB_TOKEN || null;
|
|
60
|
-
}
|
|
61
39
|
/**
|
|
62
40
|
* Scan a workflow YAML and prefetch all remote reusable workflow refs.
|
|
63
41
|
* Downloaded files are written to cacheDir.
|
|
@@ -65,9 +43,11 @@ function resolveGitHubToken() {
|
|
|
65
43
|
* - SHA refs: cached forever (immutable)
|
|
66
44
|
* - Tag/branch refs: always re-fetched (mutable)
|
|
67
45
|
*
|
|
68
|
-
*
|
|
46
|
+
* Authentication is opt-in via the `githubToken` parameter.
|
|
47
|
+
* Public repos may work without auth (within rate limits).
|
|
48
|
+
* On 401/403 responses, throws with instructions for how to authenticate.
|
|
69
49
|
*/
|
|
70
|
-
export async function prefetchRemoteWorkflows(workflowPath, cacheDir) {
|
|
50
|
+
export async function prefetchRemoteWorkflows(workflowPath, cacheDir, githubToken) {
|
|
71
51
|
const resolved = new Map();
|
|
72
52
|
const raw = parseYaml(fs.readFileSync(workflowPath, "utf-8"));
|
|
73
53
|
const jobs = raw?.jobs ?? {};
|
|
@@ -84,7 +64,6 @@ export async function prefetchRemoteWorkflows(workflowPath, cacheDir) {
|
|
|
84
64
|
if (remoteRefs.length === 0) {
|
|
85
65
|
return resolved;
|
|
86
66
|
}
|
|
87
|
-
const token = resolveGitHubToken();
|
|
88
67
|
const errors = [];
|
|
89
68
|
await Promise.all(remoteRefs.map(async (ref) => {
|
|
90
69
|
const dest = remoteCachePath(cacheDir, ref);
|
|
@@ -99,13 +78,13 @@ export async function prefetchRemoteWorkflows(workflowPath, cacheDir) {
|
|
|
99
78
|
Accept: "application/vnd.github.v3+json",
|
|
100
79
|
"User-Agent": "agent-ci/1.0",
|
|
101
80
|
};
|
|
102
|
-
if (
|
|
103
|
-
headers["Authorization"] = `token ${
|
|
81
|
+
if (githubToken) {
|
|
82
|
+
headers["Authorization"] = `token ${githubToken}`;
|
|
104
83
|
}
|
|
105
84
|
const response = await fetch(url, { headers });
|
|
106
85
|
if (!response.ok) {
|
|
107
86
|
const hint = response.status === 401 || response.status === 403
|
|
108
|
-
?
|
|
87
|
+
? ` Run with: agent-ci run --github-token\n Or set: export AGENT_CI_GITHUB_TOKEN=$(gh auth token)`
|
|
109
88
|
: "";
|
|
110
89
|
errors.push(`Failed to fetch remote workflow ${ref.raw} (HTTP ${response.status}).${hint}`);
|
|
111
90
|
return;
|
|
@@ -196,7 +196,84 @@ jobs:
|
|
|
196
196
|
lint:
|
|
197
197
|
uses: org/private-repo/.github/workflows/lint.yml@v1
|
|
198
198
|
`);
|
|
199
|
-
await expect(prefetchRemoteWorkflows(wf, cacheDir)).rejects.toThrow(/
|
|
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);
|
|
200
277
|
});
|
|
201
278
|
it("fetches multiple remote refs in parallel", async () => {
|
|
202
279
|
const remoteYaml = `
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@redwoodjs/agent-ci",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "Local GitHub Actions runner — pause on failure, ~0ms cache, official runner binary. Built for AI coding agents.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"act-alternative",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"log-update": "^7.2.0",
|
|
41
41
|
"minimatch": "^10.2.1",
|
|
42
42
|
"yaml": "^2.8.2",
|
|
43
|
-
"dtu-github-actions": "0.8.
|
|
43
|
+
"dtu-github-actions": "0.8.1"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@types/dockerode": "^3.3.34",
|