@redwoodjs/agent-ci 0.8.1 → 0.9.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/dist/cli.js CHANGED
@@ -7,17 +7,18 @@ 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 { parseWorkflowSteps, parseWorkflowServices, parseWorkflowContainer, validateSecrets, parseMatrixDef, expandMatrixCombinations, collapseMatrixToSingle, isWorkflowRelevant, getChangedFiles, parseJobOutputDefs, parseJobIf, evaluateJobIf, parseFailFast, expandExpressions, } from "./workflow/workflow-parser.js";
10
+ import { parseWorkflowSteps, parseWorkflowServices, parseWorkflowContainer, validateSecrets, extractSecretRefs, parseMatrixDef, expandMatrixCombinations, collapseMatrixToSingle, isWorkflowRelevant, getChangedFiles, parseJobOutputDefs, parseJobIf, evaluateJobIf, parseFailFast, expandExpressions, } 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";
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";
19
19
  import { printSummary } from "./output/reporter.js";
20
20
  import { syncWorkspaceForRetry } from "./runner/sync.js";
21
+ import { computeDirtySha } from "./runner/dirty-sha.js";
21
22
  import { RunStateStore } from "./output/run-state.js";
22
23
  import { renderRunState } from "./output/state-renderer.js";
23
24
  import { isAgentMode, setQuietMode } from "./output/agent-mode.js";
