@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 +39 -7
- package/dist/docker/container-config.js +1 -0
- package/dist/docker/container-config.test.js +15 -1
- package/dist/docker/repro-126.test.js +72 -0
- package/dist/docker/shutdown.js +21 -4
- package/dist/output/logger.js +35 -6
- package/dist/output/logger.test.js +40 -0
- package/dist/runner/local-job.js +39 -31
- package/dist/runner/result-builder.js +12 -0
- package/dist/runner/result-builder.test.js +21 -0
- package/dist/workflow/workflow-parser.js +18 -1
- package/dist/workflow/workflow-parser.test.js +21 -0
- package/package.json +2 -2
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
|
-
|
|
363
|
-
|
|
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({
|
|
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
|
|
707
|
-
|
|
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
|
+
});
|
package/dist/docker/shutdown.js
CHANGED
|
@@ -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
|
|
47
|
-
* 2.
|
|
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
|
|
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
|
-
//
|
|
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"],
|
package/dist/output/logger.js
CHANGED
|
@@ -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
|
|
37
|
-
let name
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
});
|
package/dist/runner/local-job.js
CHANGED
|
@@ -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.
|
|
172
|
-
process.
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
43
|
+
"dtu-github-actions": "0.7.1"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@types/dockerode": "^3.3.34",
|