@redwoodjs/agent-ci 0.8.1 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +3 -1
- package/dist/docker/container-config.js +7 -8
- package/dist/docker/container-config.test.js +8 -1
- package/dist/docker/docker-socket.js +7 -3
- package/dist/docker/docker-socket.test.js +26 -0
- package/dist/docker/shutdown.js +43 -0
- package/dist/docker/shutdown.test.js +73 -1
- package/dist/runner/directory-setup.test.js +4 -0
- package/dist/runner/git-shim.js +0 -8
- package/dist/runner/git-shim.test.js +0 -15
- package/dist/runner/local-job.js +17 -8
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -12,7 +12,7 @@ import { resolveJobOutputs } from "./runner/result-builder.js";
|
|
|
12
12
|
import { createConcurrencyLimiter, getDefaultMaxConcurrentJobs } from "./output/concurrency.js";
|
|
13
13
|
import { isWarmNodeModules, computeLockfileHash } from "./output/cleanup.js";
|
|
14
14
|
import { getWorkingDirectory } from "./output/working-directory.js";
|
|
15
|
-
import { pruneOrphanedDockerResources } from "./docker/shutdown.js";
|
|
15
|
+
import { pruneOrphanedDockerResources, killOrphanedContainers, pruneStaleWorkspaces, } from "./docker/shutdown.js";
|
|
16
16
|
import { topoSort } from "./workflow/job-scheduler.js";
|
|
17
17
|
import { expandReusableJobs } from "./workflow/reusable-workflow.js";
|
|
18
18
|
import { prefetchRemoteWorkflows } from "./workflow/remote-workflow-fetch.js";
|
|
@@ -692,6 +692,8 @@ async function handleWorkflow(options) {
|
|
|
692
692
|
return result;
|
|
693
693
|
};
|
|
694
694
|
pruneOrphanedDockerResources();
|
|
695
|
+
killOrphanedContainers();
|
|
696
|
+
pruneStaleWorkspaces(getWorkingDirectory(), 24 * 60 * 60 * 1000);
|
|
695
697
|
const limiter = createConcurrencyLimiter(maxJobs);
|
|
696
698
|
const allResults = [];
|
|
697
699
|
// Accumulate job outputs across waves for needs.*.outputs.* resolution
|
|
@@ -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
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
|
|
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:/
|
|
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
|
|
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
|
-
|
|
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;
|
package/dist/docker/shutdown.js
CHANGED
|
@@ -101,6 +101,49 @@ export function pruneOrphanedDockerResources() {
|
|
|
101
101
|
// Docker not reachable — skip
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
|
+
// ─── Orphaned container cleanup ───────────────────────────────────────────────
|
|
105
|
+
/**
|
|
106
|
+
* Find and kill running `agent-ci-*` containers whose parent process is dead.
|
|
107
|
+
*
|
|
108
|
+
* Every container created by `executeLocalJob` is labelled with
|
|
109
|
+
* `agent-ci.pid=<PID>`. If the process that spawned the container is no
|
|
110
|
+
* longer alive, the container is an orphan and should be killed — along with
|
|
111
|
+
* its svc-* sidecars and bridge network (via `killRunnerContainers`).
|
|
112
|
+
*
|
|
113
|
+
* Containers without the label (created before this feature) are skipped.
|
|
114
|
+
*/
|
|
115
|
+
export function killOrphanedContainers() {
|
|
116
|
+
let lines;
|
|
117
|
+
try {
|
|
118
|
+
// Format: "containerId containerName pid-label"
|
|
119
|
+
const raw = execSync(`docker ps --filter "name=agent-ci-" --filter "status=running" --format "{{.ID}} {{.Names}} {{.Label \\"agent-ci.pid\\"}}"`, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
120
|
+
if (!raw) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
lines = raw.split("\n");
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// Docker not reachable — skip
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
for (const line of lines) {
|
|
130
|
+
const [, containerName, pidStr] = line.split(" ");
|
|
131
|
+
if (!containerName || !pidStr) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const pid = Number(pidStr);
|
|
135
|
+
if (!Number.isFinite(pid) || pid <= 0) {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
process.kill(pid, 0); // signal 0 = liveness check, throws if dead
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// Parent is dead — this container is an orphan.
|
|
143
|
+
killRunnerContainers(containerName);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
104
147
|
// ─── Workspace pruning ────────────────────────────────────────────────────────
|
|
105
148
|
/**
|
|
106
149
|
* Remove stale `agent-ci-*` run directories older than `maxAgeMs` from
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import os from "node:os";
|
|
@@ -92,6 +92,78 @@ describe("Stale workspace pruning", () => {
|
|
|
92
92
|
expect(fs.existsSync(otherDir)).toBe(true);
|
|
93
93
|
});
|
|
94
94
|
});
|
|
95
|
+
// ── Orphaned container cleanup ────────────────────────────────────────────────
|
|
96
|
+
describe("killOrphanedContainers", () => {
|
|
97
|
+
const execSyncMock = vi.fn();
|
|
98
|
+
const killSpy = vi.spyOn(process, "kill");
|
|
99
|
+
beforeEach(() => {
|
|
100
|
+
vi.resetModules();
|
|
101
|
+
vi.doMock("node:child_process", () => ({
|
|
102
|
+
execSync: execSyncMock,
|
|
103
|
+
}));
|
|
104
|
+
execSyncMock.mockReset();
|
|
105
|
+
killSpy.mockReset();
|
|
106
|
+
});
|
|
107
|
+
afterEach(() => {
|
|
108
|
+
killSpy.mockRestore();
|
|
109
|
+
});
|
|
110
|
+
it("kills containers whose parent PID is dead", async () => {
|
|
111
|
+
execSyncMock.mockImplementation((cmd) => {
|
|
112
|
+
if (cmd.startsWith("docker ps")) {
|
|
113
|
+
return "abc123 agent-ci-runner-1 99999\n";
|
|
114
|
+
}
|
|
115
|
+
return "";
|
|
116
|
+
});
|
|
117
|
+
killSpy.mockImplementation(((pid, signal) => {
|
|
118
|
+
if (signal === 0 && pid === 99999) {
|
|
119
|
+
throw new Error("ESRCH");
|
|
120
|
+
}
|
|
121
|
+
return true;
|
|
122
|
+
}));
|
|
123
|
+
const { killOrphanedContainers } = await import("./shutdown.js");
|
|
124
|
+
killOrphanedContainers();
|
|
125
|
+
const rmCalls = execSyncMock.mock.calls.filter(([cmd]) => cmd.includes("docker rm -f agent-ci-runner-1"));
|
|
126
|
+
expect(rmCalls.length).toBeGreaterThan(0);
|
|
127
|
+
});
|
|
128
|
+
it("leaves containers whose parent PID is alive", async () => {
|
|
129
|
+
const myPid = process.pid;
|
|
130
|
+
execSyncMock.mockImplementation((cmd) => {
|
|
131
|
+
if (cmd.startsWith("docker ps")) {
|
|
132
|
+
return `abc123 agent-ci-runner-2 ${myPid}\n`;
|
|
133
|
+
}
|
|
134
|
+
return "";
|
|
135
|
+
});
|
|
136
|
+
killSpy.mockImplementation(((pid, signal) => {
|
|
137
|
+
if (signal === 0 && pid === myPid) {
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
throw new Error("ESRCH");
|
|
141
|
+
}));
|
|
142
|
+
const { killOrphanedContainers } = await import("./shutdown.js");
|
|
143
|
+
killOrphanedContainers();
|
|
144
|
+
const rmCalls = execSyncMock.mock.calls.filter(([cmd]) => cmd.includes("docker rm -f"));
|
|
145
|
+
expect(rmCalls).toEqual([]);
|
|
146
|
+
});
|
|
147
|
+
it("skips containers without a PID label", async () => {
|
|
148
|
+
execSyncMock.mockImplementation((cmd) => {
|
|
149
|
+
if (cmd.startsWith("docker ps")) {
|
|
150
|
+
return "abc123 agent-ci-runner-3 \n";
|
|
151
|
+
}
|
|
152
|
+
return "";
|
|
153
|
+
});
|
|
154
|
+
const { killOrphanedContainers } = await import("./shutdown.js");
|
|
155
|
+
killOrphanedContainers();
|
|
156
|
+
const rmCalls = execSyncMock.mock.calls.filter(([cmd]) => cmd.includes("docker rm -f"));
|
|
157
|
+
expect(rmCalls).toEqual([]);
|
|
158
|
+
});
|
|
159
|
+
it("handles Docker not reachable gracefully", async () => {
|
|
160
|
+
execSyncMock.mockImplementation(() => {
|
|
161
|
+
throw new Error("Cannot connect to Docker daemon");
|
|
162
|
+
});
|
|
163
|
+
const { killOrphanedContainers } = await import("./shutdown.js");
|
|
164
|
+
expect(() => killOrphanedContainers()).not.toThrow();
|
|
165
|
+
});
|
|
166
|
+
});
|
|
95
167
|
describe("containerWorkDir cleanup on exit", () => {
|
|
96
168
|
let tmpDir;
|
|
97
169
|
beforeEach(() => {
|
|
@@ -149,6 +149,7 @@ describe("buildContainerBinds — PM-scoped mounts", () => {
|
|
|
149
149
|
warmModulesDir: "/tmp/warm",
|
|
150
150
|
hostRunnerDir: "/tmp/runner",
|
|
151
151
|
useDirectContainer: false,
|
|
152
|
+
githubRepo: "org/repo",
|
|
152
153
|
});
|
|
153
154
|
expect(binds).toContain("/tmp/npm-cache:/home/runner/.npm");
|
|
154
155
|
expect(binds.some((b) => b.includes(".pnpm-store"))).toBe(false);
|
|
@@ -167,6 +168,7 @@ describe("buildContainerBinds — PM-scoped mounts", () => {
|
|
|
167
168
|
warmModulesDir: "/tmp/warm",
|
|
168
169
|
hostRunnerDir: "/tmp/runner",
|
|
169
170
|
useDirectContainer: false,
|
|
171
|
+
githubRepo: "org/repo",
|
|
170
172
|
});
|
|
171
173
|
expect(binds).toContain("/tmp/pnpm-store:/home/runner/_work/.pnpm-store");
|
|
172
174
|
expect(binds.some((b) => b.includes("/.npm"))).toBe(false);
|
|
@@ -185,6 +187,7 @@ describe("buildContainerBinds — PM-scoped mounts", () => {
|
|
|
185
187
|
warmModulesDir: "/tmp/warm",
|
|
186
188
|
hostRunnerDir: "/tmp/runner",
|
|
187
189
|
useDirectContainer: false,
|
|
190
|
+
githubRepo: "org/repo",
|
|
188
191
|
});
|
|
189
192
|
expect(binds).toContain("/tmp/bun-cache:/home/runner/.bun/install/cache");
|
|
190
193
|
expect(binds.some((b) => b.includes(".pnpm-store"))).toBe(false);
|
|
@@ -202,6 +205,7 @@ describe("buildContainerBinds — PM-scoped mounts", () => {
|
|
|
202
205
|
warmModulesDir: "/tmp/warm",
|
|
203
206
|
hostRunnerDir: "/tmp/runner",
|
|
204
207
|
useDirectContainer: false,
|
|
208
|
+
githubRepo: "org/repo",
|
|
205
209
|
});
|
|
206
210
|
expect(binds.some((b) => b.includes(".pnpm-store"))).toBe(false);
|
|
207
211
|
expect(binds.some((b) => b.includes("/.npm"))).toBe(false);
|
package/dist/runner/git-shim.js
CHANGED
|
@@ -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;
|
package/dist/runner/local-job.js
CHANGED
|
@@ -12,7 +12,7 @@ import { killRunnerContainers } from "../docker/shutdown.js";
|
|
|
12
12
|
import { startEphemeralDtu } from "dtu-github-actions/ephemeral";
|
|
13
13
|
import { tailLogFile } from "../output/reporter.js";
|
|
14
14
|
import { writeJobMetadata } from "./metadata.js";
|
|
15
|
-
import {
|
|
15
|
+
import { writeGitShim } from "./git-shim.js";
|
|
16
16
|
import { prepareWorkspace } from "./workspace.js";
|
|
17
17
|
import { createRunDirectories } from "./directory-setup.js";
|
|
18
18
|
import { buildContainerEnv, buildContainerBinds, buildContainerCmd, resolveDtuHost, resolveDockerApiUrl, resolveDockerExtraHosts, } from "../docker/container-config.js";
|
|
@@ -184,13 +184,23 @@ export async function executeLocalJob(job, options) {
|
|
|
184
184
|
writeJobMetadata({ logDir, containerName, job });
|
|
185
185
|
// Open debug stream to capture raw container output
|
|
186
186
|
const debugStream = fs.createWriteStream(debugLogPath);
|
|
187
|
+
// Hoisted for cleanup in `finally` — assigned inside the try block.
|
|
188
|
+
let container = null;
|
|
189
|
+
let serviceCtx;
|
|
190
|
+
const hostRunnerDir = path.resolve(runDir, "runner");
|
|
187
191
|
// Signal handler: ensure cleanup runs even when killed.
|
|
188
192
|
// Do NOT call process.exit() here — multiple jobs register handlers concurrently,
|
|
189
193
|
// and an early exit would prevent other jobs' handlers from cleaning up their containers.
|
|
190
194
|
// killRunnerContainers already handles the runner, its svc-* sidecars, and the network.
|
|
191
195
|
const signalCleanup = () => {
|
|
192
196
|
killRunnerContainers(containerName);
|
|
193
|
-
for (const d of [
|
|
197
|
+
for (const d of [
|
|
198
|
+
dirs.containerWorkDir,
|
|
199
|
+
dirs.shimsDir,
|
|
200
|
+
dirs.signalsDir,
|
|
201
|
+
dirs.diagDir,
|
|
202
|
+
hostRunnerDir,
|
|
203
|
+
]) {
|
|
194
204
|
try {
|
|
195
205
|
fs.rmSync(d, { recursive: true, force: true });
|
|
196
206
|
}
|
|
@@ -199,10 +209,6 @@ export async function executeLocalJob(job, options) {
|
|
|
199
209
|
};
|
|
200
210
|
process.on("SIGINT", signalCleanup);
|
|
201
211
|
process.on("SIGTERM", signalCleanup);
|
|
202
|
-
// Hoisted for cleanup in `finally` — assigned inside the try block.
|
|
203
|
-
let container = null;
|
|
204
|
-
let serviceCtx;
|
|
205
|
-
const hostRunnerDir = path.resolve(runDir, "runner");
|
|
206
212
|
try {
|
|
207
213
|
// 1. Seed the job to Local DTU
|
|
208
214
|
const [githubOwner, githubRepoName] = (job.githubRepo || "").split("/");
|
|
@@ -243,8 +249,7 @@ export async function executeLocalJob(job, options) {
|
|
|
243
249
|
// 4. Write git shim BEFORE container start so the entrypoint can install it
|
|
244
250
|
// immediately. On Linux, prepareWorkspace (rsync) is slow enough that the
|
|
245
251
|
// container entrypoint would race ahead and find an empty shims dir.
|
|
246
|
-
|
|
247
|
-
writeGitShim(dirs.shimsDir, fakeSha);
|
|
252
|
+
writeGitShim(dirs.shimsDir, job.realHeadSha);
|
|
248
253
|
// Prepare workspace files in parallel with container setup
|
|
249
254
|
const workspacePrepStart = Date.now();
|
|
250
255
|
const workspacePrepPromise = (async () => {
|
|
@@ -449,6 +454,7 @@ export async function executeLocalJob(job, options) {
|
|
|
449
454
|
warmModulesDir: dirs.warmModulesDir,
|
|
450
455
|
hostRunnerDir,
|
|
451
456
|
useDirectContainer,
|
|
457
|
+
githubRepo,
|
|
452
458
|
dockerSocketPath: getDockerSocket().socketPath || undefined,
|
|
453
459
|
});
|
|
454
460
|
const containerCmd = buildContainerCmd({
|
|
@@ -463,6 +469,9 @@ export async function executeLocalJob(job, options) {
|
|
|
463
469
|
container = await getDocker().createContainer({
|
|
464
470
|
Image: containerImage,
|
|
465
471
|
name: containerName,
|
|
472
|
+
Labels: {
|
|
473
|
+
"agent-ci.pid": String(process.pid),
|
|
474
|
+
},
|
|
466
475
|
Env: containerEnv,
|
|
467
476
|
...(useDirectContainer ? { Entrypoint: ["bash"] } : {}),
|
|
468
477
|
Cmd: containerCmd,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@redwoodjs/agent-ci",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2",
|
|
4
4
|
"description": "Local GitHub Actions runner — pause on failure, ~0ms cache, official runner binary. Built for AI coding agents.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"act-alternative",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"log-update": "^7.2.0",
|
|
41
41
|
"minimatch": "^10.2.1",
|
|
42
42
|
"yaml": "^2.8.2",
|
|
43
|
-
"dtu-github-actions": "0.8.
|
|
43
|
+
"dtu-github-actions": "0.8.2"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@types/dockerode": "^3.3.34",
|