@@ -489,9 +490,12 @@ async function handleWorkflow(options) {
489
490
  const { headSha, shaRef } = sha
490
491
  ? resolveHeadSha(repoRoot, sha)
491
492
  : { headSha: undefined, shaRef: undefined };
492
- // Always resolve the real HEAD SHA for the push event context (before/after).
493
- // This is separate from headSha which may be undefined for dirty workspace copies.
494
- const realHeadSha = headSha ?? resolveHeadSha(repoRoot, "HEAD").headSha;
493
+ // Always resolve a SHA that represents the code being executed.
494
+ // When the working tree is dirty and no explicit --sha was given, compute an
495
+ // ephemeral commit SHA that captures the dirty state (including untracked files).
496
+ // This is purely informational — actions/checkout is always stubbed, so no
497
+ // workflow will ever try to fetch this SHA from a remote.
498
+ const realHeadSha = headSha ?? computeDirtySha(repoRoot) ?? resolveHeadSha(repoRoot, "HEAD").headSha;
495
499
  const baseSha = resolveBaseSha(repoRoot, realHeadSha);
496
500
  const githubRepo = config.GITHUB_REPO ?? resolveRepoSlug(repoRoot);
497
501
  config.GITHUB_REPO = githubRepo;
@@ -546,7 +550,11 @@ async function handleWorkflow(options) {
546
550
  if (expandedJobs.length === 1) {
547
551
  const ej = expandedJobs[0];
548
552
  const actualTaskName = ej.sourceTaskName ?? ej.taskName;
549
- const secrets = loadMachineSecrets(repoRoot);
553
+ const requiredRefs = extractSecretRefs(ej.workflowPath, actualTaskName);
554
+ const secrets = loadMachineSecrets(repoRoot, requiredRefs);
555
+ if (githubToken && !secrets["GITHUB_TOKEN"]) {
556
+ secrets["GITHUB_TOKEN"] = githubToken;
557
+ }
550
558
  const secretsFilePath = path.join(repoRoot, ".env.agent-ci");
551
559
  validateSecrets(ej.workflowPath, actualTaskName, secrets, secretsFilePath);
552
560
  // Resolve inputs for called workflow jobs
@@ -608,7 +616,11 @@ async function handleWorkflow(options) {
608
616
  let globalIdx = 0;
609
617
  const buildJob = (ej) => {
610
618
  const actualTaskName = ej.sourceTaskName ?? ej.taskName;
611
- const secrets = loadMachineSecrets(repoRoot);
619
+ const requiredRefs = extractSecretRefs(ej.workflowPath, actualTaskName);
620
+ const secrets = loadMachineSecrets(repoRoot, requiredRefs);
621
+ if (githubToken && !secrets["GITHUB_TOKEN"]) {
622
+ secrets["GITHUB_TOKEN"] = githubToken;
623
+ }
612
624
  const secretsFilePath = path.join(repoRoot, ".env.agent-ci");
613
625
  validateSecrets(ej.workflowPath, actualTaskName, secrets, secretsFilePath);
614
626
  const idx = globalIdx++;
@@ -668,7 +680,11 @@ async function handleWorkflow(options) {
668
680
  const { taskName, matrixContext } = ej;
669
681
  const actualTaskName = ej.sourceTaskName ?? taskName;
670
682
  debugCli(`Running: ${path.basename(ej.workflowPath)} | Task: ${taskName}${matrixContext ? ` | Matrix: ${JSON.stringify(Object.fromEntries(Object.entries(matrixContext).filter(([k]) => !k.startsWith("__"))))}` : ""}`);
671
- const secrets = loadMachineSecrets(repoRoot);
683
+ const requiredRefs = extractSecretRefs(ej.workflowPath, actualTaskName);
684
+ const secrets = loadMachineSecrets(repoRoot, requiredRefs);
685
+ if (githubToken && !secrets["GITHUB_TOKEN"]) {
686
+ secrets["GITHUB_TOKEN"] = githubToken;
687
+ }
672
688
  const secretsFilePath = path.join(repoRoot, ".env.agent-ci");
673
689
  validateSecrets(ej.workflowPath, actualTaskName, secrets, secretsFilePath);
674
690
  const inputsContext = resolveInputsForJob(ej, secrets, needsContext);
@@ -692,6 +708,8 @@ async function handleWorkflow(options) {
692
708
  return result;
693
709
  };
694
710
  pruneOrphanedDockerResources();
711
+ killOrphanedContainers();
712
+ pruneStaleWorkspaces(getWorkingDirectory(), 24 * 60 * 60 * 1000);
695
713
  const limiter = createConcurrencyLimiter(maxJobs);
696
714
  const allResults = [];
697
715
  // Accumulate job outputs across waves for needs.*.outputs.* resolution
@@ -833,6 +851,7 @@ async function handleWorkflow(options) {
833
851
  collectOutputs(result, ej.taskName);
834
852
  return result;
835
853
  };
854
+ const seenErrorMessages = new Set();
836
855
  for (let wi = 0; wi < filteredWaves.length; wi++) {
837
856
  const waveJobIds = new Set(filteredWaves[wi]);
838
857
  const waveJobs = expandedJobs.filter((j) => waveJobIds.has(j.taskName));
@@ -854,8 +873,11 @@ async function handleWorkflow(options) {
854
873
  else {
855
874
  const taskName = isJobError(r.reason) ? r.reason.taskName : "unknown";
856
875
  const errorMessage = isJobError(r.reason) ? r.reason.message : String(r.reason);
857
- console.error(`\n[Agent CI] Job failed with error: ${taskName}`);
858
- console.error(` Error: ${errorMessage}`);
876
+ if (!seenErrorMessages.has(errorMessage)) {
877
+ seenErrorMessages.add(errorMessage);
878
+ console.error(`\n[Agent CI] Job failed with error: ${taskName}`);
879
+ console.error(` Error: ${errorMessage}`);
880
+ }
859
881
  allResults.push(createFailedJobResult(taskName, workflowPath, r.reason));
860
882
  }
861
883
  }
@@ -872,8 +894,11 @@ async function handleWorkflow(options) {
872
894
  else {
873
895
  const taskName = isJobError(r.reason) ? r.reason.taskName : "unknown";
874
896
  const errorMessage = isJobError(r.reason) ? r.reason.message : String(r.reason);
875
- console.error(`\n[Agent CI] Job failed with error: ${taskName}`);
876
- console.error(` Error: ${errorMessage}`);
897
+ if (!seenErrorMessages.has(errorMessage)) {
898
+ seenErrorMessages.add(errorMessage);
899
+ console.error(`\n[Agent CI] Job failed with error: ${taskName}`);
900
+ console.error(` Error: ${errorMessage}`);
901
+ }
877
902
  allResults.push(createFailedJobResult(taskName, workflowPath, r.reason));
878
903
  }
879
904
  }
@@ -920,6 +945,12 @@ function printUsage() {
920
945
  console.log(" (auto-resolves via `gh auth token` if no value given)");
921
946
  console.log(" Or set: AGENT_CI_GITHUB_TOKEN env var");
922
947
  console.log(" --commit-status Post a GitHub commit status after the run (requires --github-token)");
948
+ console.log("");
949
+ console.log("Secrets:");
950
+ console.log(" Workflow secrets (${{ secrets.FOO }}) are resolved from:");
951
+ console.log(" 1. .env.agent-ci file in the repo root");
952
+ console.log(" 2. Environment variables (shell env acts as fallback)");
953
+ console.log(" 3. --github-token automatically provides secrets.GITHUB_TOKEN");
923
954
  }
924
955
  function resolveRepoRoot() {
925
956
  let repoRoot = process.cwd();
package/dist/config.js CHANGED
@@ -55,35 +55,47 @@ export const config = {
55
55
  GITHUB_API_URL: process.env.GITHUB_API_URL || "http://localhost:8910",
56
56
  };
57
57
  /**
58
- * Load machine-local secrets from `.env.machine` at the agent-ci project root.
58
+ * Load machine-local secrets from `.env.agent-ci` at the given base directory.
59
59
  * The file uses KEY=VALUE syntax (lines starting with # are ignored).
60
- * Returns an empty object if the file doesn't exist.
60
+ *
61
+ * When `envFallbackKeys` is provided, any key in that list that is NOT already
62
+ * present in the file will be filled from `process.env` (shell environment
63
+ * variables act as a fallback for the .env file).
64
+ *
65
+ * Returns an empty object if the file doesn't exist and no env fallbacks match.
61
66
  */
62
- export function loadMachineSecrets(baseDir) {
67
+ export function loadMachineSecrets(baseDir, envFallbackKeys) {
63
68
  const envMachinePath = path.join(baseDir ?? PROJECT_ROOT, ".env.agent-ci");
64
- if (!fs.existsSync(envMachinePath)) {
65
- return {};
66
- }
67
69
  const secrets = {};
68
- const lines = fs.readFileSync(envMachinePath, "utf-8").split("\n");
69
- for (const line of lines) {
70
- const trimmed = line.trim();
71
- if (!trimmed || trimmed.startsWith("#")) {
72
- continue;
73
- }
74
- const eqIdx = trimmed.indexOf("=");
75
- if (eqIdx < 1) {
76
- continue;
77
- }
78
- const key = trimmed.slice(0, eqIdx).trim();
79
- let value = trimmed.slice(eqIdx + 1).trim();
80
- // Strip optional surrounding quotes
81
- if ((value.startsWith('"') && value.endsWith('"')) ||
82
- (value.startsWith("'") && value.endsWith("'"))) {
83
- value = value.slice(1, -1);
70
+ if (fs.existsSync(envMachinePath)) {
71
+ const lines = fs.readFileSync(envMachinePath, "utf-8").split("\n");
72
+ for (const line of lines) {
73
+ const trimmed = line.trim();
74
+ if (!trimmed || trimmed.startsWith("#")) {
75
+ continue;
76
+ }
77
+ const eqIdx = trimmed.indexOf("=");
78
+ if (eqIdx < 1) {
79
+ continue;
80
+ }
81
+ const key = trimmed.slice(0, eqIdx).trim();
82
+ let value = trimmed.slice(eqIdx + 1).trim();
83
+ // Strip optional surrounding quotes
84
+ if ((value.startsWith('"') && value.endsWith('"')) ||
85
+ (value.startsWith("'") && value.endsWith("'"))) {
86
+ value = value.slice(1, -1);
87
+ }
88
+ if (key) {
89
+ secrets[key] = value;
90
+ }
84
91
  }
85
- if (key) {
86
- secrets[key] = value;
92
+ }
93
+ // Fill missing secrets from process.env (shell env vars act as fallback)
94
+ if (envFallbackKeys) {
95
+ for (const key of envFallbackKeys) {
96
+ if (!secrets[key] && process.env[key]) {
97
+ secrets[key] = process.env[key];
98
+ }
87
99
  }
88
100
  }
89
101
  return secrets;
@@ -3,7 +3,7 @@ import fs from "fs";
3
3
  import os from "os";
4
4
  import path from "path";
5
5
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
- import { config, getFirstRemoteUrl, parseRepoSlug, resolveRepoSlug } from "./config.js";
6
+ import { config, getFirstRemoteUrl, loadMachineSecrets, parseRepoSlug, resolveRepoSlug, } from "./config.js";
7
7
  describe("parseRepoSlug", () => {
8
8
  it.each([
9
9
  ["https://github.com/redwoodjs/agent-ci.git", "redwoodjs/agent-ci"],
@@ -155,3 +155,79 @@ describe("GITHUB_REPO env var override priority", () => {
155
155
  }).toThrow(/Could not detect GitHub repository/);
156
156
  });
157
157
  });
158
+ // ─── loadMachineSecrets ──────────────────────────────────────────────────────
159
+ describe("loadMachineSecrets", () => {
160
+ let tmpDir;
161
+ const savedEnv = {};
162
+ function saveEnv(...keys) {
163
+ for (const k of keys) {
164
+ savedEnv[k] = process.env[k];
165
+ }
166
+ }
167
+ afterEach(() => {
168
+ if (tmpDir) {
169
+ fs.rmSync(tmpDir, { recursive: true, force: true });
170
+ }
171
+ for (const [k, v] of Object.entries(savedEnv)) {
172
+ if (v === undefined) {
173
+ delete process.env[k];
174
+ }
175
+ else {
176
+ process.env[k] = v;
177
+ }
178
+ }
179
+ });
180
+ function writeEnvFile(content) {
181
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "secrets-test-"));
182
+ fs.writeFileSync(path.join(tmpDir, ".env.agent-ci"), content);
183
+ return tmpDir;
184
+ }
185
+ function makeTmpDir() {
186
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "secrets-test-"));
187
+ return tmpDir;
188
+ }
189
+ it("returns empty object when .env.agent-ci does not exist", () => {
190
+ const dir = makeTmpDir();
191
+ expect(loadMachineSecrets(dir)).toEqual({});
192
+ });
193
+ it("parses KEY=VALUE pairs from file", () => {
194
+ const dir = writeEnvFile("FOO=bar\nBAZ=qux\n");
195
+ expect(loadMachineSecrets(dir)).toEqual({ FOO: "bar", BAZ: "qux" });
196
+ });
197
+ it("fills missing secrets from process.env when envFallbackKeys provided", () => {
198
+ const dir = makeTmpDir();
199
+ saveEnv("TEST_SECRET_ABC");
200
+ process.env.TEST_SECRET_ABC = "from-env";
201
+ const secrets = loadMachineSecrets(dir, ["TEST_SECRET_ABC"]);
202
+ expect(secrets.TEST_SECRET_ABC).toBe("from-env");
203
+ });
204
+ it("file values take precedence over process.env", () => {
205
+ const dir = writeEnvFile("MY_TOKEN=from-file\n");
206
+ saveEnv("MY_TOKEN");
207
+ process.env.MY_TOKEN = "from-env";
208
+ const secrets = loadMachineSecrets(dir, ["MY_TOKEN"]);
209
+ expect(secrets.MY_TOKEN).toBe("from-file");
210
+ });
211
+ it("does not pull from process.env for keys not in envFallbackKeys", () => {
212
+ const dir = makeTmpDir();
213
+ saveEnv("UNRELATED_VAR");
214
+ process.env.UNRELATED_VAR = "should-not-appear";
215
+ const secrets = loadMachineSecrets(dir, ["OTHER_KEY"]);
216
+ expect(secrets.UNRELATED_VAR).toBeUndefined();
217
+ expect(secrets.OTHER_KEY).toBeUndefined();
218
+ });
219
+ it("does not pull from process.env when envFallbackKeys is omitted", () => {
220
+ const dir = makeTmpDir();
221
+ saveEnv("SOME_SECRET");
222
+ process.env.SOME_SECRET = "env-value";
223
+ const secrets = loadMachineSecrets(dir);
224
+ expect(secrets.SOME_SECRET).toBeUndefined();
225
+ });
226
+ it("merges file secrets and env fallbacks", () => {
227
+ const dir = writeEnvFile("FROM_FILE=file-val\n");
228
+ saveEnv("FROM_ENV");
229
+ process.env.FROM_ENV = "env-val";
230
+ const secrets = loadMachineSecrets(dir, ["FROM_FILE", "FROM_ENV"]);
231
+ expect(secrets).toEqual({ FROM_FILE: "file-val", FROM_ENV: "env-val" });
232
+ });
233
+ });
@@ -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;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Ensures a Docker image is present locally, pulling it if not.
3
+ *
4
+ * Docker's createContainer() returns a 404 "No such image" error when the
5
+ * image is absent — it does not pull automatically. This helper mirrors the
6
+ * pattern already used by service-containers.ts and must be called before
7
+ * any createContainer() call.
8
+ *
9
+ * Reproduces: https://github.com/redwoodjs/agent-ci/issues/203
10
+ */
11
+ export async function ensureImagePulled(docker, image) {
12
+ try {
13
+ await docker.getImage(image).inspect();
14
+ return; // already present
15
+ }
16
+ catch {
17
+ // Not found locally — fall through to pull
18
+ }
19
+ await new Promise((resolve, reject) => {
20
+ docker.pull(image, (err, stream) => {
21
+ if (err) {
22
+ return reject(wrapPullError(image, err));
23
+ }
24
+ docker.modem.followProgress(stream, (err) => {
25
+ if (err) {
26
+ reject(wrapPullError(image, err));
27
+ }
28
+ else {
29
+ resolve();
30
+ }
31
+ });
32
+ });
33
+ });
34
+ }
35
+ function wrapPullError(image, cause) {
36
+ return new Error(`Failed to pull Docker image '${image}': ${cause.message}\n` +
37
+ "\n" +
38
+ " Possible causes:\n" +
39
+ " • The image name is misspelled or does not exist in the registry\n" +
40
+ " • The image is private — authenticate first: docker login <registry>\n" +
41
+ " • No network connection");
42
+ }
@@ -0,0 +1,38 @@
1
+ import { describe, it, expect, beforeAll } from "vitest";
2
+ import Docker from "dockerode";
3
+ import { ensureImagePulled } from "./image-pull.js";
4
+ import { resolveDockerSocket } from "./docker-socket.js";
5
+ // Integration test: requires a running Docker daemon and network access.
6
+ // Uses hello-world (~13 KB) to keep pull time minimal.
7
+ const TEST_IMAGE = "hello-world:latest";
8
+ describe("ensureImagePulled", () => {
9
+ let docker;
10
+ beforeAll(async () => {
11
+ const socket = resolveDockerSocket();
12
+ docker = new Docker({ socketPath: socket.socketPath });
13
+ await docker.ping();
14
+ });
15
+ it("pulls the image when it is not present locally", { timeout: 60000 }, async () => {
16
+ // Arrange: remove the image so it is definitely absent
17
+ try {
18
+ await docker.getImage(TEST_IMAGE).remove({ force: true });
19
+ }
20
+ catch {
21
+ // Already absent — fine
22
+ }
23
+ // Act
24
+ await ensureImagePulled(docker, TEST_IMAGE);
25
+ // Assert: image must now be inspectable
26
+ const info = await docker.getImage(TEST_IMAGE).inspect();
27
+ expect(info.RepoTags).toContain(TEST_IMAGE);
28
+ });
29
+ it("rejects with an error when the image does not exist in the registry", { timeout: 30000 }, async () => {
30
+ await expect(ensureImagePulled(docker, "ghcr.io/redwoodjs/agent-ci-does-not-exist:latest")).rejects.toThrow("Failed to pull Docker image 'ghcr.io/redwoodjs/agent-ci-does-not-exist:latest'");
31
+ });
32
+ it("does nothing when the image is already present", async () => {
33
+ // Arrange: ensure the image is present (previous test or pre-cached)
34
+ await ensureImagePulled(docker, TEST_IMAGE);
35
+ // Act: calling again must not throw
36
+ await expect(ensureImagePulled(docker, TEST_IMAGE)).resolves.toBeUndefined();
37
+ });
38
+ });
@@ -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(() => {
@@ -10,25 +10,48 @@ function formatDuration(ms) {
10
10
  return rem > 0 ? `${m}m ${rem}s` : `${m}m`;
11
11
  }
12
12
  // ─── Failures-first summary (emitted after all jobs complete) ─────────────────
13
+ function getErrorContent(f) {
14
+ if (f.failedStepLogPath && fs.existsSync(f.failedStepLogPath)) {
15
+ return fs.readFileSync(f.failedStepLogPath, "utf-8");
16
+ }
17
+ if (f.lastOutputLines && f.lastOutputLines.length > 0) {
18
+ return f.lastOutputLines.join("\n") + "\n";
19
+ }
20
+ return "";
21
+ }
22
+ function formatFailureHeader(f) {
23
+ if (f.failedStep) {
24
+ return ` ✗ ${f.workflow} > ${f.taskId} > "${f.failedStep}"`;
25
+ }
26
+ return ` ✗ ${f.workflow} > ${f.taskId}`;
27
+ }
13
28
  export function printSummary(results, runDir) {
14
29
  const failures = results.filter((r) => !r.succeeded);
15
30
  const passes = results.filter((r) => r.succeeded);
16
31
  const totalMs = results.reduce((sum, r) => sum + r.durationMs, 0);
17
32
  if (failures.length > 0) {
18
33
  process.stdout.write("\n━━━ FAILURES ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n");
34
+ // Group failures by error content to avoid repeating identical errors
35
+ const groups = [];
36
+ const seen = new Map();
19
37
  for (const f of failures) {
20
- if (f.failedStep) {
21
- process.stdout.write(` ✗ ${f.workflow} > ${f.taskId} > "${f.failedStep}"\n`);
38
+ const content = getErrorContent(f);
39
+ const existing = seen.get(content);
40
+ if (existing) {
41
+ existing.failures.push(f);
22
42
  }
23
43
  else {
24
- process.stdout.write(` ✗ ${f.workflow} > ${f.taskId}\n`);
44
+ const group = { failures: [f], errorContent: content };
45
+ groups.push(group);
46
+ seen.set(content, group);
25
47
  }
26
- if (f.failedStepLogPath && fs.existsSync(f.failedStepLogPath)) {
27
- const content = fs.readFileSync(f.failedStepLogPath, "utf-8");
28
- process.stdout.write("\n" + content);
48
+ }
49
+ for (const group of groups) {
50
+ for (const f of group.failures) {
51
+ process.stdout.write(formatFailureHeader(f) + "\n");
29
52
  }
30
- else if (f.lastOutputLines && f.lastOutputLines.length > 0) {
31
- process.stdout.write("\n" + f.lastOutputLines.join("\n") + "\n");
53
+ if (group.errorContent) {
54
+ process.stdout.write("\n" + group.errorContent);
32
55
  }
33
56
  process.stdout.write("\n");
34
57
  }
@@ -65,6 +65,50 @@ describe("printSummary", () => {
65
65
  ]);
66
66
  expect(output).toContain('✗ retry-proof.yml > test > "Run assertion test"');
67
67
  });
68
+ it("deduplicates failures with identical error content", () => {
69
+ printSummary([
70
+ makeResult({
71
+ taskId: "test (1)",
72
+ failedStep: "[Job startup failed]",
73
+ lastOutputLines: ["Missing secrets"],
74
+ }),
75
+ makeResult({
76
+ taskId: "test (2)",
77
+ failedStep: "[Job startup failed]",
78
+ lastOutputLines: ["Missing secrets"],
79
+ }),
80
+ makeResult({
81
+ taskId: "test (3)",
82
+ failedStep: "[Job startup failed]",
83
+ lastOutputLines: ["Missing secrets"],
84
+ }),
85
+ ]);
86
+ // Error content should appear only once
87
+ const matches = output.match(/Missing secrets/g);
88
+ expect(matches).toHaveLength(1);
89
+ // All job headers should still appear
90
+ expect(output).toContain('test (1) > "[Job startup failed]"');
91
+ expect(output).toContain('test (2) > "[Job startup failed]"');
92
+ expect(output).toContain('test (3) > "[Job startup failed]"');
93
+ // Summary should show correct count
94
+ expect(output).toContain("3 failed");
95
+ });
96
+ it("keeps distinct errors separate", () => {
97
+ printSummary([
98
+ makeResult({
99
+ taskId: "build",
100
+ failedStep: "Compile",
101
+ lastOutputLines: ["syntax error"],
102
+ }),
103
+ makeResult({
104
+ taskId: "lint",
105
+ failedStep: "ESLint",
106
+ lastOutputLines: ["unused variable"],
107
+ }),
108
+ ]);
109
+ expect(output).toContain("syntax error");
110
+ expect(output).toContain("unused variable");
111
+ });
68
112
  it("shows pass count in summary for a successful run", () => {
69
113
  printSummary([makeResult({ succeeded: true })]);
70
114
  expect(output).toContain("✓ 1 passed");
@@ -142,22 +142,22 @@ export function renderRunState(state) {
142
142
  const totalJobs = state.workflows.reduce((sum, wf) => sum + wf.jobs.length, 0);
143
143
  const singleJobMode = state.workflows.length === 1 && totalJobs === 1;
144
144
  const roots = [];
145
- let pausedSingleJob;
145
+ let pausedJob;
146
146
  for (const wf of state.workflows) {
147
147
  const children = [];
148
148
  for (const job of wf.jobs) {
149
149
  children.push(...buildJobNodes(job, singleJobMode));
150
- // Capture the first paused job for single-job trailing output
151
- if (singleJobMode && job.status === "paused" && !pausedSingleJob) {
152
- pausedSingleJob = job;
150
+ // Capture the first paused job for trailing output
151
+ if (job.status === "paused" && !pausedJob) {
152
+ pausedJob = job;
153
153
  }
154
154
  }
155
155
  roots.push({ label: path.basename(wf.path), children });
156
156
  }
157
157
  let output = renderTree(roots);
158
- // ── Single-job pause: append last output + retry/abort hints below tree ────
159
- if (pausedSingleJob) {
160
- const { lastOutputLines, runnerId } = pausedSingleJob;
158
+ // ── Paused job: append last output + retry/abort hints below tree ──────────
159
+ if (pausedJob) {
160
+ const { lastOutputLines, runnerId } = pausedJob;
161
161
  if (lastOutputLines && lastOutputLines.length > 0) {
162
162
  output += `\n\n ${DIM}Last output:${RESET}`;
163
163
  for (const line of lastOutputLines) {
@@ -376,11 +376,54 @@ describe("renderRunState", () => {
376
376
  ],
377
377
  });
378
378
  const output = renderRunState(state);
379
- // Retry hint is a child node (not trailing output like single-job mode)
379
+ // Retry hint is a child node in the tree
380
380
  expect(output).toContain("↻ retry: agent-ci retry --runner agent-ci-5-j2");
381
- // No trailing "To retry:" / "To abort:" lines in multi-job mode
382
- expect(output).not.toContain("↻ To retry:");
383
- expect(output).not.toContain("■ To abort:");
381
+ // Trailing "To retry:" / "To abort:" lines also shown in multi-job mode
382
+ expect(output).toContain("↻ To retry:");
383
+ expect(output).toContain("■ To abort:");
384
+ });
385
+ it("shows last output lines for paused job in multi-job mode", () => {
386
+ const state = makeState({
387
+ workflows: [
388
+ {
389
+ id: "ci.yml",
390
+ path: "/repo/.github/workflows/ci.yml",
391
+ status: "running",
392
+ jobs: [
393
+ {
394
+ id: "build",
395
+ runnerId: "agent-ci-5-j1",
396
+ status: "completed",
397
+ durationMs: 5000,
398
+ steps: [],
399
+ },
400
+ {
401
+ id: "test",
402
+ runnerId: "agent-ci-5-j2",
403
+ status: "paused",
404
+ pausedAtStep: "Run tests",
405
+ pausedAtMs: "1970-01-01T00:00:05.000Z",
406
+ attempt: 1,
407
+ lastOutputLines: ["FAIL src/app.test.ts", " Expected: true", " Received: false"],
408
+ bootDurationMs: 1000,
409
+ steps: [
410
+ {
411
+ name: "Run tests",
412
+ index: 1,
413
+ status: "paused",
414
+ startedAt: "1970-01-01T00:00:03.000Z",
415
+ },
416
+ ],
417
+ },
418
+ ],
419
+ },
420
+ ],
421
+ });
422
+ const output = renderRunState(state);
423
+ expect(output).toContain("Last output:");
424
+ expect(output).toContain("FAIL src/app.test.ts");
425
+ expect(output).toContain("Expected: true");
426
+ expect(output).toContain("Received: false");
384
427
  });
385
428
  });
386
429
  describe("multi-workflow (--all mode)", () => {
@@ -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);
@@ -0,0 +1,64 @@
1
+ import { execSync } from "child_process";
2
+ import path from "path";
3
+ import fs from "fs";
4
+ /**
5
+ * Compute a SHA that represents the current dirty working-tree state, as if
6
+ * it were committed. Uses a temporary index + `git write-tree` /
7
+ * `git commit-tree` so no refs are moved and real history is untouched.
8
+ *
9
+ * Returns `undefined` when the tree is clean (no uncommitted changes).
10
+ */
11
+ export function computeDirtySha(repoRoot) {
12
+ try {
13
+ // Quick check: anything dirty?
14
+ const status = execSync("git status --porcelain", {
15
+ cwd: repoRoot,
16
+ stdio: "pipe",
17
+ })
18
+ .toString()
19
+ .trim();
20
+ if (!status) {
21
+ return undefined;
22
+ }
23
+ const gitDir = execSync("git rev-parse --git-dir", {
24
+ cwd: repoRoot,
25
+ stdio: "pipe",
26
+ })
27
+ .toString()
28
+ .trim();
29
+ const absoluteGitDir = path.isAbsolute(gitDir) ? gitDir : path.join(repoRoot, gitDir);
30
+ const tmpIndex = path.join(absoluteGitDir, `index-agent-ci-${Date.now()}`);
31
+ try {
32
+ // Seed the temp index from the real one so we start from the current staging area.
33
+ fs.copyFileSync(path.join(absoluteGitDir, "index"), tmpIndex);
34
+ const env = { ...process.env, GIT_INDEX_FILE: tmpIndex };
35
+ // Stage everything (tracked + untracked, respecting .gitignore) into the temp index.
36
+ execSync("git add -A", { cwd: repoRoot, stdio: "pipe", env });
37
+ // Write a tree object from the temp index.
38
+ const tree = execSync("git write-tree", {
39
+ cwd: repoRoot,
40
+ stdio: "pipe",
41
+ env,
42
+ })
43
+ .toString()
44
+ .trim();
45
+ // Create an ephemeral commit object parented on HEAD — no ref is updated.
46
+ const sha = execSync(`git commit-tree ${tree} -p HEAD -m "agent-ci: dirty working tree"`, {
47
+ cwd: repoRoot,
48
+ stdio: "pipe",
49
+ })
50
+ .toString()
51
+ .trim();
52
+ return sha;
53
+ }
54
+ finally {
55
+ try {
56
+ fs.unlinkSync(tmpIndex);
57
+ }
58
+ catch { }
59
+ }
60
+ }
61
+ catch {
62
+ return undefined;
63
+ }
64
+ }
@@ -0,0 +1,101 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { execSync } from "child_process";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import os from "node:os";
6
+ import { computeDirtySha } from "./dirty-sha.js";
7
+ describe("computeDirtySha", () => {
8
+ let repoDir;
9
+ beforeEach(() => {
10
+ repoDir = fs.mkdtempSync(path.join(os.tmpdir(), "dirty-sha-test-"));
11
+ execSync("git init", { cwd: repoDir, stdio: "pipe" });
12
+ execSync('git config user.name "test"', { cwd: repoDir, stdio: "pipe" });
13
+ execSync('git config user.email "test@test.com"', { cwd: repoDir, stdio: "pipe" });
14
+ // Create an initial commit so HEAD exists
15
+ fs.writeFileSync(path.join(repoDir, "initial.txt"), "initial");
16
+ execSync("git add -A && git commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
17
+ });
18
+ afterEach(() => {
19
+ fs.rmSync(repoDir, { recursive: true, force: true });
20
+ });
21
+ it("returns undefined for a clean working tree", () => {
22
+ expect(computeDirtySha(repoDir)).toBeUndefined();
23
+ });
24
+ it("returns a SHA when tracked files are modified", () => {
25
+ fs.writeFileSync(path.join(repoDir, "initial.txt"), "modified");
26
+ const sha = computeDirtySha(repoDir);
27
+ expect(sha).toBeDefined();
28
+ expect(sha).toMatch(/^[0-9a-f]{40}$/);
29
+ });
30
+ it("returns a SHA when untracked files are present", () => {
31
+ fs.writeFileSync(path.join(repoDir, "untracked.txt"), "new file");
32
+ const sha = computeDirtySha(repoDir);
33
+ expect(sha).toBeDefined();
34
+ expect(sha).toMatch(/^[0-9a-f]{40}$/);
35
+ });
36
+ it("returns a different SHA for different dirty states", () => {
37
+ fs.writeFileSync(path.join(repoDir, "a.txt"), "content a");
38
+ const sha1 = computeDirtySha(repoDir);
39
+ // Stage and commit a.txt, then modify differently
40
+ execSync("git add -A && git commit -m 'add a'", { cwd: repoDir, stdio: "pipe" });
41
+ fs.writeFileSync(path.join(repoDir, "a.txt"), "content b");
42
+ const sha2 = computeDirtySha(repoDir);
43
+ expect(sha1).toBeDefined();
44
+ expect(sha2).toBeDefined();
45
+ expect(sha1).not.toBe(sha2);
46
+ });
47
+ it("does not move HEAD or create refs", () => {
48
+ const headBefore = execSync("git rev-parse HEAD", { cwd: repoDir, stdio: "pipe" })
49
+ .toString()
50
+ .trim();
51
+ const refsBefore = execSync("git for-each-ref", { cwd: repoDir, stdio: "pipe" })
52
+ .toString()
53
+ .trim();
54
+ fs.writeFileSync(path.join(repoDir, "dirty.txt"), "dirty");
55
+ computeDirtySha(repoDir);
56
+ const headAfter = execSync("git rev-parse HEAD", { cwd: repoDir, stdio: "pipe" })
57
+ .toString()
58
+ .trim();
59
+ const refsAfter = execSync("git for-each-ref", { cwd: repoDir, stdio: "pipe" })
60
+ .toString()
61
+ .trim();
62
+ expect(headAfter).toBe(headBefore);
63
+ expect(refsAfter).toBe(refsBefore);
64
+ });
65
+ it("does not modify the real index", () => {
66
+ // Stage nothing, but have an untracked file
67
+ fs.writeFileSync(path.join(repoDir, "untracked.txt"), "new");
68
+ const statusBefore = execSync("git status --porcelain", { cwd: repoDir, stdio: "pipe" })
69
+ .toString()
70
+ .trim();
71
+ computeDirtySha(repoDir);
72
+ const statusAfter = execSync("git status --porcelain", { cwd: repoDir, stdio: "pipe" })
73
+ .toString()
74
+ .trim();
75
+ expect(statusAfter).toBe(statusBefore);
76
+ });
77
+ it("returns a valid commit object parented on HEAD", () => {
78
+ fs.writeFileSync(path.join(repoDir, "dirty.txt"), "content");
79
+ const sha = computeDirtySha(repoDir);
80
+ expect(sha).toBeDefined();
81
+ // Verify it's a valid commit object
82
+ const type = execSync(`git cat-file -t ${sha}`, { cwd: repoDir, stdio: "pipe" })
83
+ .toString()
84
+ .trim();
85
+ expect(type).toBe("commit");
86
+ // Read the parent SHA directly from the commit object (bypasses any git shims
87
+ // that intercept `git rev-parse HEAD` in CI environments).
88
+ const commitBody = execSync(`git cat-file -p ${sha}`, {
89
+ cwd: repoDir,
90
+ stdio: "pipe",
91
+ }).toString();
92
+ const parentMatch = commitBody.match(/^parent ([0-9a-f]{40})$/m);
93
+ expect(parentMatch).not.toBeNull();
94
+ // Read HEAD the same way to compare — resolve the ref from .git/HEAD.
95
+ const headContent = fs.readFileSync(path.join(repoDir, ".git", "HEAD"), "utf-8").trim();
96
+ const headSha = headContent.startsWith("ref: ")
97
+ ? fs.readFileSync(path.join(repoDir, ".git", headContent.slice(5)), "utf-8").trim()
98
+ : headContent;
99
+ expect(parentMatch[1]).toBe(headSha);
100
+ });
101
+ });
@@ -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,11 +12,12 @@ 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";
19
19
  import { buildJobResult, isJobSuccessful } from "./result-builder.js";
20
+ import { ensureImagePulled } from "../docker/image-pull.js";
20
21
  import { wrapJobSteps, appendOutputCaptureStep } from "./step-wrapper.js";
21
22
  import { syncWorkspaceForRetry } from "./sync.js";
22
23
  // ─── Docker setup ─────────────────────────────────────────────────────────────
@@ -184,13 +185,23 @@ export async function executeLocalJob(job, options) {
184
185
  writeJobMetadata({ logDir, containerName, job });
185
186
  // Open debug stream to capture raw container output
186
187
  const debugStream = fs.createWriteStream(debugLogPath);
188
+ // Hoisted for cleanup in `finally` — assigned inside the try block.
189
+ let container = null;
190
+ let serviceCtx;
191
+ const hostRunnerDir = path.resolve(runDir, "runner");
187
192
  // Signal handler: ensure cleanup runs even when killed.
188
193
  // Do NOT call process.exit() here — multiple jobs register handlers concurrently,
189
194
  // and an early exit would prevent other jobs' handlers from cleaning up their containers.
190
195
  // killRunnerContainers already handles the runner, its svc-* sidecars, and the network.
191
196
  const signalCleanup = () => {
192
197
  killRunnerContainers(containerName);
193
- for (const d of [dirs.containerWorkDir, dirs.shimsDir, dirs.signalsDir, dirs.diagDir]) {
198
+ for (const d of [
199
+ dirs.containerWorkDir,
200
+ dirs.shimsDir,
201
+ dirs.signalsDir,
202
+ dirs.diagDir,
203
+ hostRunnerDir,
204
+ ]) {
194
205
  try {
195
206
  fs.rmSync(d, { recursive: true, force: true });
196
207
  }
@@ -199,10 +210,6 @@ export async function executeLocalJob(job, options) {
199
210
  };
200
211
  process.on("SIGINT", signalCleanup);
201
212
  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
213
  try {
207
214
  // 1. Seed the job to Local DTU
208
215
  const [githubOwner, githubRepoName] = (job.githubRepo || "").split("/");
@@ -243,8 +250,7 @@ export async function executeLocalJob(job, options) {
243
250
  // 4. Write git shim BEFORE container start so the entrypoint can install it
244
251
  // immediately. On Linux, prepareWorkspace (rsync) is slow enough that the
245
252
  // container entrypoint would race ahead and find an empty shims dir.
246
- const fakeSha = computeFakeSha(job.headSha);
247
- writeGitShim(dirs.shimsDir, fakeSha);
253
+ writeGitShim(dirs.shimsDir, job.realHeadSha);
248
254
  // Prepare workspace files in parallel with container setup
249
255
  const workspacePrepStart = Date.now();
250
256
  const workspacePrepPromise = (async () => {
@@ -300,6 +306,10 @@ export async function executeLocalJob(job, options) {
300
306
  const hostRunnerSeedDir = path.resolve(getWorkingDirectory(), "runner");
301
307
  const useDirectContainer = !!job.container;
302
308
  const containerImage = useDirectContainer ? job.container.image : IMAGE;
309
+ // Pull the runner image if not cached locally. Required in both modes:
310
+ // default mode uses it directly as the container image; direct-container
311
+ // mode uses it to seed the runner binary. Fixes: github.com/redwoodjs/agent-ci/issues/203
312
+ await ensureImagePulled(getDocker(), IMAGE);
303
313
  if (useDirectContainer) {
304
314
  await fs.promises.mkdir(hostRunnerSeedDir, { recursive: true });
305
315
  const markerFile = path.join(hostRunnerSeedDir, ".seeded");
@@ -449,6 +459,7 @@ export async function executeLocalJob(job, options) {
449
459
  warmModulesDir: dirs.warmModulesDir,
450
460
  hostRunnerDir,
451
461
  useDirectContainer,
462
+ githubRepo,
452
463
  dockerSocketPath: getDockerSocket().socketPath || undefined,
453
464
  });
454
465
  const containerCmd = buildContainerCmd({
@@ -463,6 +474,9 @@ export async function executeLocalJob(job, options) {
463
474
  container = await getDocker().createContainer({
464
475
  Image: containerImage,
465
476
  name: containerName,
477
+ Labels: {
478
+ "agent-ci.pid": String(process.pid),
479
+ },
466
480
  Env: containerEnv,
467
481
  ...(useDirectContainer ? { Entrypoint: ["bash"] } : {}),
468
482
  Cmd: containerCmd,
@@ -663,7 +663,7 @@ export function validateSecrets(filePath, taskName, secrets, secretsFilePath) {
663
663
  return;
664
664
  }
665
665
  throw new Error(`[Agent CI] Missing secrets required by workflow job "${taskName}".\n` +
666
- `Add the following to ${secretsFilePath}:\n\n` +
666
+ `Add the following to ${secretsFilePath} or set them as environment variables:\n\n` +
667
667
  missing.map((n) => `${n}=`).join("\n") +
668
668
  "\n");
669
669
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redwoodjs/agent-ci",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
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.1"
43
+ "dtu-github-actions": "0.9.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/dockerode": "^3.3.34",