@redwoodjs/agent-ci 0.8.0 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -7
- package/dist/cli.js +51 -6
- package/dist/commit-status.js +6 -2
- 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/dist/workflow/remote-workflow-fetch.js +7 -28
- package/dist/workflow/remote-workflow-fetch.test.js +78 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -64,13 +64,15 @@ When using a remote daemon (`DOCKER_HOST=ssh://...`), `host-gateway` resolves re
|
|
|
64
64
|
|
|
65
65
|
Run GitHub Actions workflow jobs locally.
|
|
66
66
|
|
|
67
|
-
| Flag
|
|
68
|
-
|
|
|
69
|
-
| `--workflow <path>`
|
|
70
|
-
| `--all`
|
|
71
|
-
| `--pause-on-failure`
|
|
72
|
-
| `--quiet`
|
|
73
|
-
| `--no-matrix`
|
|
67
|
+
| Flag | Short | Description |
|
|
68
|
+
| -------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
69
|
+
| `--workflow <path>` | `-w` | Path to the workflow file |
|
|
70
|
+
| `--all` | `-a` | Discover and run all relevant workflows for the current branch |
|
|
71
|
+
| `--pause-on-failure` | `-p` | Pause on step failure for interactive debugging |
|
|
72
|
+
| `--quiet` | `-q` | Suppress animated rendering (also enabled by `AI_AGENT=1`) |
|
|
73
|
+
| `--no-matrix` | | Collapse all matrix combinations into a single job (uses first value of each key) |
|
|
74
|
+
| `--github-token [<token>]` | | GitHub token for fetching remote reusable workflows (auto-resolves via `gh auth token` if no value given). Also available as `AGENT_CI_GITHUB_TOKEN` env var |
|
|
75
|
+
| `--commit-status` | | Post a GitHub commit status after the run (requires `--github-token`) |
|
|
74
76
|
|
|
75
77
|
### `agent-ci retry`
|
|
76
78
|
|
package/dist/cli.js
CHANGED
|
@@ -12,7 +12,7 @@ import { resolveJobOutputs } from "./runner/result-builder.js";
|
|
|
12
12
|
import { createConcurrencyLimiter, getDefaultMaxConcurrentJobs } from "./output/concurrency.js";
|
|
13
13
|
import { isWarmNodeModules, computeLockfileHash } from "./output/cleanup.js";
|
|
14
14
|
import { getWorkingDirectory } from "./output/working-directory.js";
|
|
15
|
-
import { pruneOrphanedDockerResources } from "./docker/shutdown.js";
|
|
15
|
+
import { pruneOrphanedDockerResources, killOrphanedContainers, pruneStaleWorkspaces, } from "./docker/shutdown.js";
|
|
16
16
|
import { topoSort } from "./workflow/job-scheduler.js";
|
|
17
17
|
import { expandReusableJobs } from "./workflow/reusable-workflow.js";
|
|
18
18
|
import { prefetchRemoteWorkflows } from "./workflow/remote-workflow-fetch.js";
|
|
@@ -49,6 +49,8 @@ async function run() {
|
|
|
49
49
|
let pauseOnFailure = false;
|
|
50
50
|
let runAll = false;
|
|
51
51
|
let noMatrix = false;
|
|
52
|
+
let githubToken;
|
|
53
|
+
let commitStatus = false;
|
|
52
54
|
for (let i = 1; i < args.length; i++) {
|
|
53
55
|
if ((args[i] === "--workflow" || args[i] === "-w") && args[i + 1]) {
|
|
54
56
|
workflow = args[i + 1];
|
|
@@ -66,10 +68,37 @@ async function run() {
|
|
|
66
68
|
else if (args[i] === "--no-matrix") {
|
|
67
69
|
noMatrix = true;
|
|
68
70
|
}
|
|
71
|
+
else if (args[i] === "--commit-status") {
|
|
72
|
+
commitStatus = true;
|
|
73
|
+
}
|
|
74
|
+
else if (args[i] === "--github-token") {
|
|
75
|
+
// If the next arg looks like a token value (not another flag), use it.
|
|
76
|
+
// Otherwise, auto-resolve via `gh auth token`.
|
|
77
|
+
if (args[i + 1] && !args[i + 1].startsWith("-")) {
|
|
78
|
+
githubToken = args[i + 1];
|
|
79
|
+
i++;
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
try {
|
|
83
|
+
githubToken = execSync("gh auth token", {
|
|
84
|
+
encoding: "utf-8",
|
|
85
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
86
|
+
}).trim();
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
console.error("[Agent CI] Error: --github-token requires `gh` CLI to be installed and authenticated, or pass a token value: --github-token <value>");
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
69
94
|
else if (!args[i].startsWith("-")) {
|
|
70
95
|
sha = args[i];
|
|
71
96
|
}
|
|
72
97
|
}
|
|
98
|
+
// Also accept AGENT_CI_GITHUB_TOKEN env var (CLI flag takes precedence)
|
|
99
|
+
if (!githubToken && process.env.AGENT_CI_GITHUB_TOKEN) {
|
|
100
|
+
githubToken = process.env.AGENT_CI_GITHUB_TOKEN;
|
|
101
|
+
}
|
|
73
102
|
let workingDir = process.env.AGENT_CI_WORKING_DIR;
|
|
74
103
|
if (workingDir) {
|
|
75
104
|
if (!path.isAbsolute(workingDir)) {
|
|
@@ -131,11 +160,14 @@ async function run() {
|
|
|
131
160
|
sha,
|
|
132
161
|
pauseOnFailure,
|
|
133
162
|
noMatrix,
|
|
163
|
+
githubToken,
|
|
134
164
|
});
|
|
135
165
|
if (results.length > 0) {
|
|
136
166
|
printSummary(results);
|
|
137
167
|
}
|
|
138
|
-
|
|
168
|
+
if (commitStatus) {
|
|
169
|
+
postCommitStatus(results, sha, githubToken);
|
|
170
|
+
}
|
|
139
171
|
const anyFailed = results.length === 0 || results.some((r) => !r.succeeded);
|
|
140
172
|
process.exit(anyFailed ? 1 : 0);
|
|
141
173
|
}
|
|
@@ -166,11 +198,14 @@ async function run() {
|
|
|
166
198
|
sha,
|
|
167
199
|
pauseOnFailure,
|
|
168
200
|
noMatrix,
|
|
201
|
+
githubToken,
|
|
169
202
|
});
|
|
170
203
|
if (results.length > 0) {
|
|
171
204
|
printSummary(results);
|
|
172
205
|
}
|
|
173
|
-
|
|
206
|
+
if (commitStatus) {
|
|
207
|
+
postCommitStatus(results, sha, githubToken);
|
|
208
|
+
}
|
|
174
209
|
if (results.length === 0 || results.some((r) => !r.succeeded)) {
|
|
175
210
|
process.exit(1);
|
|
176
211
|
}
|
|
@@ -246,7 +281,7 @@ async function run() {
|
|
|
246
281
|
// Single entry point for both `--workflow` and `--all`.
|
|
247
282
|
// One workflow = --all with a single entry.
|
|
248
283
|
async function runWorkflows(options) {
|
|
249
|
-
const { workflowPaths, sha, pauseOnFailure, noMatrix = false } = options;
|
|
284
|
+
const { workflowPaths, sha, pauseOnFailure, noMatrix = false, githubToken } = options;
|
|
250
285
|
// Suppress EventEmitter MaxListenersExceeded warnings when running many
|
|
251
286
|
// parallel jobs (each job adds SIGINT/SIGTERM listeners).
|
|
252
287
|
process.setMaxListeners(0);
|
|
@@ -346,6 +381,7 @@ async function runWorkflows(options) {
|
|
|
346
381
|
pauseOnFailure,
|
|
347
382
|
noMatrix,
|
|
348
383
|
store,
|
|
384
|
+
githubToken,
|
|
349
385
|
});
|
|
350
386
|
allResults.push(...results);
|
|
351
387
|
}
|
|
@@ -377,6 +413,7 @@ async function runWorkflows(options) {
|
|
|
377
413
|
noMatrix,
|
|
378
414
|
store,
|
|
379
415
|
baseRunNum: runNums[0],
|
|
416
|
+
githubToken,
|
|
380
417
|
});
|
|
381
418
|
allResults.push(...firstResults);
|
|
382
419
|
const settled = await Promise.allSettled(workflowPaths.slice(1).map((wf, i) => handleWorkflow({
|
|
@@ -386,6 +423,7 @@ async function runWorkflows(options) {
|
|
|
386
423
|
noMatrix,
|
|
387
424
|
store,
|
|
388
425
|
baseRunNum: runNums[i + 1],
|
|
426
|
+
githubToken,
|
|
389
427
|
})));
|
|
390
428
|
for (const s of settled) {
|
|
391
429
|
if (s.status === "fulfilled") {
|
|
@@ -404,6 +442,7 @@ async function runWorkflows(options) {
|
|
|
404
442
|
noMatrix,
|
|
405
443
|
store,
|
|
406
444
|
baseRunNum: runNums[i],
|
|
445
|
+
githubToken,
|
|
407
446
|
})));
|
|
408
447
|
for (const s of settled) {
|
|
409
448
|
if (s.status === "fulfilled") {
|
|
@@ -438,7 +477,7 @@ async function runWorkflows(options) {
|
|
|
438
477
|
// Processes a single workflow file: parses jobs, handles matrix expansion,
|
|
439
478
|
// wave scheduling, warm-cache serialization, and concurrency limiting.
|
|
440
479
|
async function handleWorkflow(options) {
|
|
441
|
-
const { sha, pauseOnFailure, noMatrix = false, store } = options;
|
|
480
|
+
const { sha, pauseOnFailure, noMatrix = false, store, githubToken } = options;
|
|
442
481
|
let workflowPath = options.workflowPath;
|
|
443
482
|
if (!fs.existsSync(workflowPath)) {
|
|
444
483
|
throw new Error(`Workflow file not found: ${workflowPath}`);
|
|
@@ -458,7 +497,7 @@ async function handleWorkflow(options) {
|
|
|
458
497
|
config.GITHUB_REPO = githubRepo;
|
|
459
498
|
const [owner, name] = githubRepo.split("/");
|
|
460
499
|
const remoteCacheDir = path.resolve(getWorkingDirectory(), "cache", "remote-workflows");
|
|
461
|
-
const remoteCache = await prefetchRemoteWorkflows(workflowPath, remoteCacheDir);
|
|
500
|
+
const remoteCache = await prefetchRemoteWorkflows(workflowPath, remoteCacheDir, githubToken);
|
|
462
501
|
const expandedEntries = expandReusableJobs(workflowPath, repoRoot, remoteCache);
|
|
463
502
|
if (expandedEntries.length === 0) {
|
|
464
503
|
debugCli(`[Agent CI] No jobs found in workflow: ${path.basename(workflowPath)}`);
|
|
@@ -653,6 +692,8 @@ async function handleWorkflow(options) {
|
|
|
653
692
|
return result;
|
|
654
693
|
};
|
|
655
694
|
pruneOrphanedDockerResources();
|
|
695
|
+
killOrphanedContainers();
|
|
696
|
+
pruneStaleWorkspaces(getWorkingDirectory(), 24 * 60 * 60 * 1000);
|
|
656
697
|
const limiter = createConcurrencyLimiter(maxJobs);
|
|
657
698
|
const allResults = [];
|
|
658
699
|
// Accumulate job outputs across waves for needs.*.outputs.* resolution
|
|
@@ -877,6 +918,10 @@ function printUsage() {
|
|
|
877
918
|
console.log(" -p, --pause-on-failure Pause on step failure for interactive debugging");
|
|
878
919
|
console.log(" -q, --quiet Suppress animated rendering (also enabled by AI_AGENT=1)");
|
|
879
920
|
console.log(" --no-matrix Collapse all matrix combinations into a single job (uses first value of each key)");
|
|
921
|
+
console.log(" --github-token [<token>] GitHub token for fetching remote reusable workflows");
|
|
922
|
+
console.log(" (auto-resolves via `gh auth token` if no value given)");
|
|
923
|
+
console.log(" Or set: AGENT_CI_GITHUB_TOKEN env var");
|
|
924
|
+
console.log(" --commit-status Post a GitHub commit status after the run (requires --github-token)");
|
|
880
925
|
}
|
|
881
926
|
function resolveRepoRoot() {
|
|
882
927
|
let repoRoot = process.cwd();
|
package/dist/commit-status.js
CHANGED
|
@@ -2,9 +2,13 @@ import { execSync } from "child_process";
|
|
|
2
2
|
import { config } from "./config.js";
|
|
3
3
|
/**
|
|
4
4
|
* Post a GitHub commit status via the `gh` CLI.
|
|
5
|
-
*
|
|
5
|
+
* Only called when --commit-status is passed. Requires a GitHub token.
|
|
6
6
|
*/
|
|
7
|
-
export function postCommitStatus(results, sha) {
|
|
7
|
+
export function postCommitStatus(results, sha, githubToken) {
|
|
8
|
+
if (!githubToken) {
|
|
9
|
+
console.warn("[Agent CI] --commit-status requires a GitHub token. Use --github-token or set AGENT_CI_GITHUB_TOKEN.");
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
8
12
|
// Check if gh CLI is available
|
|
9
13
|
try {
|
|
10
14
|
execSync("which gh", { stdio: "ignore" });
|
|
@@ -33,7 +33,8 @@ export function buildContainerEnv(opts) {
|
|
|
33
33
|
* Build the Binds array for `docker.createContainer()`.
|
|
34
34
|
*/
|
|
35
35
|
export function buildContainerBinds(opts) {
|
|
36
|
-
const { hostWorkDir, shimsDir, signalsDir, diagDir, toolCacheDir, pnpmStoreDir, npmCacheDir, bunCacheDir, playwrightCacheDir, warmModulesDir, hostRunnerDir, useDirectContainer, dockerSocketPath = "/var/run/docker.sock", } = opts;
|
|
36
|
+
const { hostWorkDir, shimsDir, signalsDir, diagDir, toolCacheDir, pnpmStoreDir, npmCacheDir, bunCacheDir, playwrightCacheDir, warmModulesDir, hostRunnerDir, useDirectContainer, githubRepo, dockerSocketPath = "/var/run/docker.sock", } = opts;
|
|
37
|
+
const repoName = githubRepo.split("/").pop() || "repo";
|
|
37
38
|
const h = toHostPath;
|
|
38
39
|
return [
|
|
39
40
|
// When using a custom container, bind-mount the extracted runner
|
|
@@ -50,12 +51,11 @@ export function buildContainerBinds(opts) {
|
|
|
50
51
|
...(npmCacheDir ? [`${h(npmCacheDir)}:/home/runner/.npm`] : []),
|
|
51
52
|
...(bunCacheDir ? [`${h(bunCacheDir)}:/home/runner/.bun/install/cache`] : []),
|
|
52
53
|
`${h(playwrightCacheDir)}:/home/runner/.cache/ms-playwright`,
|
|
53
|
-
// Warm node_modules: mounted
|
|
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,
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { execSync } from "node:child_process";
|
|
4
3
|
import { parse as parseYaml } from "yaml";
|
|
5
4
|
/**
|
|
6
5
|
* Parse a remote reusable workflow ref string.
|
|
@@ -37,27 +36,6 @@ export function remoteCachePath(cacheDir, ref) {
|
|
|
37
36
|
const sanitizedRef = ref.ref.replace(/[^a-zA-Z0-9._-]/g, "-");
|
|
38
37
|
return path.join(cacheDir, `${ref.owner}__${ref.repo}@${sanitizedRef}`, ref.path);
|
|
39
38
|
}
|
|
40
|
-
/**
|
|
41
|
-
* Resolve a GitHub token for API access.
|
|
42
|
-
* Tries `gh auth token` first (real user credentials), then falls back to
|
|
43
|
-
* GITHUB_TOKEN env var. This ordering matters because agent-ci injects a
|
|
44
|
-
* fake token as GITHUB_TOKEN for the runner context.
|
|
45
|
-
*/
|
|
46
|
-
function resolveGitHubToken() {
|
|
47
|
-
try {
|
|
48
|
-
const token = execSync("gh auth token", {
|
|
49
|
-
encoding: "utf-8",
|
|
50
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
51
|
-
}).trim();
|
|
52
|
-
if (token) {
|
|
53
|
-
return token;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
catch {
|
|
57
|
-
// gh not installed or not authenticated
|
|
58
|
-
}
|
|
59
|
-
return process.env.GITHUB_TOKEN || null;
|
|
60
|
-
}
|
|
61
39
|
/**
|
|
62
40
|
* Scan a workflow YAML and prefetch all remote reusable workflow refs.
|
|
63
41
|
* Downloaded files are written to cacheDir.
|
|
@@ -65,9 +43,11 @@ function resolveGitHubToken() {
|
|
|
65
43
|
* - SHA refs: cached forever (immutable)
|
|
66
44
|
* - Tag/branch refs: always re-fetched (mutable)
|
|
67
45
|
*
|
|
68
|
-
*
|
|
46
|
+
* Authentication is opt-in via the `githubToken` parameter.
|
|
47
|
+
* Public repos may work without auth (within rate limits).
|
|
48
|
+
* On 401/403 responses, throws with instructions for how to authenticate.
|
|
69
49
|
*/
|
|
70
|
-
export async function prefetchRemoteWorkflows(workflowPath, cacheDir) {
|
|
50
|
+
export async function prefetchRemoteWorkflows(workflowPath, cacheDir, githubToken) {
|
|
71
51
|
const resolved = new Map();
|
|
72
52
|
const raw = parseYaml(fs.readFileSync(workflowPath, "utf-8"));
|
|
73
53
|
const jobs = raw?.jobs ?? {};
|
|
@@ -84,7 +64,6 @@ export async function prefetchRemoteWorkflows(workflowPath, cacheDir) {
|
|
|
84
64
|
if (remoteRefs.length === 0) {
|
|
85
65
|
return resolved;
|
|
86
66
|
}
|
|
87
|
-
const token = resolveGitHubToken();
|
|
88
67
|
const errors = [];
|
|
89
68
|
await Promise.all(remoteRefs.map(async (ref) => {
|
|
90
69
|
const dest = remoteCachePath(cacheDir, ref);
|
|
@@ -99,13 +78,13 @@ export async function prefetchRemoteWorkflows(workflowPath, cacheDir) {
|
|
|
99
78
|
Accept: "application/vnd.github.v3+json",
|
|
100
79
|
"User-Agent": "agent-ci/1.0",
|
|
101
80
|
};
|
|
102
|
-
if (
|
|
103
|
-
headers["Authorization"] = `token ${
|
|
81
|
+
if (githubToken) {
|
|
82
|
+
headers["Authorization"] = `token ${githubToken}`;
|
|
104
83
|
}
|
|
105
84
|
const response = await fetch(url, { headers });
|
|
106
85
|
if (!response.ok) {
|
|
107
86
|
const hint = response.status === 401 || response.status === 403
|
|
108
|
-
?
|
|
87
|
+
? ` Run with: agent-ci run --github-token\n Or set: export AGENT_CI_GITHUB_TOKEN=$(gh auth token)`
|
|
109
88
|
: "";
|
|
110
89
|
errors.push(`Failed to fetch remote workflow ${ref.raw} (HTTP ${response.status}).${hint}`);
|
|
111
90
|
return;
|
|
@@ -196,7 +196,84 @@ jobs:
|
|
|
196
196
|
lint:
|
|
197
197
|
uses: org/private-repo/.github/workflows/lint.yml@v1
|
|
198
198
|
`);
|
|
199
|
-
await expect(prefetchRemoteWorkflows(wf, cacheDir)).rejects.toThrow(/
|
|
199
|
+
await expect(prefetchRemoteWorkflows(wf, cacheDir)).rejects.toThrow(/--github-token/);
|
|
200
|
+
});
|
|
201
|
+
it("sends Authorization header when githubToken is provided", async () => {
|
|
202
|
+
const remoteYaml = `
|
|
203
|
+
on: workflow_call
|
|
204
|
+
jobs:
|
|
205
|
+
lint:
|
|
206
|
+
runs-on: ubuntu-latest
|
|
207
|
+
steps:
|
|
208
|
+
- run: echo lint
|
|
209
|
+
`;
|
|
210
|
+
mockFetchSuccess(remoteYaml);
|
|
211
|
+
const wf = writeWorkflow(`
|
|
212
|
+
jobs:
|
|
213
|
+
lint:
|
|
214
|
+
uses: org/repo/.github/workflows/lint.yml@v1
|
|
215
|
+
`);
|
|
216
|
+
await prefetchRemoteWorkflows(wf, cacheDir, "ghp_test123");
|
|
217
|
+
const fetchCall = globalThis.fetch.mock.calls[0];
|
|
218
|
+
expect(fetchCall[1].headers["Authorization"]).toBe("token ghp_test123");
|
|
219
|
+
});
|
|
220
|
+
it("does not send Authorization header when no token provided", async () => {
|
|
221
|
+
const remoteYaml = `
|
|
222
|
+
on: workflow_call
|
|
223
|
+
jobs:
|
|
224
|
+
lint:
|
|
225
|
+
runs-on: ubuntu-latest
|
|
226
|
+
steps:
|
|
227
|
+
- run: echo lint
|
|
228
|
+
`;
|
|
229
|
+
mockFetchSuccess(remoteYaml);
|
|
230
|
+
const wf = writeWorkflow(`
|
|
231
|
+
jobs:
|
|
232
|
+
lint:
|
|
233
|
+
uses: org/repo/.github/workflows/lint.yml@v1
|
|
234
|
+
`);
|
|
235
|
+
await prefetchRemoteWorkflows(wf, cacheDir);
|
|
236
|
+
const fetchCall = globalThis.fetch.mock.calls[0];
|
|
237
|
+
expect(fetchCall[1].headers["Authorization"]).toBeUndefined();
|
|
238
|
+
});
|
|
239
|
+
it("throws on 403 with auth hint mentioning --github-token and AGENT_CI_GITHUB_TOKEN", async () => {
|
|
240
|
+
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
241
|
+
ok: false,
|
|
242
|
+
status: 403,
|
|
243
|
+
});
|
|
244
|
+
const wf = writeWorkflow(`
|
|
245
|
+
jobs:
|
|
246
|
+
lint:
|
|
247
|
+
uses: org/private-repo/.github/workflows/lint.yml@v1
|
|
248
|
+
`);
|
|
249
|
+
await expect(prefetchRemoteWorkflows(wf, cacheDir)).rejects.toThrow(/--github-token/);
|
|
250
|
+
await expect(prefetchRemoteWorkflows(wf, cacheDir)).rejects.toThrow(/AGENT_CI_GITHUB_TOKEN/);
|
|
251
|
+
});
|
|
252
|
+
it("succeeds fetching a public remote workflow without auth", async () => {
|
|
253
|
+
const remoteYaml = `
|
|
254
|
+
on: workflow_call
|
|
255
|
+
jobs:
|
|
256
|
+
lint:
|
|
257
|
+
runs-on: ubuntu-latest
|
|
258
|
+
steps:
|
|
259
|
+
- run: echo lint
|
|
260
|
+
`;
|
|
261
|
+
mockFetchSuccess(remoteYaml);
|
|
262
|
+
const wf = writeWorkflow(`
|
|
263
|
+
jobs:
|
|
264
|
+
lint:
|
|
265
|
+
uses: org/public-repo/.github/workflows/lint.yml@v1
|
|
266
|
+
`);
|
|
267
|
+
// No githubToken passed — simulates public repo access without auth
|
|
268
|
+
const result = await prefetchRemoteWorkflows(wf, cacheDir);
|
|
269
|
+
expect(result.size).toBe(1);
|
|
270
|
+
// Verify no Authorization header was sent
|
|
271
|
+
const fetchCall = globalThis.fetch.mock.calls[0];
|
|
272
|
+
expect(fetchCall[1].headers["Authorization"]).toBeUndefined();
|
|
273
|
+
// Verify the cached file was written correctly
|
|
274
|
+
const cachedPath = result.get("org/public-repo/.github/workflows/lint.yml@v1");
|
|
275
|
+
expect(fs.existsSync(cachedPath)).toBe(true);
|
|
276
|
+
expect(fs.readFileSync(cachedPath, "utf-8")).toBe(remoteYaml);
|
|
200
277
|
});
|
|
201
278
|
it("fetches multiple remote refs in parallel", async () => {
|
|
202
279
|
const remoteYaml = `
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@redwoodjs/agent-ci",
|
|
3
|
-
"version": "0.8.
|
|
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",
|