@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 | 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) |
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
- postCommitStatus(results, sha);
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
- postCommitStatus(results, sha);
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();
@@ -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
- * Silently skips if `gh` is not available on PATH.
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
- * Throws on fetch failures (404, auth errors, network errors).
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 (token) {
103
- headers["Authorization"] = `token ${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
- ? " Ensure GITHUB_TOKEN is set or run `gh auth login`."
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(/gh auth login/);
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.0",
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.0"
43
+ "dtu-github-actions": "0.8.1"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/dockerode": "^3.3.34",