@redwoodjs/agent-ci 0.8.0 → 0.8.2

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
@@ -12,7 +12,7 @@ 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";
14
14
  import { getWorkingDirectory } from "./output/working-directory.js";
15
- import { pruneOrphanedDockerResources } from "./docker/shutdown.js";
15
+ import { pruneOrphanedDockerResources, killOrphanedContainers, pruneStaleWorkspaces, } from "./docker/shutdown.js";
16
16
  import { topoSort } from "./workflow/job-scheduler.js";
17
17
  import { expandReusableJobs } from "./workflow/reusable-workflow.js";
18
18
  import { prefetchRemoteWorkflows } from "./workflow/remote-workflow-fetch.js";
@@ -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)}`);
@@ -653,6 +692,8 @@ async function handleWorkflow(options) {
653
692
  return result;
654
693
  };
655
694
  pruneOrphanedDockerResources();
695
+ killOrphanedContainers();
696
+ pruneStaleWorkspaces(getWorkingDirectory(), 24 * 60 * 60 * 1000);
656
697
  const limiter = createConcurrencyLimiter(maxJobs);
657
698
  const allResults = [];
658
699
  // Accumulate job outputs across waves for needs.*.outputs.* resolution
@@ -877,6 +918,10 @@ function printUsage() {
877
918
  console.log(" -p, --pause-on-failure Pause on step failure for interactive debugging");
878
919
  console.log(" -q, --quiet Suppress animated rendering (also enabled by AI_AGENT=1)");
879
920
  console.log(" --no-matrix Collapse all matrix combinations into a single job (uses first value of each key)");
921
+ console.log(" --github-token [<token>] GitHub token for fetching remote reusable workflows");
922
+ console.log(" (auto-resolves via `gh auth token` if no value given)");
923
+ console.log(" Or set: AGENT_CI_GITHUB_TOKEN env var");
924
+ console.log(" --commit-status Post a GitHub commit status after the run (requires --github-token)");
880
925
  }
881
926
  function resolveRepoRoot() {
882
927
  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" });
@@ -33,7 +33,8 @@ export function buildContainerEnv(opts) {
33
33
  * Build the Binds array for `docker.createContainer()`.
34
34
  */
35
35
  export function buildContainerBinds(opts) {
36
- const { hostWorkDir, shimsDir, signalsDir, diagDir, toolCacheDir, pnpmStoreDir, npmCacheDir, bunCacheDir, playwrightCacheDir, warmModulesDir, hostRunnerDir, useDirectContainer, dockerSocketPath = "/var/run/docker.sock", } = opts;
36
+ const { hostWorkDir, shimsDir, signalsDir, diagDir, toolCacheDir, pnpmStoreDir, npmCacheDir, bunCacheDir, playwrightCacheDir, warmModulesDir, hostRunnerDir, useDirectContainer, githubRepo, dockerSocketPath = "/var/run/docker.sock", } = opts;
37
+ const repoName = githubRepo.split("/").pop() || "repo";
37
38
  const h = toHostPath;
38
39
  return [
39
40
  // When using a custom container, bind-mount the extracted runner
@@ -50,12 +51,11 @@ export function buildContainerBinds(opts) {
50
51
  ...(npmCacheDir ? [`${h(npmCacheDir)}:/home/runner/.npm`] : []),
51
52
  ...(bunCacheDir ? [`${h(bunCacheDir)}:/home/runner/.bun/install/cache`] : []),
52
53
  `${h(playwrightCacheDir)}:/home/runner/.cache/ms-playwright`,
53
- // Warm node_modules: mounted outside the workspace so actions/checkout can
54
- // delete the symlink without EBUSY. A symlink in the entrypoint points
55
- // workspace/node_modules /tmp/node_modules.
56
- // Mounted at /tmp/node_modules (not /tmp/warm-modules) so that TypeScript's
57
- // upward @types walk from .pnpm realpath finds /tmp/node_modules/@types.
58
- `${h(warmModulesDir)}:/tmp/node_modules`,
54
+ // Warm node_modules: mounted directly at the workspace node_modules path
55
+ // so pnpm/esbuild path resolution sees a real directory (not a symlink).
56
+ // The git shim blocks `git clean` and checkout is patched with clean:false,
57
+ // so EBUSY on this bind mount is not a concern.
58
+ `${h(warmModulesDir)}:/home/runner/_work/${repoName}/${repoName}/node_modules`,
59
59
  ];
60
60
  }
61
61
  // ─── Container command ────────────────────────────────────────────────────────
@@ -89,7 +89,6 @@ export function buildContainerCmd(opts) {
89
89
  `REPO_NAME=$(basename $GITHUB_REPOSITORY)`,
90
90
  `WORKSPACE_PATH=/home/runner/_work/$REPO_NAME/$REPO_NAME`,
91
91
  `mkdir -p $WORKSPACE_PATH`,
92
- `ln -sfn /tmp/node_modules $WORKSPACE_PATH/node_modules`,
93
92
  T("workspace-setup"),
94
93
  `echo "[agent-ci:boot] total: $(($(date +%s%3N)-BOOT_T0))ms"`,
95
94
  `echo "[agent-ci:boot] starting run.sh --once"`,
@@ -56,11 +56,12 @@ describe("buildContainerBinds", () => {
56
56
  warmModulesDir: "/tmp/warm",
57
57
  hostRunnerDir: "/tmp/runner",
58
58
  useDirectContainer: false,
59
+ githubRepo: "org/repo",
59
60
  });
60
61
  expect(binds).toContain("/tmp/work:/home/runner/_work");
61
62
  expect(binds).toContain("/var/run/docker.sock:/var/run/docker.sock"); // default when dockerSocketPath is not set
62
63
  expect(binds).toContain("/tmp/shims:/tmp/agent-ci-shims");
63
- expect(binds).toContain("/tmp/warm:/tmp/node_modules");
64
+ expect(binds).toContain("/tmp/warm:/home/runner/_work/repo/repo/node_modules");
64
65
  expect(binds).toContain("/tmp/pnpm:/home/runner/_work/.pnpm-store");
65
66
  expect(binds).toContain("/tmp/npm:/home/runner/.npm");
66
67
  expect(binds).toContain("/tmp/bun:/home/runner/.bun/install/cache");
@@ -78,6 +79,7 @@ describe("buildContainerBinds", () => {
78
79
  warmModulesDir: "/tmp/warm",
79
80
  hostRunnerDir: "/tmp/runner",
80
81
  useDirectContainer: false,
82
+ githubRepo: "org/repo",
81
83
  });
82
84
  expect(binds).toContain("/tmp/work:/home/runner/_work");
83
85
  expect(binds.some((b) => b.includes(".pnpm-store"))).toBe(false);
@@ -96,6 +98,7 @@ describe("buildContainerBinds", () => {
96
98
  warmModulesDir: "/tmp/warm",
97
99
  hostRunnerDir: "/tmp/runner",
98
100
  useDirectContainer: false,
101
+ githubRepo: "org/repo",
99
102
  });
100
103
  expect(binds).toContain("/tmp/npm:/home/runner/.npm");
101
104
  expect(binds.some((b) => b.includes(".pnpm-store"))).toBe(false);
@@ -112,6 +115,7 @@ describe("buildContainerBinds", () => {
112
115
  warmModulesDir: "/tmp/warm",
113
116
  hostRunnerDir: "/tmp/runner",
114
117
  useDirectContainer: false,
118
+ githubRepo: "org/repo",
115
119
  dockerSocketPath: "/Users/test/.orbstack/run/docker.sock",
116
120
  });
117
121
  expect(binds).toContain("/Users/test/.orbstack/run/docker.sock:/var/run/docker.sock");
@@ -131,6 +135,7 @@ describe("buildContainerBinds", () => {
131
135
  warmModulesDir: "/tmp/warm",
132
136
  hostRunnerDir: "/tmp/runner",
133
137
  useDirectContainer: true,
138
+ githubRepo: "org/repo",
134
139
  });
135
140
  expect(binds).toContain("/tmp/runner:/home/runner");
136
141
  });
@@ -322,6 +327,7 @@ describe("buildContainerBinds with dockerSocketPath", () => {
322
327
  warmModulesDir: "/tmp/warm",
323
328
  hostRunnerDir: "/tmp/runner",
324
329
  useDirectContainer: false,
330
+ githubRepo: "org/repo",
325
331
  };
326
332
  it("uses default /var/run/docker.sock when no dockerSocketPath is provided", async () => {
327
333
  const { buildContainerBinds } = await import("./container-config.js");
@@ -352,6 +358,7 @@ describe("buildContainerBinds with signalsDir", () => {
352
358
  warmModulesDir: "/tmp/warm",
353
359
  hostRunnerDir: "/tmp/runner",
354
360
  useDirectContainer: false,
361
+ githubRepo: "org/repo",
355
362
  };
356
363
  it("includes signals bind-mount when signalsDir is provided", async () => {
357
364
  const { buildContainerBinds } = await import("./container-config.js");
@@ -105,13 +105,17 @@ export function resolveDockerSocket() {
105
105
  throw new Error(lines.join("\n"));
106
106
  }
107
107
  /**
108
- * If `socketPath` exists (following symlinks), return the real path.
109
- * Returns undefined otherwise.
108
+ * If `socketPath` exists (following symlinks) and is accessible, return the
109
+ * real path. Returns undefined otherwise so the caller can keep searching.
110
110
  */
111
111
  function resolveIfExists(socketPath) {
112
112
  try {
113
113
  // fs.realpathSync follows symlinks and throws if the target doesn't exist
114
- return fs.realpathSync(socketPath);
114
+ const resolved = fs.realpathSync(socketPath);
115
+ // Verify we can actually connect — the socket may exist but be owned by
116
+ // root:docker with 660 perms (common on Linux with Docker Desktop).
117
+ fs.accessSync(resolved, fs.constants.R_OK | fs.constants.W_OK);
118
+ return resolved;
115
119
  }
116
120
  catch {
117
121
  return undefined;
@@ -20,6 +20,7 @@ describe("resolveDockerSocket", () => {
20
20
  it("uses DOCKER_HOST when set to a unix socket that exists", async () => {
21
21
  process.env.DOCKER_HOST = "unix:///tmp/test-docker.sock";
22
22
  vi.spyOn(fs, "realpathSync").mockReturnValue("/tmp/test-docker.sock");
23
+ vi.spyOn(fs, "accessSync").mockReturnValue(undefined);
23
24
  const { resolveDockerSocket } = await importFresh();
24
25
  const result = resolveDockerSocket();
25
26
  expect(result.socketPath).toBe("/tmp/test-docker.sock");
@@ -41,11 +42,36 @@ describe("resolveDockerSocket", () => {
41
42
  }
42
43
  throw new Error("ENOENT");
43
44
  });
45
+ vi.spyOn(fs, "accessSync").mockReturnValue(undefined);
44
46
  const { resolveDockerSocket } = await importFresh();
45
47
  const result = resolveDockerSocket();
46
48
  expect(result.socketPath).toBe("/Users/test/.orbstack/run/docker.sock");
47
49
  expect(result.uri).toBe("unix:///Users/test/.orbstack/run/docker.sock");
48
50
  });
51
+ // ── EACCES fallthrough ─────────────────────────────────────────────────
52
+ it("falls through to docker context when default socket is not accessible", async () => {
53
+ delete process.env.DOCKER_HOST;
54
+ // Socket exists but is not accessible (e.g. root:docker 660 on Linux)
55
+ vi.spyOn(fs, "realpathSync").mockReturnValue("/var/run/docker.sock");
56
+ vi.spyOn(fs, "accessSync").mockImplementation(() => {
57
+ throw Object.assign(new Error("EACCES"), { code: "EACCES" });
58
+ });
59
+ vi.spyOn(fs, "existsSync").mockImplementation((p) => {
60
+ return String(p) === "/home/user/.docker/desktop/docker.sock";
61
+ });
62
+ mockedExecSync.mockReturnValue(JSON.stringify([
63
+ {
64
+ Endpoints: {
65
+ docker: {
66
+ Host: "unix:///home/user/.docker/desktop/docker.sock",
67
+ },
68
+ },
69
+ },
70
+ ]));
71
+ const { resolveDockerSocket } = await importFresh();
72
+ const result = resolveDockerSocket();
73
+ expect(result.socketPath).toBe("/home/user/.docker/desktop/docker.sock");
74
+ });
49
75
  // ── Docker context fallback ─────────────────────────────────────────────
50
76
  it("falls back to docker context inspect when default socket missing", async () => {
51
77
  delete process.env.DOCKER_HOST;
@@ -101,6 +101,49 @@ export function pruneOrphanedDockerResources() {
101
101
  // Docker not reachable — skip
102
102
  }
103
103
  }
104
+ // ─── Orphaned container cleanup ───────────────────────────────────────────────
105
+ /**
106
+ * Find and kill running `agent-ci-*` containers whose parent process is dead.
107
+ *
108
+ * Every container created by `executeLocalJob` is labelled with
109
+ * `agent-ci.pid=<PID>`. If the process that spawned the container is no
110
+ * longer alive, the container is an orphan and should be killed — along with
111
+ * its svc-* sidecars and bridge network (via `killRunnerContainers`).
112
+ *
113
+ * Containers without the label (created before this feature) are skipped.
114
+ */
115
+ export function killOrphanedContainers() {
116
+ let lines;
117
+ try {
118
+ // Format: "containerId containerName pid-label"
119
+ const raw = execSync(`docker ps --filter "name=agent-ci-" --filter "status=running" --format "{{.ID}} {{.Names}} {{.Label \\"agent-ci.pid\\"}}"`, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
120
+ if (!raw) {
121
+ return;
122
+ }
123
+ lines = raw.split("\n");
124
+ }
125
+ catch {
126
+ // Docker not reachable — skip
127
+ return;
128
+ }
129
+ for (const line of lines) {
130
+ const [, containerName, pidStr] = line.split(" ");
131
+ if (!containerName || !pidStr) {
132
+ continue;
133
+ }
134
+ const pid = Number(pidStr);
135
+ if (!Number.isFinite(pid) || pid <= 0) {
136
+ continue;
137
+ }
138
+ try {
139
+ process.kill(pid, 0); // signal 0 = liveness check, throws if dead
140
+ }
141
+ catch {
142
+ // Parent is dead — this container is an orphan.
143
+ killRunnerContainers(containerName);
144
+ }
145
+ }
146
+ }
104
147
  // ─── Workspace pruning ────────────────────────────────────────────────────────
105
148
  /**
106
149
  * Remove stale `agent-ci-*` run directories older than `maxAgeMs` from
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, beforeEach, afterEach } from "vitest";
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import os from "node:os";
@@ -92,6 +92,78 @@ describe("Stale workspace pruning", () => {
92
92
  expect(fs.existsSync(otherDir)).toBe(true);
93
93
  });
94
94
  });
95
+ // ── Orphaned container cleanup ────────────────────────────────────────────────
96
+ describe("killOrphanedContainers", () => {
97
+ const execSyncMock = vi.fn();
98
+ const killSpy = vi.spyOn(process, "kill");
99
+ beforeEach(() => {
100
+ vi.resetModules();
101
+ vi.doMock("node:child_process", () => ({
102
+ execSync: execSyncMock,
103
+ }));
104
+ execSyncMock.mockReset();
105
+ killSpy.mockReset();
106
+ });
107
+ afterEach(() => {
108
+ killSpy.mockRestore();
109
+ });
110
+ it("kills containers whose parent PID is dead", async () => {
111
+ execSyncMock.mockImplementation((cmd) => {
112
+ if (cmd.startsWith("docker ps")) {
113
+ return "abc123 agent-ci-runner-1 99999\n";
114
+ }
115
+ return "";
116
+ });
117
+ killSpy.mockImplementation(((pid, signal) => {
118
+ if (signal === 0 && pid === 99999) {
119
+ throw new Error("ESRCH");
120
+ }
121
+ return true;
122
+ }));
123
+ const { killOrphanedContainers } = await import("./shutdown.js");
124
+ killOrphanedContainers();
125
+ const rmCalls = execSyncMock.mock.calls.filter(([cmd]) => cmd.includes("docker rm -f agent-ci-runner-1"));
126
+ expect(rmCalls.length).toBeGreaterThan(0);
127
+ });
128
+ it("leaves containers whose parent PID is alive", async () => {
129
+ const myPid = process.pid;
130
+ execSyncMock.mockImplementation((cmd) => {
131
+ if (cmd.startsWith("docker ps")) {
132
+ return `abc123 agent-ci-runner-2 ${myPid}\n`;
133
+ }
134
+ return "";
135
+ });
136
+ killSpy.mockImplementation(((pid, signal) => {
137
+ if (signal === 0 && pid === myPid) {
138
+ return true;
139
+ }
140
+ throw new Error("ESRCH");
141
+ }));
142
+ const { killOrphanedContainers } = await import("./shutdown.js");
143
+ killOrphanedContainers();
144
+ const rmCalls = execSyncMock.mock.calls.filter(([cmd]) => cmd.includes("docker rm -f"));
145
+ expect(rmCalls).toEqual([]);
146
+ });
147
+ it("skips containers without a PID label", async () => {
148
+ execSyncMock.mockImplementation((cmd) => {
149
+ if (cmd.startsWith("docker ps")) {
150
+ return "abc123 agent-ci-runner-3 \n";
151
+ }
152
+ return "";
153
+ });
154
+ const { killOrphanedContainers } = await import("./shutdown.js");
155
+ killOrphanedContainers();
156
+ const rmCalls = execSyncMock.mock.calls.filter(([cmd]) => cmd.includes("docker rm -f"));
157
+ expect(rmCalls).toEqual([]);
158
+ });
159
+ it("handles Docker not reachable gracefully", async () => {
160
+ execSyncMock.mockImplementation(() => {
161
+ throw new Error("Cannot connect to Docker daemon");
162
+ });
163
+ const { killOrphanedContainers } = await import("./shutdown.js");
164
+ expect(() => killOrphanedContainers()).not.toThrow();
165
+ });
166
+ });
95
167
  describe("containerWorkDir cleanup on exit", () => {
96
168
  let tmpDir;
97
169
  beforeEach(() => {
@@ -149,6 +149,7 @@ describe("buildContainerBinds — PM-scoped mounts", () => {
149
149
  warmModulesDir: "/tmp/warm",
150
150
  hostRunnerDir: "/tmp/runner",
151
151
  useDirectContainer: false,
152
+ githubRepo: "org/repo",
152
153
  });
153
154
  expect(binds).toContain("/tmp/npm-cache:/home/runner/.npm");
154
155
  expect(binds.some((b) => b.includes(".pnpm-store"))).toBe(false);
@@ -167,6 +168,7 @@ describe("buildContainerBinds — PM-scoped mounts", () => {
167
168
  warmModulesDir: "/tmp/warm",
168
169
  hostRunnerDir: "/tmp/runner",
169
170
  useDirectContainer: false,
171
+ githubRepo: "org/repo",
170
172
  });
171
173
  expect(binds).toContain("/tmp/pnpm-store:/home/runner/_work/.pnpm-store");
172
174
  expect(binds.some((b) => b.includes("/.npm"))).toBe(false);
@@ -185,6 +187,7 @@ describe("buildContainerBinds — PM-scoped mounts", () => {
185
187
  warmModulesDir: "/tmp/warm",
186
188
  hostRunnerDir: "/tmp/runner",
187
189
  useDirectContainer: false,
190
+ githubRepo: "org/repo",
188
191
  });
189
192
  expect(binds).toContain("/tmp/bun-cache:/home/runner/.bun/install/cache");
190
193
  expect(binds.some((b) => b.includes(".pnpm-store"))).toBe(false);
@@ -202,6 +205,7 @@ describe("buildContainerBinds — PM-scoped mounts", () => {
202
205
  warmModulesDir: "/tmp/warm",
203
206
  hostRunnerDir: "/tmp/runner",
204
207
  useDirectContainer: false,
208
+ githubRepo: "org/repo",
205
209
  });
206
210
  expect(binds.some((b) => b.includes(".pnpm-store"))).toBe(false);
207
211
  expect(binds.some((b) => b.includes("/.npm"))).toBe(false);
@@ -1,13 +1,5 @@
1
1
  import path from "path";
2
2
  import fs from "fs";
3
- // ─── Fake SHA computation ─────────────────────────────────────────────────────
4
- /**
5
- * Resolve which SHA the git shim should return for ls-remote / rev-parse.
6
- * Uses the real SHA if provided, otherwise falls back to a deterministic fake.
7
- */
8
- export function computeFakeSha(headSha) {
9
- return headSha && headSha !== "HEAD" ? headSha : "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
10
- }
11
3
  // ─── Git shim script ──────────────────────────────────────────────────────────
12
4
  /**
13
5
  * Write the bash git shim to `<shimsDir>/git`.
@@ -2,21 +2,6 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import os from "node:os";
5
- // ── computeFakeSha ────────────────────────────────────────────────────────────
6
- describe("computeFakeSha", () => {
7
- it("returns the headSha when it is a real SHA", async () => {
8
- const { computeFakeSha } = await import("./git-shim.js");
9
- expect(computeFakeSha("abc123def456")).toBe("abc123def456");
10
- });
11
- it("returns the deterministic fake when headSha is HEAD", async () => {
12
- const { computeFakeSha } = await import("./git-shim.js");
13
- expect(computeFakeSha("HEAD")).toBe("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
14
- });
15
- it("returns the deterministic fake when headSha is undefined", async () => {
16
- const { computeFakeSha } = await import("./git-shim.js");
17
- expect(computeFakeSha(undefined)).toBe("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
18
- });
19
- });
20
5
  // ── writeGitShim ──────────────────────────────────────────────────────────────
21
6
  describe("writeGitShim", () => {
22
7
  let tmpDir;
@@ -12,7 +12,7 @@ import { killRunnerContainers } from "../docker/shutdown.js";
12
12
  import { startEphemeralDtu } from "dtu-github-actions/ephemeral";
13
13
  import { tailLogFile } from "../output/reporter.js";
14
14
  import { writeJobMetadata } from "./metadata.js";
15
- import { computeFakeSha, writeGitShim } from "./git-shim.js";
15
+ import { writeGitShim } from "./git-shim.js";
16
16
  import { prepareWorkspace } from "./workspace.js";
17
17
  import { createRunDirectories } from "./directory-setup.js";
18
18
  import { buildContainerEnv, buildContainerBinds, buildContainerCmd, resolveDtuHost, resolveDockerApiUrl, resolveDockerExtraHosts, } from "../docker/container-config.js";
@@ -184,13 +184,23 @@ export async function executeLocalJob(job, options) {
184
184
  writeJobMetadata({ logDir, containerName, job });
185
185
  // Open debug stream to capture raw container output
186
186
  const debugStream = fs.createWriteStream(debugLogPath);
187
+ // Hoisted for cleanup in `finally` — assigned inside the try block.
188
+ let container = null;
189
+ let serviceCtx;
190
+ const hostRunnerDir = path.resolve(runDir, "runner");
187
191
  // Signal handler: ensure cleanup runs even when killed.
188
192
  // Do NOT call process.exit() here — multiple jobs register handlers concurrently,
189
193
  // and an early exit would prevent other jobs' handlers from cleaning up their containers.
190
194
  // killRunnerContainers already handles the runner, its svc-* sidecars, and the network.
191
195
  const signalCleanup = () => {
192
196
  killRunnerContainers(containerName);
193
- for (const d of [dirs.containerWorkDir, dirs.shimsDir, dirs.signalsDir, dirs.diagDir]) {
197
+ for (const d of [
198
+ dirs.containerWorkDir,
199
+ dirs.shimsDir,
200
+ dirs.signalsDir,
201
+ dirs.diagDir,
202
+ hostRunnerDir,
203
+ ]) {
194
204
  try {
195
205
  fs.rmSync(d, { recursive: true, force: true });
196
206
  }
@@ -199,10 +209,6 @@ export async function executeLocalJob(job, options) {
199
209
  };
200
210
  process.on("SIGINT", signalCleanup);
201
211
  process.on("SIGTERM", signalCleanup);
202
- // Hoisted for cleanup in `finally` — assigned inside the try block.
203
- let container = null;
204
- let serviceCtx;
205
- const hostRunnerDir = path.resolve(runDir, "runner");
206
212
  try {
207
213
  // 1. Seed the job to Local DTU
208
214
  const [githubOwner, githubRepoName] = (job.githubRepo || "").split("/");
@@ -243,8 +249,7 @@ export async function executeLocalJob(job, options) {
243
249
  // 4. Write git shim BEFORE container start so the entrypoint can install it
244
250
  // immediately. On Linux, prepareWorkspace (rsync) is slow enough that the
245
251
  // container entrypoint would race ahead and find an empty shims dir.
246
- const fakeSha = computeFakeSha(job.headSha);
247
- writeGitShim(dirs.shimsDir, fakeSha);
252
+ writeGitShim(dirs.shimsDir, job.realHeadSha);
248
253
  // Prepare workspace files in parallel with container setup
249
254
  const workspacePrepStart = Date.now();
250
255
  const workspacePrepPromise = (async () => {
@@ -449,6 +454,7 @@ export async function executeLocalJob(job, options) {
449
454
  warmModulesDir: dirs.warmModulesDir,
450
455
  hostRunnerDir,
451
456
  useDirectContainer,
457
+ githubRepo,
452
458
  dockerSocketPath: getDockerSocket().socketPath || undefined,
453
459
  });
454
460
  const containerCmd = buildContainerCmd({
@@ -463,6 +469,9 @@ export async function executeLocalJob(job, options) {
463
469
  container = await getDocker().createContainer({
464
470
  Image: containerImage,
465
471
  name: containerName,
472
+ Labels: {
473
+ "agent-ci.pid": String(process.pid),
474
+ },
466
475
  Env: containerEnv,
467
476
  ...(useDirectContainer ? { Entrypoint: ["bash"] } : {}),
468
477
  Cmd: containerCmd,
@@ -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.2",
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.2"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/dockerode": "^3.3.34",