@redwoodjs/agent-ci 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -245,6 +245,9 @@ async function run() {
245
245
  // One workflow = --all with a single entry.
246
246
  async function runWorkflows(options) {
247
247
  const { workflowPaths, sha, pauseOnFailure, noMatrix = false } = options;
248
+ // Suppress EventEmitter MaxListenersExceeded warnings when running many
249
+ // parallel jobs (each job adds SIGINT/SIGTERM listeners).
250
+ process.setMaxListeners(0);
248
251
  // Create the run state store — single source of truth for all progress
249
252
  const runId = `run-${Date.now()}`;
250
253
  const storeFilePath = path.join(getWorkingDirectory(), "runs", runId, "run-state.json");
@@ -322,6 +325,15 @@ async function runWorkflows(options) {
322
325
  }
323
326
  }, 80);
324
327
  }
328
+ // Top-level signal handler: exit the process after per-job handlers have
329
+ // cleaned up their containers. All listeners fire synchronously in
330
+ // registration order, so we defer the exit to let per-job handlers
331
+ // (registered later in local-job.ts) run first.
332
+ const exitOnSignal = () => {
333
+ setTimeout(() => process.exit(1), 0);
334
+ };
335
+ process.on("SIGINT", exitOnSignal);
336
+ process.on("SIGTERM", exitOnSignal);
325
337
  try {
326
338
  const allResults = [];
327
339
  if (workflowPaths.length === 1) {
@@ -347,6 +359,11 @@ async function runWorkflows(options) {
347
359
  catch { }
348
360
  const warmModulesDir = path.resolve(getWorkingDirectory(), "cache", "warm-modules", repoSlug, lockfileHash);
349
361
  const warm = isWarmNodeModules(warmModulesDir);
362
+ // Pre-allocate unique run numbers so parallel workflows don't collide.
363
+ // Each workflow gets its own baseRunNum (e.g. 306, 307, 308) so their
364
+ // job suffixes (-j1, -j2, -j3) never produce duplicate container names.
365
+ const baseRunNum = getNextLogNum("agent-ci");
366
+ const runNums = workflowPaths.map((_, i) => baseRunNum + i);
350
367
  if (!warm && workflowPaths.length > 1) {
351
368
  // Cold cache — run first workflow serially to populate warm modules,
352
369
  // then launch the rest in parallel.
@@ -356,11 +373,17 @@ async function runWorkflows(options) {
356
373
  pauseOnFailure,
357
374
  noMatrix,
358
375
  store,
376
+ baseRunNum: runNums[0],
359
377
  });
360
378
  allResults.push(...firstResults);
361
- const settled = await Promise.allSettled(workflowPaths
362
- .slice(1)
363
- .map((wf) => handleWorkflow({ workflowPath: wf, sha, pauseOnFailure, noMatrix, store })));
379
+ const settled = await Promise.allSettled(workflowPaths.slice(1).map((wf, i) => handleWorkflow({
380
+ workflowPath: wf,
381
+ sha,
382
+ pauseOnFailure,
383
+ noMatrix,
384
+ store,
385
+ baseRunNum: runNums[i + 1],
386
+ })));
364
387
  for (const s of settled) {
365
388
  if (s.status === "fulfilled") {
366
389
  allResults.push(...s.value);
@@ -371,7 +394,14 @@ async function runWorkflows(options) {
371
394
  }
372
395
  }
373
396
  else {
374
- const settled = await Promise.allSettled(workflowPaths.map((wf) => handleWorkflow({ workflowPath: wf, sha, pauseOnFailure, noMatrix, store })));
397
+ const settled = await Promise.allSettled(workflowPaths.map((wf, i) => handleWorkflow({
398
+ workflowPath: wf,
399
+ sha,
400
+ pauseOnFailure,
401
+ noMatrix,
402
+ store,
403
+ baseRunNum: runNums[i],
404
+ })));
375
405
  for (const s of settled) {
376
406
  if (s.status === "fulfilled") {
377
407
  allResults.push(...s.value);
@@ -386,6 +416,8 @@ async function runWorkflows(options) {
386
416
  return allResults;
387
417
  }
388
418
  finally {
419
+ process.removeListener("SIGINT", exitOnSignal);
420
+ process.removeListener("SIGTERM", exitOnSignal);
389
421
  if (renderInterval) {
390
422
  clearInterval(renderInterval);
391
423
  }
@@ -496,7 +528,7 @@ async function handleWorkflow(options) {
496
528
  const warmModulesDir = path.resolve(getWorkingDirectory(), "cache", "warm-modules", repoSlug, lockfileHash);
497
529
  let warm = isWarmNodeModules(warmModulesDir);
498
530
  // Naming convention: agent-ci-<N>[-j<idx>][-m<shardIdx>]
499
- const baseRunNum = getNextLogNum("agent-ci");
531
+ const baseRunNum = options.baseRunNum ?? getNextLogNum("agent-ci");
500
532
  let globalIdx = 0;
501
533
  const buildJob = (ej) => {
502
534
  const secrets = loadMachineSecrets(repoRoot);
@@ -703,8 +735,8 @@ async function handleWorkflow(options) {
703
735
  return allResults;
704
736
  }
705
737
  catch (error) {
706
- console.error(`[Agent CI] Failed to trigger run: ${error.message}`);
707
- return [];
738
+ console.error(`[Agent CI] Failed to run workflow ${path.basename(workflowPath)}: ${error.message}`);
739
+ throw error;
708
740
  }
709
741
  }
710
742
  // ─── Utilities ────────────────────────────────────────────────────────────────
@@ -11,6 +11,7 @@ export function buildContainerEnv(opts) {
11
11
  `GITHUB_API_URL=${dockerApiUrl}`,
12
12
  `GITHUB_SERVER_URL=${repoUrl}`,
13
13
  `GITHUB_REPOSITORY=${githubRepo}`,
14
+ `AGENT_CI_LOCAL=true`,
14
15
  `AGENT_CI_LOCAL_SYNC=true`,
15
16
  `AGENT_CI_HEAD_SHA=${headSha || "HEAD"}`,
16
17
  `AGENT_CI_DTU_HOST=${dtuHost}`,
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, afterEach, vi } from "vitest";
1
+ import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
2
2
  import fs from "node:fs";
3
3
  afterEach(() => {
4
4
  vi.restoreAllMocks();
@@ -19,6 +19,7 @@ describe("buildContainerEnv", () => {
19
19
  });
20
20
  expect(env).toContain("RUNNER_NAME=runner-1");
21
21
  expect(env).toContain("GITHUB_REPOSITORY=org/repo");
22
+ expect(env).toContain("AGENT_CI_LOCAL=true");
22
23
  expect(env).toContain("AGENT_CI_HEAD_SHA=abc123");
23
24
  expect(env).toContain("FORCE_COLOR=1");
24
25
  // Should NOT include root-mode vars for standard container
@@ -183,6 +184,10 @@ describe("resolveDockerApiUrl", () => {
183
184
  });
184
185
  describe("resolveDtuHost", () => {
185
186
  const originalBridgeGateway = process.env.AGENT_CI_DOCKER_BRIDGE_GATEWAY;
187
+ const originalDtuHost = process.env.AGENT_CI_DTU_HOST;
188
+ beforeEach(() => {
189
+ delete process.env.AGENT_CI_DTU_HOST;
190
+ });
186
191
  afterEach(() => {
187
192
  if (originalBridgeGateway === undefined) {
188
193
  delete process.env.AGENT_CI_DOCKER_BRIDGE_GATEWAY;
@@ -190,8 +195,15 @@ describe("resolveDtuHost", () => {
190
195
  else {
191
196
  process.env.AGENT_CI_DOCKER_BRIDGE_GATEWAY = originalBridgeGateway;
192
197
  }
198
+ if (originalDtuHost === undefined) {
199
+ delete process.env.AGENT_CI_DTU_HOST;
200
+ }
201
+ else {
202
+ process.env.AGENT_CI_DTU_HOST = originalDtuHost;
203
+ }
193
204
  });
194
205
  it("uses host alias when available outside Docker", async () => {
206
+ delete process.env.AGENT_CI_DTU_HOST;
195
207
  const { resolveDtuHost } = await import("./container-config.js");
196
208
  const originalExistsSync = fs.existsSync;
197
209
  vi.spyOn(fs, "existsSync").mockImplementation((filePath) => {
@@ -203,6 +215,7 @@ describe("resolveDtuHost", () => {
203
215
  await expect(resolveDtuHost()).resolves.toBe("host.docker.internal");
204
216
  });
205
217
  it("uses configured bridge gateway outside Docker when provided", async () => {
218
+ delete process.env.AGENT_CI_DTU_HOST;
206
219
  const { resolveDtuHost } = await import("./container-config.js");
207
220
  const originalExistsSync = fs.existsSync;
208
221
  vi.spyOn(fs, "existsSync").mockImplementation((filePath) => {
@@ -215,6 +228,7 @@ describe("resolveDtuHost", () => {
215
228
  await expect(resolveDtuHost()).resolves.toBe("10.10.0.1");
216
229
  });
217
230
  it("uses host alias outside Docker when no gateway override is configured", async () => {
231
+ delete process.env.AGENT_CI_DTU_HOST;
218
232
  const { resolveDtuHost } = await import("./container-config.js");
219
233
  const originalExistsSync = fs.existsSync;
220
234
  vi.spyOn(fs, "existsSync").mockImplementation((filePath) => {
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Reproduction for https://github.com/redwoodjs/agent-ci/issues/126
3
+ *
4
+ * When running inside Docker (via agent-ci), AGENT_CI_DTU_HOST is set in the
5
+ * container environment. The resolveDtuHost tests delete it, but this test
6
+ * verifies the mock and env var interaction actually works in that scenario.
7
+ */
8
+ import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
9
+ import fs from "node:fs";
10
+ afterEach(() => {
11
+ vi.restoreAllMocks();
12
+ });
13
+ describe("issue-126 reproduction: resolveDtuHost inside Docker", () => {
14
+ const originalBridgeGateway = process.env.AGENT_CI_DOCKER_BRIDGE_GATEWAY;
15
+ const originalDtuHost = process.env.AGENT_CI_DTU_HOST;
16
+ beforeEach(() => {
17
+ delete process.env.AGENT_CI_DTU_HOST;
18
+ });
19
+ afterEach(() => {
20
+ if (originalBridgeGateway === undefined) {
21
+ delete process.env.AGENT_CI_DOCKER_BRIDGE_GATEWAY;
22
+ }
23
+ else {
24
+ process.env.AGENT_CI_DOCKER_BRIDGE_GATEWAY = originalBridgeGateway;
25
+ }
26
+ if (originalDtuHost === undefined) {
27
+ delete process.env.AGENT_CI_DTU_HOST;
28
+ }
29
+ else {
30
+ process.env.AGENT_CI_DTU_HOST = originalDtuHost;
31
+ }
32
+ });
33
+ it("reports environment state", async () => {
34
+ console.log("--- issue-126 repro diagnostics ---");
35
+ console.log("/.dockerenv exists:", fs.existsSync("/.dockerenv"));
36
+ console.log("AGENT_CI_DTU_HOST:", process.env.AGENT_CI_DTU_HOST ?? "(unset)");
37
+ console.log("AGENT_CI_DOCKER_BRIDGE_GATEWAY:", process.env.AGENT_CI_DOCKER_BRIDGE_GATEWAY ?? "(unset)");
38
+ expect(true).toBe(true);
39
+ });
40
+ it("mock fs.existsSync intercepts /.dockerenv check", async () => {
41
+ const realResult = fs.existsSync("/.dockerenv");
42
+ console.log("Real fs.existsSync('/.dockerenv'):", realResult);
43
+ const originalExistsSync = fs.existsSync;
44
+ vi.spyOn(fs, "existsSync").mockImplementation((filePath) => {
45
+ if (filePath === "/.dockerenv") {
46
+ return false;
47
+ }
48
+ return originalExistsSync(filePath);
49
+ });
50
+ const mockedResult = fs.existsSync("/.dockerenv");
51
+ console.log("Mocked fs.existsSync('/.dockerenv'):", mockedResult);
52
+ expect(mockedResult).toBe(false);
53
+ });
54
+ it("resolveDtuHost uses bridge gateway when mock is active (the failing test)", async () => {
55
+ // Simulate agent-ci Docker env: AGENT_CI_DTU_HOST was set but we deleted it
56
+ delete process.env.AGENT_CI_DTU_HOST;
57
+ const { resolveDtuHost } = await import("./container-config.js");
58
+ const originalExistsSync = fs.existsSync;
59
+ vi.spyOn(fs, "existsSync").mockImplementation((filePath) => {
60
+ if (filePath === "/.dockerenv") {
61
+ return false;
62
+ }
63
+ return originalExistsSync(filePath);
64
+ });
65
+ process.env.AGENT_CI_DOCKER_BRIDGE_GATEWAY = "10.10.0.1";
66
+ // This is the assertion that was failing: expected '10.10.0.1' but got 'host.docker.internal'
67
+ const result = await resolveDtuHost();
68
+ console.log("resolveDtuHost() returned:", result);
69
+ console.log("Expected: 10.10.0.1");
70
+ expect(result).toBe("10.10.0.1");
71
+ });
72
+ });
@@ -43,14 +43,31 @@ export function killRunnerContainers(runnerName) {
43
43
  }
44
44
  /**
45
45
  * Remove orphaned Docker resources left behind by previous runs:
46
- * 1. `agent-ci-net-*` networks with no connected containers
47
- * 2. Dangling volumes (anonymous volumes from service containers like MySQL)
46
+ * 1. Stopped `agent-ci-*` containers (runners + sidecars)
47
+ * 2. `agent-ci-net-*` networks with no connected containers
48
+ * 3. Dangling volumes (anonymous volumes from service containers like MySQL)
49
+ *
50
+ * Stopped containers must be removed first so their network references are
51
+ * released, allowing the network prune in step 2 to reclaim address pool capacity.
48
52
  *
49
53
  * Call this proactively before creating new resources to prevent Docker from
50
54
  * exhausting its address pool ("all predefined address pools have been fully subnetted").
51
55
  */
52
56
  export function pruneOrphanedDockerResources() {
53
- // 1. Remove orphaned agent-ci-net-* networks
57
+ // 1. Remove stopped agent-ci-* containers (runners + sidecars) so their
58
+ // network references are released before we try to prune networks.
59
+ try {
60
+ const stoppedIds = execSync(`docker ps -aq --filter "name=agent-ci-" --filter "status=exited"`, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
61
+ if (stoppedIds) {
62
+ execSync(`docker rm -f ${stoppedIds.split("\n").join(" ")}`, {
63
+ stdio: ["pipe", "pipe", "pipe"],
64
+ });
65
+ }
66
+ }
67
+ catch {
68
+ // Docker not reachable or no stopped containers — skip
69
+ }
70
+ // 2. Remove orphaned agent-ci-net-* networks
54
71
  try {
55
72
  const nets = execSync(`docker network ls -q --filter "name=agent-ci-net-"`, {
56
73
  encoding: "utf8",
@@ -74,7 +91,7 @@ export function pruneOrphanedDockerResources() {
74
91
  catch {
75
92
  // Docker not reachable — skip
76
93
  }
77
- // 2. Remove dangling volumes (anonymous volumes from service containers)
94
+ // 3. Remove dangling volumes (anonymous volumes from service containers)
78
95
  try {
79
96
  execSync(`docker volume prune -f`, {
80
97
  stdio: ["pipe", "pipe", "pipe"],
@@ -31,15 +31,44 @@ export function getNextLogNum(prefix) {
31
31
  });
32
32
  return nums.length > 0 ? Math.max(...nums) + 1 : 1;
33
33
  }
34
+ /**
35
+ * Atomically allocate a numbered run directory under `runs/`.
36
+ * Uses mkdirSync without `recursive` so that EEXIST signals a
37
+ * collision — the caller just increments and retries.
38
+ */
39
+ function allocateRunDir(prefix) {
40
+ const runsDir = getRunsDir();
41
+ let num = getNextLogNum(prefix);
42
+ for (;;) {
43
+ const name = `${prefix}-${num}`;
44
+ const runDir = path.join(runsDir, name);
45
+ try {
46
+ fs.mkdirSync(runDir); // atomic — fails with EEXIST on collision
47
+ return { num, name, runDir };
48
+ }
49
+ catch (err) {
50
+ if (err.code === "EEXIST") {
51
+ num++;
52
+ continue;
53
+ }
54
+ throw err;
55
+ }
56
+ }
57
+ }
34
58
  export function createLogContext(prefix, preferredName) {
35
59
  ensureLogDirs();
36
- let num = 0;
37
- let name = preferredName;
38
- if (!name) {
39
- num = getNextLogNum(prefix);
40
- name = `${prefix}-${num}`;
60
+ let num;
61
+ let name;
62
+ let runDir;
63
+ if (preferredName) {
64
+ num = 0;
65
+ name = preferredName;
66
+ runDir = path.join(getRunsDir(), name);
67
+ fs.mkdirSync(runDir, { recursive: true });
68
+ }
69
+ else {
70
+ ({ num, name, runDir } = allocateRunDir(prefix));
41
71
  }
42
- const runDir = path.join(getRunsDir(), name);
43
72
  const logDir = path.join(runDir, "logs");
44
73
  fs.mkdirSync(logDir, { recursive: true });
45
74
  return {
@@ -78,5 +78,45 @@ describe("Logger utilities", () => {
78
78
  const second = createLogContext("agent-ci");
79
79
  expect(second.num).toBe(first.num + 1);
80
80
  });
81
+ it("skips over a directory that already exists (race condition)", async () => {
82
+ const { setWorkingDirectory } = await import("./working-directory.js");
83
+ const { createLogContext } = await import("./logger.js");
84
+ setWorkingDirectory(tmpDir);
85
+ // Pre-create runs/agent-ci-1 to simulate another process winning the race
86
+ fs.mkdirSync(path.join(tmpDir, "runs", "agent-ci-1"), { recursive: true });
87
+ const ctx = createLogContext("agent-ci");
88
+ // Should have skipped 1 and landed on 2
89
+ expect(ctx.num).toBe(2);
90
+ expect(ctx.name).toBe("agent-ci-2");
91
+ expect(fs.existsSync(ctx.runDir)).toBe(true);
92
+ });
93
+ it("handles multiple consecutive collisions", async () => {
94
+ const { setWorkingDirectory } = await import("./working-directory.js");
95
+ const { createLogContext } = await import("./logger.js");
96
+ setWorkingDirectory(tmpDir);
97
+ // Pre-create 1, 2, and 3 to simulate several concurrent processes
98
+ fs.mkdirSync(path.join(tmpDir, "runs", "agent-ci-1"), { recursive: true });
99
+ fs.mkdirSync(path.join(tmpDir, "runs", "agent-ci-2"), { recursive: true });
100
+ fs.mkdirSync(path.join(tmpDir, "runs", "agent-ci-3"), { recursive: true });
101
+ const ctx = createLogContext("agent-ci");
102
+ expect(ctx.num).toBe(4);
103
+ expect(ctx.name).toBe("agent-ci-4");
104
+ expect(fs.existsSync(ctx.runDir)).toBe(true);
105
+ });
106
+ it("concurrent calls each get a unique directory", async () => {
107
+ const { setWorkingDirectory } = await import("./working-directory.js");
108
+ const { createLogContext } = await import("./logger.js");
109
+ setWorkingDirectory(tmpDir);
110
+ // Call createLogContext many times synchronously — each should get a unique dir
111
+ const results = Array.from({ length: 10 }, () => createLogContext("agent-ci"));
112
+ const names = results.map((r) => r.name);
113
+ const uniqueNames = new Set(names);
114
+ expect(uniqueNames.size).toBe(10);
115
+ // All directories should exist
116
+ for (const r of results) {
117
+ expect(fs.existsSync(r.runDir)).toBe(true);
118
+ expect(fs.existsSync(r.logDir)).toBe(true);
119
+ }
120
+ });
81
121
  });
82
122
  });
@@ -16,7 +16,7 @@ import { computeFakeSha, 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
- import { buildJobResult } from "./result-builder.js";
19
+ import { buildJobResult, isJobSuccessful } from "./result-builder.js";
20
20
  import { wrapJobSteps, appendOutputCaptureStep } from "./step-wrapper.js";
21
21
  import { syncWorkspaceForRetry } from "./sync.js";
22
22
  // ─── Docker setup ─────────────────────────────────────────────────────────────
@@ -158,6 +158,9 @@ export async function executeLocalJob(job, options) {
158
158
  // Open debug stream to capture raw container output
159
159
  const debugStream = fs.createWriteStream(debugLogPath);
160
160
  // Signal handler: ensure cleanup runs even when killed.
161
+ // Do NOT call process.exit() here — multiple jobs register handlers concurrently,
162
+ // and an early exit would prevent other jobs' handlers from cleaning up their containers.
163
+ // killRunnerContainers already handles the runner, its svc-* sidecars, and the network.
161
164
  const signalCleanup = () => {
162
165
  killRunnerContainers(containerName);
163
166
  for (const d of [dirs.containerWorkDir, dirs.shimsDir, dirs.signalsDir, dirs.diagDir]) {
@@ -166,10 +169,13 @@ export async function executeLocalJob(job, options) {
166
169
  }
167
170
  catch { }
168
171
  }
169
- process.exit(1);
170
172
  };
171
- process.once("SIGINT", signalCleanup);
172
- process.once("SIGTERM", signalCleanup);
173
+ process.on("SIGINT", signalCleanup);
174
+ process.on("SIGTERM", signalCleanup);
175
+ // Hoisted for cleanup in `finally` — assigned inside the try block.
176
+ let container = null;
177
+ let serviceCtx;
178
+ const hostRunnerDir = path.resolve(runDir, "runner");
173
179
  try {
174
180
  // 1. Seed the job to Local DTU
175
181
  const [githubOwner, githubRepoName] = (job.githubRepo || "").split("/");
@@ -183,6 +189,10 @@ export async function executeLocalJob(job, options) {
183
189
  : job.repository;
184
190
  const wrappedSteps = pauseOnFailure ? wrapJobSteps(job.steps ?? [], true) : job.steps;
185
191
  const seededSteps = appendOutputCaptureStep(wrappedSteps ?? []);
192
+ // Pin runnerName so the job goes to the runner-specific pool, not the
193
+ // shared generic pool where a runner from another concurrent workflow
194
+ // could steal it (see issue #103).
195
+ job.runnerName = containerName;
186
196
  t0 = Date.now();
187
197
  const seedResponse = await fetch(`${dtuUrl}/_dtu/seed`, {
188
198
  method: "POST",
@@ -249,7 +259,6 @@ export async function executeLocalJob(job, options) {
249
259
  // Ignore - container doesn't exist
250
260
  }
251
261
  // ── Service containers ────────────────────────────────────────────────────
252
- let serviceCtx;
253
262
  if (job.services && job.services.length > 0) {
254
263
  const svcStart = Date.now();
255
264
  debugRunner(`Starting ${job.services.length} service container(s)...`);
@@ -262,7 +271,6 @@ export async function executeLocalJob(job, options) {
262
271
  // ── Direct container injection ─────────────────────────────────────────────
263
272
  const hostWorkDir = dirs.containerWorkDir;
264
273
  const hostRunnerSeedDir = path.resolve(getWorkingDirectory(), "runner");
265
- const hostRunnerDir = path.resolve(runDir, "runner");
266
274
  const useDirectContainer = !!job.container;
267
275
  const containerImage = useDirectContainer ? job.container.image : IMAGE;
268
276
  if (useDirectContainer) {
@@ -420,7 +428,7 @@ export async function executeLocalJob(job, options) {
420
428
  });
421
429
  const extraHosts = resolveDockerExtraHosts(dtuHost);
422
430
  t0 = Date.now();
423
- const container = await docker.createContainer({
431
+ container = await docker.createContainer({
424
432
  Image: containerImage,
425
433
  name: containerName,
426
434
  Env: containerEnv,
@@ -706,7 +714,7 @@ export async function executeLocalJob(job, options) {
706
714
  waitResult = await container.wait();
707
715
  }
708
716
  const containerExitCode = waitResult.StatusCode;
709
- const jobSucceeded = lastFailedStep === null && containerExitCode === 0;
717
+ const jobSucceeded = isJobSuccessful({ lastFailedStep, containerExitCode, isBooting });
710
718
  // Update store with final exit code on failure
711
719
  if (!jobSucceeded) {
712
720
  store?.updateJob(containerName, {
@@ -714,28 +722,6 @@ export async function executeLocalJob(job, options) {
714
722
  });
715
723
  }
716
724
  await new Promise((resolve) => debugStream.end(resolve));
717
- // Cleanup
718
- try {
719
- await container.remove({ force: true });
720
- }
721
- catch {
722
- /* already removed */
723
- }
724
- if (serviceCtx) {
725
- await cleanupServiceContainers(docker, serviceCtx, (line) => debugRunner(line));
726
- }
727
- if (fs.existsSync(dirs.shimsDir)) {
728
- fs.rmSync(dirs.shimsDir, { recursive: true, force: true });
729
- }
730
- if (!pauseOnFailure && fs.existsSync(dirs.signalsDir)) {
731
- fs.rmSync(dirs.signalsDir, { recursive: true, force: true });
732
- }
733
- if (fs.existsSync(dirs.diagDir)) {
734
- fs.rmSync(dirs.diagDir, { recursive: true, force: true });
735
- }
736
- if (fs.existsSync(hostRunnerDir)) {
737
- fs.rmSync(hostRunnerDir, { recursive: true, force: true });
738
- }
739
725
  // Read step outputs captured by the DTU server via the runner's outputs API
740
726
  let stepOutputs = {};
741
727
  if (jobSucceeded) {
@@ -758,7 +744,6 @@ export async function executeLocalJob(job, options) {
758
744
  // processes haven't fully released file handles yet.
759
745
  }
760
746
  }
761
- await ephemeralDtu?.close().catch(() => { });
762
747
  return buildJobResult({
763
748
  containerName,
764
749
  job,
@@ -773,6 +758,29 @@ export async function executeLocalJob(job, options) {
773
758
  });
774
759
  }
775
760
  finally {
761
+ // Cleanup: always runs even when errors occur mid-run.
762
+ try {
763
+ await container?.remove({ force: true });
764
+ }
765
+ catch {
766
+ /* already removed */
767
+ }
768
+ if (serviceCtx) {
769
+ await cleanupServiceContainers(docker, serviceCtx, (line) => debugRunner(line));
770
+ }
771
+ if (fs.existsSync(dirs.shimsDir)) {
772
+ fs.rmSync(dirs.shimsDir, { recursive: true, force: true });
773
+ }
774
+ if (!pauseOnFailure && fs.existsSync(dirs.signalsDir)) {
775
+ fs.rmSync(dirs.signalsDir, { recursive: true, force: true });
776
+ }
777
+ if (fs.existsSync(dirs.diagDir)) {
778
+ fs.rmSync(dirs.diagDir, { recursive: true, force: true });
779
+ }
780
+ if (fs.existsSync(hostRunnerDir)) {
781
+ fs.rmSync(hostRunnerDir, { recursive: true, force: true });
782
+ }
783
+ await ephemeralDtu?.close().catch(() => { });
776
784
  process.removeListener("SIGINT", signalCleanup);
777
785
  process.removeListener("SIGTERM", signalCleanup);
778
786
  }
@@ -189,6 +189,18 @@ export function resolveJobOutputs(outputDefs, stepOutputs) {
189
189
  }
190
190
  return result;
191
191
  }
192
+ // ─── Job success determination ────────────────────────────────────────────────
193
+ /**
194
+ * Determine whether a job succeeded based on container exit state and
195
+ * whether the runner ever contacted the DTU.
196
+ *
197
+ * `isBooting` stays `true` when the runner never sent any timeline entries —
198
+ * it started but couldn't reach the DTU or crashed before executing any steps.
199
+ * That must be treated as a failure regardless of exit code.
200
+ */
201
+ export function isJobSuccessful(opts) {
202
+ return opts.lastFailedStep === null && opts.containerExitCode === 0 && !opts.isBooting;
203
+ }
192
204
  /**
193
205
  * Build the structured `JobResult` from container exit state and timeline data.
194
206
  */
@@ -113,6 +113,27 @@ describe("extractFailureDetails", () => {
113
113
  expect(details).toEqual({});
114
114
  });
115
115
  });
116
+ // ── isJobSuccessful ──────────────────────────────────────────────────────────
117
+ describe("isJobSuccessful", () => {
118
+ it("succeeds when no failed step, exit code 0, and not booting", async () => {
119
+ const { isJobSuccessful } = await import("./result-builder.js");
120
+ expect(isJobSuccessful({ lastFailedStep: null, containerExitCode: 0, isBooting: false })).toBe(true);
121
+ });
122
+ it("fails when a step failed", async () => {
123
+ const { isJobSuccessful } = await import("./result-builder.js");
124
+ expect(isJobSuccessful({ lastFailedStep: "Build", containerExitCode: 0, isBooting: false })).toBe(false);
125
+ });
126
+ it("fails when container exit code is non-zero", async () => {
127
+ const { isJobSuccessful } = await import("./result-builder.js");
128
+ expect(isJobSuccessful({ lastFailedStep: null, containerExitCode: 1, isBooting: false })).toBe(false);
129
+ });
130
+ it("fails when runner never contacted DTU (isBooting=true)", async () => {
131
+ const { isJobSuccessful } = await import("./result-builder.js");
132
+ // This is the bug from #102: container exits 0 with no failed steps,
133
+ // but the runner never sent any timeline entries (isBooting stayed true).
134
+ expect(isJobSuccessful({ lastFailedStep: null, containerExitCode: 0, isBooting: true })).toBe(false);
135
+ });
136
+ });
116
137
  // ── buildJobResult ────────────────────────────────────────────────────────────
117
138
  describe("buildJobResult", () => {
118
139
  let tmpDir;
@@ -574,7 +574,24 @@ export function isWorkflowRelevant(template, branch, changedFiles) {
574
574
  return true;
575
575
  }
576
576
  }
577
- // 2. Check push
577
+ // 2. Check pull_request_target (same logic as pull_request)
578
+ if (events.pull_request_target) {
579
+ const prt = events.pull_request_target;
580
+ let branchMatches = false;
581
+ if (!prt.branches && !prt["branches-ignore"]) {
582
+ branchMatches = true;
583
+ }
584
+ else if (prt.branches) {
585
+ branchMatches = prt.branches.some((pattern) => minimatch("main", pattern));
586
+ }
587
+ else if (prt["branches-ignore"]) {
588
+ branchMatches = !prt["branches-ignore"].some((pattern) => minimatch("main", pattern));
589
+ }
590
+ if (branchMatches && matchesPaths(prt, changedFiles)) {
591
+ return true;
592
+ }
593
+ }
594
+ // 3. Check push
578
595
  if (events.push) {
579
596
  const push = events.push;
580
597
  let branchMatches = false;
@@ -639,6 +639,27 @@ describe("isWorkflowRelevant", () => {
639
639
  });
640
640
  expect(isWorkflowRelevant(template, "feature", ["cli/src/cli.ts"])).toBe(true);
641
641
  });
642
+ // ── pull_request_target ─────────────────────────────────────────────────
643
+ function prtTemplate(config = {}) {
644
+ return { events: { pull_request_target: config } };
645
+ }
646
+ it("matches pull_request_target with no filters", () => {
647
+ expect(isWorkflowRelevant(prtTemplate(), "feature")).toBe(true);
648
+ });
649
+ it("matches pull_request_target when branch filter includes main", () => {
650
+ expect(isWorkflowRelevant(prtTemplate({ branches: ["main"] }), "feature")).toBe(true);
651
+ });
652
+ it("skips pull_request_target when branch filter excludes main", () => {
653
+ expect(isWorkflowRelevant(prtTemplate({ branches: ["develop"] }), "feature")).toBe(false);
654
+ });
655
+ it("matches pull_request_target with paths filter when files match", () => {
656
+ const template = prtTemplate({ paths: ["src/**"] });
657
+ expect(isWorkflowRelevant(template, "feature", ["src/index.ts"])).toBe(true);
658
+ });
659
+ it("skips pull_request_target with paths filter when no files match", () => {
660
+ const template = prtTemplate({ paths: ["src/**"] });
661
+ expect(isWorkflowRelevant(template, "feature", ["README.md"])).toBe(false);
662
+ });
642
663
  });
643
664
  // ─── fromJSON / toJSON ────────────────────────────────────────────────────────
644
665
  describe("expandExpressions — fromJSON", () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redwoodjs/agent-ci",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Local GitHub Actions runner — pause on failure, ~0ms cache, official runner binary. Built for AI coding agents.",
5
5
  "keywords": [
6
6
  "act-alternative",
@@ -40,7 +40,7 @@
40
40
  "log-update": "^7.2.0",
41
41
  "minimatch": "^10.2.1",
42
42
  "yaml": "^2.8.2",
43
- "dtu-github-actions": "0.7.0"
43
+ "dtu-github-actions": "0.7.1"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/dockerode": "^3.3.34",