@redwoodjs/agent-ci 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +8 -4
- package/dist/docker/container-config.js +4 -4
- package/dist/docker/container-config.test.js +38 -1
- package/dist/output/cleanup.js +20 -0
- package/dist/output/cleanup.test.js +45 -0
- package/dist/output/reporter.test.js +36 -0
- package/dist/output/state-renderer.js +26 -1
- package/dist/runner/directory-setup.js +19 -6
- package/dist/runner/directory-setup.test.js +179 -0
- package/dist/runner/local-job.js +101 -42
- package/dist/runner/step-wrapper.js +1 -1
- package/dist/runner/step-wrapper.test.js +4 -0
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -130,9 +130,11 @@ async function run() {
|
|
|
130
130
|
pauseOnFailure,
|
|
131
131
|
noMatrix,
|
|
132
132
|
});
|
|
133
|
-
|
|
133
|
+
if (results.length > 0) {
|
|
134
|
+
printSummary(results);
|
|
135
|
+
}
|
|
134
136
|
postCommitStatus(results, sha);
|
|
135
|
-
const anyFailed = results.some((r) => !r.succeeded);
|
|
137
|
+
const anyFailed = results.length === 0 || results.some((r) => !r.succeeded);
|
|
136
138
|
process.exit(anyFailed ? 1 : 0);
|
|
137
139
|
}
|
|
138
140
|
if (!workflow) {
|
|
@@ -163,9 +165,11 @@ async function run() {
|
|
|
163
165
|
pauseOnFailure,
|
|
164
166
|
noMatrix,
|
|
165
167
|
});
|
|
166
|
-
|
|
168
|
+
if (results.length > 0) {
|
|
169
|
+
printSummary(results);
|
|
170
|
+
}
|
|
167
171
|
postCommitStatus(results, sha);
|
|
168
|
-
if (results.some((r) => !r.succeeded)) {
|
|
172
|
+
if (results.length === 0 || results.some((r) => !r.succeeded)) {
|
|
169
173
|
process.exit(1);
|
|
170
174
|
}
|
|
171
175
|
process.exit(0);
|
|
@@ -44,10 +44,10 @@ export function buildContainerBinds(opts) {
|
|
|
44
44
|
...(signalsDir ? [`${h(signalsDir)}:/tmp/agent-ci-signals`] : []),
|
|
45
45
|
`${h(diagDir)}:/home/runner/_diag`,
|
|
46
46
|
`${h(toolCacheDir)}:/opt/hostedtoolcache`,
|
|
47
|
-
// Package manager caches (persist across runs)
|
|
48
|
-
`${h(pnpmStoreDir)}:/home/runner/_work/.pnpm-store
|
|
49
|
-
`${h(npmCacheDir)}:/home/runner/.npm
|
|
50
|
-
`${h(bunCacheDir)}:/home/runner/.bun/install/cache
|
|
47
|
+
// Package manager caches (persist across runs, only for detected PM)
|
|
48
|
+
...(pnpmStoreDir ? [`${h(pnpmStoreDir)}:/home/runner/_work/.pnpm-store`] : []),
|
|
49
|
+
...(npmCacheDir ? [`${h(npmCacheDir)}:/home/runner/.npm`] : []),
|
|
50
|
+
...(bunCacheDir ? [`${h(bunCacheDir)}:/home/runner/.bun/install/cache`] : []),
|
|
51
51
|
`${h(playwrightCacheDir)}:/home/runner/.cache/ms-playwright`,
|
|
52
52
|
// Warm node_modules: mounted outside the workspace so actions/checkout can
|
|
53
53
|
// delete the symlink without EBUSY. A symlink in the entrypoint points
|
|
@@ -41,7 +41,7 @@ describe("buildContainerEnv", () => {
|
|
|
41
41
|
});
|
|
42
42
|
// ── buildContainerBinds ───────────────────────────────────────────────────────
|
|
43
43
|
describe("buildContainerBinds", () => {
|
|
44
|
-
it("builds standard bind mounts", async () => {
|
|
44
|
+
it("builds standard bind mounts with all PM caches", async () => {
|
|
45
45
|
const { buildContainerBinds } = await import("./container-config.js");
|
|
46
46
|
const binds = buildContainerBinds({
|
|
47
47
|
hostWorkDir: "/tmp/work",
|
|
@@ -60,9 +60,46 @@ describe("buildContainerBinds", () => {
|
|
|
60
60
|
expect(binds).toContain("/var/run/docker.sock:/var/run/docker.sock");
|
|
61
61
|
expect(binds).toContain("/tmp/shims:/tmp/agent-ci-shims");
|
|
62
62
|
expect(binds).toContain("/tmp/warm:/tmp/warm-modules");
|
|
63
|
+
expect(binds).toContain("/tmp/pnpm:/home/runner/_work/.pnpm-store");
|
|
64
|
+
expect(binds).toContain("/tmp/npm:/home/runner/.npm");
|
|
65
|
+
expect(binds).toContain("/tmp/bun:/home/runner/.bun/install/cache");
|
|
63
66
|
// Standard mode should NOT include runner home bind (but _work bind is expected)
|
|
64
67
|
expect(binds.some((b) => b.endsWith(":/home/runner"))).toBe(false);
|
|
65
68
|
});
|
|
69
|
+
it("omits PM bind mounts when cache dirs are not provided", async () => {
|
|
70
|
+
const { buildContainerBinds } = await import("./container-config.js");
|
|
71
|
+
const binds = buildContainerBinds({
|
|
72
|
+
hostWorkDir: "/tmp/work",
|
|
73
|
+
shimsDir: "/tmp/shims",
|
|
74
|
+
diagDir: "/tmp/diag",
|
|
75
|
+
toolCacheDir: "/tmp/toolcache",
|
|
76
|
+
playwrightCacheDir: "/tmp/playwright",
|
|
77
|
+
warmModulesDir: "/tmp/warm",
|
|
78
|
+
hostRunnerDir: "/tmp/runner",
|
|
79
|
+
useDirectContainer: false,
|
|
80
|
+
});
|
|
81
|
+
expect(binds).toContain("/tmp/work:/home/runner/_work");
|
|
82
|
+
expect(binds.some((b) => b.includes(".pnpm-store"))).toBe(false);
|
|
83
|
+
expect(binds.some((b) => b.includes("/.npm"))).toBe(false);
|
|
84
|
+
expect(binds.some((b) => b.includes(".bun"))).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
it("includes only the npm bind mount when only npmCacheDir is provided", async () => {
|
|
87
|
+
const { buildContainerBinds } = await import("./container-config.js");
|
|
88
|
+
const binds = buildContainerBinds({
|
|
89
|
+
hostWorkDir: "/tmp/work",
|
|
90
|
+
shimsDir: "/tmp/shims",
|
|
91
|
+
diagDir: "/tmp/diag",
|
|
92
|
+
toolCacheDir: "/tmp/toolcache",
|
|
93
|
+
npmCacheDir: "/tmp/npm",
|
|
94
|
+
playwrightCacheDir: "/tmp/playwright",
|
|
95
|
+
warmModulesDir: "/tmp/warm",
|
|
96
|
+
hostRunnerDir: "/tmp/runner",
|
|
97
|
+
useDirectContainer: false,
|
|
98
|
+
});
|
|
99
|
+
expect(binds).toContain("/tmp/npm:/home/runner/.npm");
|
|
100
|
+
expect(binds.some((b) => b.includes(".pnpm-store"))).toBe(false);
|
|
101
|
+
expect(binds.some((b) => b.includes(".bun"))).toBe(false);
|
|
102
|
+
});
|
|
66
103
|
it("includes runner bind mount for direct container", async () => {
|
|
67
104
|
const { buildContainerBinds } = await import("./container-config.js");
|
|
68
105
|
const binds = buildContainerBinds({
|
package/dist/output/cleanup.js
CHANGED
|
@@ -18,6 +18,7 @@ export function copyWorkspace(repoRoot, dest) {
|
|
|
18
18
|
const files = execSync("git ls-files --cached --others --exclude-standard -z", {
|
|
19
19
|
stdio: "pipe",
|
|
20
20
|
cwd: repoRoot,
|
|
21
|
+
maxBuffer: 100 * 1024 * 1024, // 100MB — default 1MB overflows in large monorepos
|
|
21
22
|
})
|
|
22
23
|
.toString()
|
|
23
24
|
.split("\0")
|
|
@@ -82,6 +83,25 @@ const LOCKFILE_NAMES = [
|
|
|
82
83
|
"bun.lock",
|
|
83
84
|
"bun.lockb",
|
|
84
85
|
];
|
|
86
|
+
const LOCKFILE_TO_PM = {
|
|
87
|
+
"pnpm-lock.yaml": "pnpm",
|
|
88
|
+
"package-lock.json": "npm",
|
|
89
|
+
"yarn.lock": "yarn",
|
|
90
|
+
"bun.lock": "bun",
|
|
91
|
+
"bun.lockb": "bun",
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* Detect the project's package manager by looking for lockfiles in the repo root.
|
|
95
|
+
* Returns the first match in priority order, or null if no lockfile is found.
|
|
96
|
+
*/
|
|
97
|
+
export function detectPackageManager(repoRoot) {
|
|
98
|
+
for (const name of LOCKFILE_NAMES) {
|
|
99
|
+
if (fs.existsSync(path.join(repoRoot, name))) {
|
|
100
|
+
return LOCKFILE_TO_PM[name];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
85
105
|
/**
|
|
86
106
|
* Compute a short SHA-256 hash of lockfiles tracked in the repo.
|
|
87
107
|
* Searches for all known lockfile types (pnpm, npm, yarn, bun) and hashes
|
|
@@ -136,6 +136,51 @@ describe("computeLockfileHash", () => {
|
|
|
136
136
|
expect(hash).toMatch(/^[a-f0-9]{16}$/);
|
|
137
137
|
});
|
|
138
138
|
});
|
|
139
|
+
// ── detectPackageManager tests ────────────────────────────────────────────────
|
|
140
|
+
describe("detectPackageManager", () => {
|
|
141
|
+
let repoDir;
|
|
142
|
+
beforeEach(() => {
|
|
143
|
+
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), "oa-pm-detect-"));
|
|
144
|
+
});
|
|
145
|
+
afterEach(() => {
|
|
146
|
+
fs.rmSync(repoDir, { recursive: true, force: true });
|
|
147
|
+
});
|
|
148
|
+
it("detects pnpm from pnpm-lock.yaml", async () => {
|
|
149
|
+
const { detectPackageManager } = await import("./cleanup.js");
|
|
150
|
+
fs.writeFileSync(path.join(repoDir, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n");
|
|
151
|
+
expect(detectPackageManager(repoDir)).toBe("pnpm");
|
|
152
|
+
});
|
|
153
|
+
it("detects npm from package-lock.json", async () => {
|
|
154
|
+
const { detectPackageManager } = await import("./cleanup.js");
|
|
155
|
+
fs.writeFileSync(path.join(repoDir, "package-lock.json"), '{"lockfileVersion": 3}');
|
|
156
|
+
expect(detectPackageManager(repoDir)).toBe("npm");
|
|
157
|
+
});
|
|
158
|
+
it("detects yarn from yarn.lock", async () => {
|
|
159
|
+
const { detectPackageManager } = await import("./cleanup.js");
|
|
160
|
+
fs.writeFileSync(path.join(repoDir, "yarn.lock"), "# yarn lockfile v1");
|
|
161
|
+
expect(detectPackageManager(repoDir)).toBe("yarn");
|
|
162
|
+
});
|
|
163
|
+
it("detects bun from bun.lock", async () => {
|
|
164
|
+
const { detectPackageManager } = await import("./cleanup.js");
|
|
165
|
+
fs.writeFileSync(path.join(repoDir, "bun.lock"), '{"lockfileVersion": 0}');
|
|
166
|
+
expect(detectPackageManager(repoDir)).toBe("bun");
|
|
167
|
+
});
|
|
168
|
+
it("detects bun from bun.lockb", async () => {
|
|
169
|
+
const { detectPackageManager } = await import("./cleanup.js");
|
|
170
|
+
fs.writeFileSync(path.join(repoDir, "bun.lockb"), Buffer.from([0x00]));
|
|
171
|
+
expect(detectPackageManager(repoDir)).toBe("bun");
|
|
172
|
+
});
|
|
173
|
+
it("returns null when no lockfile exists", async () => {
|
|
174
|
+
const { detectPackageManager } = await import("./cleanup.js");
|
|
175
|
+
expect(detectPackageManager(repoDir)).toBeNull();
|
|
176
|
+
});
|
|
177
|
+
it("prefers pnpm over npm when both lockfiles exist", async () => {
|
|
178
|
+
const { detectPackageManager } = await import("./cleanup.js");
|
|
179
|
+
fs.writeFileSync(path.join(repoDir, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n");
|
|
180
|
+
fs.writeFileSync(path.join(repoDir, "package-lock.json"), '{"lockfileVersion": 3}');
|
|
181
|
+
expect(detectPackageManager(repoDir)).toBe("pnpm");
|
|
182
|
+
});
|
|
183
|
+
});
|
|
139
184
|
// ── isWarmNodeModules tests ───────────────────────────────────────────────────
|
|
140
185
|
describe("isWarmNodeModules", () => {
|
|
141
186
|
let tmpDir;
|
|
@@ -71,3 +71,39 @@ describe("printSummary", () => {
|
|
|
71
71
|
expect(output).not.toContain("FAILURES");
|
|
72
72
|
});
|
|
73
73
|
});
|
|
74
|
+
// ── Empty results behavior (CLI exit logic) ──────────────────────────────────
|
|
75
|
+
// The CLI treats empty results as failure. These tests verify the logic pattern
|
|
76
|
+
// used in cli.ts: `results.length === 0 || results.some(r => !r.succeeded)`
|
|
77
|
+
describe("empty results exit logic", () => {
|
|
78
|
+
function shouldFail(results) {
|
|
79
|
+
return results.length === 0 || results.some((r) => !r.succeeded);
|
|
80
|
+
}
|
|
81
|
+
function shouldPrintSummary(results) {
|
|
82
|
+
return results.length > 0;
|
|
83
|
+
}
|
|
84
|
+
it("treats empty results as failure", () => {
|
|
85
|
+
expect(shouldFail([])).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
it("treats results with a failure as failure", () => {
|
|
88
|
+
expect(shouldFail([makeResult({ succeeded: false })])).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
it("treats all-passing results as success", () => {
|
|
91
|
+
const passing = [
|
|
92
|
+
{
|
|
93
|
+
name: "c1",
|
|
94
|
+
workflow: "ci.yml",
|
|
95
|
+
taskId: "test",
|
|
96
|
+
succeeded: true,
|
|
97
|
+
durationMs: 100,
|
|
98
|
+
debugLogPath: "/tmp/x",
|
|
99
|
+
},
|
|
100
|
+
];
|
|
101
|
+
expect(shouldFail(passing)).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
it("skips summary print for empty results", () => {
|
|
104
|
+
expect(shouldPrintSummary([])).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
it("prints summary for non-empty results", () => {
|
|
107
|
+
expect(shouldPrintSummary([makeResult()])).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -17,6 +17,18 @@ function getSpinnerFrame() {
|
|
|
17
17
|
function fmtMs(ms) {
|
|
18
18
|
return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`;
|
|
19
19
|
}
|
|
20
|
+
function fmtBytes(bytes) {
|
|
21
|
+
if (bytes >= 1000000000) {
|
|
22
|
+
return `${(bytes / 1000000000).toFixed(1)} GB`;
|
|
23
|
+
}
|
|
24
|
+
if (bytes >= 1000000) {
|
|
25
|
+
return `${(bytes / 1000000).toFixed(1)} MB`;
|
|
26
|
+
}
|
|
27
|
+
if (bytes >= 1000) {
|
|
28
|
+
return `${(bytes / 1000).toFixed(1)} KB`;
|
|
29
|
+
}
|
|
30
|
+
return `${bytes} B`;
|
|
31
|
+
}
|
|
20
32
|
// ─── Step node builder ────────────────────────────────────────────────────────
|
|
21
33
|
function buildStepNode(step, job, padW) {
|
|
22
34
|
const pad = (n) => String(n).padStart(padW);
|
|
@@ -72,8 +84,21 @@ function buildJobNodes(job, singleJobMode) {
|
|
|
72
84
|
const bootNode = {
|
|
73
85
|
label: `${getSpinnerFrame()} Starting runner ${job.runnerId} (${elapsed}s)`,
|
|
74
86
|
};
|
|
87
|
+
const children = [];
|
|
88
|
+
if (job.pullProgress) {
|
|
89
|
+
const { phase, currentBytes, totalBytes } = job.pullProgress;
|
|
90
|
+
const pct = totalBytes > 0 ? Math.round((currentBytes / totalBytes) * 100) : 0;
|
|
91
|
+
const label = phase === "extracting" ? "Extracting" : "Downloading";
|
|
92
|
+
children.push({
|
|
93
|
+
label: `${DIM}${label}: ${fmtBytes(currentBytes)} / ${fmtBytes(totalBytes)} (${pct}%)${RESET}`,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
75
96
|
if (job.logDir) {
|
|
76
|
-
|
|
97
|
+
const shortLogDir = job.logDir.replace(/^.*?(agent-ci\/)/, "$1");
|
|
98
|
+
children.push({ label: `${DIM}Logs: ${shortLogDir}${RESET}` });
|
|
99
|
+
}
|
|
100
|
+
if (children.length > 0) {
|
|
101
|
+
bootNode.children = children;
|
|
77
102
|
}
|
|
78
103
|
return [bootNode];
|
|
79
104
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from "path";
|
|
2
2
|
import fs from "fs";
|
|
3
3
|
import { getWorkingDirectory } from "../output/working-directory.js";
|
|
4
|
-
import { computeLockfileHash, repairWarmCache } from "../output/cleanup.js";
|
|
4
|
+
import { computeLockfileHash, detectPackageManager, repairWarmCache } from "../output/cleanup.js";
|
|
5
5
|
import { config } from "../config.js";
|
|
6
6
|
import { findRepoRoot } from "./metadata.js";
|
|
7
7
|
import { debugRunner } from "../output/debug.js";
|
|
@@ -23,14 +23,26 @@ export function createRunDirectories(opts) {
|
|
|
23
23
|
// Shared caches
|
|
24
24
|
const repoSlug = (githubRepo || config.GITHUB_REPO).replace("/", "-");
|
|
25
25
|
const toolCacheDir = path.resolve(workDir, "cache", "toolcache");
|
|
26
|
-
const pnpmStoreDir = path.resolve(workDir, "cache", "pnpm-store", repoSlug);
|
|
27
|
-
const npmCacheDir = path.resolve(workDir, "cache", "npm-cache", repoSlug);
|
|
28
|
-
const bunCacheDir = path.resolve(workDir, "cache", "bun-cache", repoSlug);
|
|
29
26
|
const playwrightCacheDir = path.resolve(workDir, "cache", "playwright", repoSlug);
|
|
27
|
+
// Detect the project's package manager so we only mount the relevant cache
|
|
28
|
+
let detectedPM = null;
|
|
29
|
+
const repoRoot = workflowPath ? findRepoRoot(workflowPath) : undefined;
|
|
30
|
+
if (repoRoot) {
|
|
31
|
+
detectedPM = detectPackageManager(repoRoot);
|
|
32
|
+
}
|
|
33
|
+
// Only create cache dirs for the detected PM (or all if unknown)
|
|
34
|
+
const pnpmStoreDir = !detectedPM || detectedPM === "pnpm"
|
|
35
|
+
? path.resolve(workDir, "cache", "pnpm-store", repoSlug)
|
|
36
|
+
: undefined;
|
|
37
|
+
const npmCacheDir = !detectedPM || detectedPM === "npm"
|
|
38
|
+
? path.resolve(workDir, "cache", "npm-cache", repoSlug)
|
|
39
|
+
: undefined;
|
|
40
|
+
const bunCacheDir = !detectedPM || detectedPM === "bun"
|
|
41
|
+
? path.resolve(workDir, "cache", "bun-cache", repoSlug)
|
|
42
|
+
: undefined;
|
|
30
43
|
// Warm node_modules: keyed by the lockfile hash (any supported PM)
|
|
31
44
|
let lockfileHash = "no-lockfile";
|
|
32
45
|
try {
|
|
33
|
-
const repoRoot = workflowPath ? findRepoRoot(workflowPath) : undefined;
|
|
34
46
|
if (repoRoot) {
|
|
35
47
|
lockfileHash = computeLockfileHash(repoRoot);
|
|
36
48
|
}
|
|
@@ -55,7 +67,7 @@ export function createRunDirectories(opts) {
|
|
|
55
67
|
bunCacheDir,
|
|
56
68
|
playwrightCacheDir,
|
|
57
69
|
warmModulesDir,
|
|
58
|
-
];
|
|
70
|
+
].filter((d) => d !== undefined);
|
|
59
71
|
for (const dir of allDirs) {
|
|
60
72
|
fs.mkdirSync(dir, { recursive: true, mode: 0o777 });
|
|
61
73
|
}
|
|
@@ -79,6 +91,7 @@ export function createRunDirectories(opts) {
|
|
|
79
91
|
warmModulesDir,
|
|
80
92
|
workspaceDir,
|
|
81
93
|
repoSlug,
|
|
94
|
+
detectedPM,
|
|
82
95
|
};
|
|
83
96
|
}
|
|
84
97
|
// ─── Permissions helper ───────────────────────────────────────────────────────
|
|
@@ -2,6 +2,7 @@ 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
|
+
import { execSync } from "node:child_process";
|
|
5
6
|
// ── ensureWorldWritable ───────────────────────────────────────────────────────
|
|
6
7
|
describe("ensureWorldWritable", () => {
|
|
7
8
|
let tmpDir;
|
|
@@ -29,3 +30,181 @@ describe("ensureWorldWritable", () => {
|
|
|
29
30
|
expect(() => ensureWorldWritable(["/nonexistent/path"])).not.toThrow();
|
|
30
31
|
});
|
|
31
32
|
});
|
|
33
|
+
// ── Package manager detection + conditional cache dirs ────────────────────────
|
|
34
|
+
describe("createRunDirectories — PM-scoped caching", () => {
|
|
35
|
+
let repoDir;
|
|
36
|
+
let runDir;
|
|
37
|
+
/** Scaffold a git repo with a workflow file and the given lockfile. */
|
|
38
|
+
function makeFixture(lockfileName, lockfileContent) {
|
|
39
|
+
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), "pm-fixture-"));
|
|
40
|
+
runDir = fs.mkdtempSync(path.join(os.tmpdir(), "pm-rundir-"));
|
|
41
|
+
execSync("git init", { cwd: repoDir, stdio: "pipe" });
|
|
42
|
+
execSync('git config user.name "test"', { cwd: repoDir, stdio: "pipe" });
|
|
43
|
+
execSync('git config user.email "t@t.com"', { cwd: repoDir, stdio: "pipe" });
|
|
44
|
+
// Lockfile
|
|
45
|
+
fs.writeFileSync(path.join(repoDir, lockfileName), lockfileContent);
|
|
46
|
+
// Minimal workflow
|
|
47
|
+
const wfDir = path.join(repoDir, ".github", "workflows");
|
|
48
|
+
fs.mkdirSync(wfDir, { recursive: true });
|
|
49
|
+
fs.writeFileSync(path.join(wfDir, "ci.yml"), "name: CI\non: [push]\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo ok\n");
|
|
50
|
+
execSync("git add -A", { cwd: repoDir, stdio: "pipe" });
|
|
51
|
+
execSync('git commit -m "init"', { cwd: repoDir, stdio: "pipe" });
|
|
52
|
+
}
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
if (repoDir) {
|
|
55
|
+
fs.rmSync(repoDir, { recursive: true, force: true });
|
|
56
|
+
}
|
|
57
|
+
if (runDir) {
|
|
58
|
+
fs.rmSync(runDir, { recursive: true, force: true });
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
it("npm: only creates npm cache dir, not pnpm or bun", async () => {
|
|
62
|
+
makeFixture("package-lock.json", '{"lockfileVersion":3}');
|
|
63
|
+
const { createRunDirectories } = await import("./directory-setup.js");
|
|
64
|
+
const dirs = createRunDirectories({
|
|
65
|
+
runDir,
|
|
66
|
+
githubRepo: "test/npm-project",
|
|
67
|
+
workflowPath: path.join(repoDir, ".github", "workflows", "ci.yml"),
|
|
68
|
+
});
|
|
69
|
+
expect(dirs.detectedPM).toBe("npm");
|
|
70
|
+
expect(dirs.npmCacheDir).toBeDefined();
|
|
71
|
+
expect(dirs.pnpmStoreDir).toBeUndefined();
|
|
72
|
+
expect(dirs.bunCacheDir).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
it("pnpm: only creates pnpm cache dir, not npm or bun", async () => {
|
|
75
|
+
makeFixture("pnpm-lock.yaml", "lockfileVersion: '9.0'\n");
|
|
76
|
+
const { createRunDirectories } = await import("./directory-setup.js");
|
|
77
|
+
const dirs = createRunDirectories({
|
|
78
|
+
runDir,
|
|
79
|
+
githubRepo: "test/pnpm-project",
|
|
80
|
+
workflowPath: path.join(repoDir, ".github", "workflows", "ci.yml"),
|
|
81
|
+
});
|
|
82
|
+
expect(dirs.detectedPM).toBe("pnpm");
|
|
83
|
+
expect(dirs.pnpmStoreDir).toBeDefined();
|
|
84
|
+
expect(dirs.npmCacheDir).toBeUndefined();
|
|
85
|
+
expect(dirs.bunCacheDir).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
it("yarn: creates no PM-specific cache dirs (no dedicated mount)", async () => {
|
|
88
|
+
makeFixture("yarn.lock", "# yarn lockfile v1\n");
|
|
89
|
+
const { createRunDirectories } = await import("./directory-setup.js");
|
|
90
|
+
const dirs = createRunDirectories({
|
|
91
|
+
runDir,
|
|
92
|
+
githubRepo: "test/yarn-project",
|
|
93
|
+
workflowPath: path.join(repoDir, ".github", "workflows", "ci.yml"),
|
|
94
|
+
});
|
|
95
|
+
expect(dirs.detectedPM).toBe("yarn");
|
|
96
|
+
expect(dirs.pnpmStoreDir).toBeUndefined();
|
|
97
|
+
expect(dirs.npmCacheDir).toBeUndefined();
|
|
98
|
+
expect(dirs.bunCacheDir).toBeUndefined();
|
|
99
|
+
});
|
|
100
|
+
it("bun: only creates bun cache dir, not pnpm or npm", async () => {
|
|
101
|
+
makeFixture("bun.lock", '{"lockfileVersion":0}');
|
|
102
|
+
const { createRunDirectories } = await import("./directory-setup.js");
|
|
103
|
+
const dirs = createRunDirectories({
|
|
104
|
+
runDir,
|
|
105
|
+
githubRepo: "test/bun-project",
|
|
106
|
+
workflowPath: path.join(repoDir, ".github", "workflows", "ci.yml"),
|
|
107
|
+
});
|
|
108
|
+
expect(dirs.detectedPM).toBe("bun");
|
|
109
|
+
expect(dirs.bunCacheDir).toBeDefined();
|
|
110
|
+
expect(dirs.pnpmStoreDir).toBeUndefined();
|
|
111
|
+
expect(dirs.npmCacheDir).toBeUndefined();
|
|
112
|
+
});
|
|
113
|
+
it("no lockfile: creates all PM cache dirs (fallback)", async () => {
|
|
114
|
+
// Repo with no lockfile at all
|
|
115
|
+
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), "pm-fixture-"));
|
|
116
|
+
runDir = fs.mkdtempSync(path.join(os.tmpdir(), "pm-rundir-"));
|
|
117
|
+
execSync("git init", { cwd: repoDir, stdio: "pipe" });
|
|
118
|
+
execSync('git config user.name "test"', { cwd: repoDir, stdio: "pipe" });
|
|
119
|
+
execSync('git config user.email "t@t.com"', { cwd: repoDir, stdio: "pipe" });
|
|
120
|
+
const wfDir = path.join(repoDir, ".github", "workflows");
|
|
121
|
+
fs.mkdirSync(wfDir, { recursive: true });
|
|
122
|
+
fs.writeFileSync(path.join(wfDir, "ci.yml"), "name: CI\non: [push]\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo ok\n");
|
|
123
|
+
execSync("git add -A", { cwd: repoDir, stdio: "pipe" });
|
|
124
|
+
execSync('git commit -m "init"', { cwd: repoDir, stdio: "pipe" });
|
|
125
|
+
const { createRunDirectories } = await import("./directory-setup.js");
|
|
126
|
+
const dirs = createRunDirectories({
|
|
127
|
+
runDir,
|
|
128
|
+
githubRepo: "test/no-pm-project",
|
|
129
|
+
workflowPath: path.join(repoDir, ".github", "workflows", "ci.yml"),
|
|
130
|
+
});
|
|
131
|
+
expect(dirs.detectedPM).toBeNull();
|
|
132
|
+
expect(dirs.pnpmStoreDir).toBeDefined();
|
|
133
|
+
expect(dirs.npmCacheDir).toBeDefined();
|
|
134
|
+
expect(dirs.bunCacheDir).toBeDefined();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
// ── Bind mounts respect detected PM ──────────────────────────────────────────
|
|
138
|
+
describe("buildContainerBinds — PM-scoped mounts", () => {
|
|
139
|
+
it("npm project: only mounts .npm, no .pnpm-store or .bun", async () => {
|
|
140
|
+
const { buildContainerBinds } = await import("../docker/container-config.js");
|
|
141
|
+
const binds = buildContainerBinds({
|
|
142
|
+
hostWorkDir: "/tmp/work",
|
|
143
|
+
shimsDir: "/tmp/shims",
|
|
144
|
+
diagDir: "/tmp/diag",
|
|
145
|
+
toolCacheDir: "/tmp/toolcache",
|
|
146
|
+
npmCacheDir: "/tmp/npm-cache",
|
|
147
|
+
// pnpmStoreDir and bunCacheDir intentionally omitted (npm project)
|
|
148
|
+
playwrightCacheDir: "/tmp/playwright",
|
|
149
|
+
warmModulesDir: "/tmp/warm",
|
|
150
|
+
hostRunnerDir: "/tmp/runner",
|
|
151
|
+
useDirectContainer: false,
|
|
152
|
+
});
|
|
153
|
+
expect(binds).toContain("/tmp/npm-cache:/home/runner/.npm");
|
|
154
|
+
expect(binds.some((b) => b.includes(".pnpm-store"))).toBe(false);
|
|
155
|
+
expect(binds.some((b) => b.includes(".bun/install"))).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
it("pnpm project: only mounts .pnpm-store, no .npm or .bun", async () => {
|
|
158
|
+
const { buildContainerBinds } = await import("../docker/container-config.js");
|
|
159
|
+
const binds = buildContainerBinds({
|
|
160
|
+
hostWorkDir: "/tmp/work",
|
|
161
|
+
shimsDir: "/tmp/shims",
|
|
162
|
+
diagDir: "/tmp/diag",
|
|
163
|
+
toolCacheDir: "/tmp/toolcache",
|
|
164
|
+
pnpmStoreDir: "/tmp/pnpm-store",
|
|
165
|
+
// npmCacheDir and bunCacheDir intentionally omitted (pnpm project)
|
|
166
|
+
playwrightCacheDir: "/tmp/playwright",
|
|
167
|
+
warmModulesDir: "/tmp/warm",
|
|
168
|
+
hostRunnerDir: "/tmp/runner",
|
|
169
|
+
useDirectContainer: false,
|
|
170
|
+
});
|
|
171
|
+
expect(binds).toContain("/tmp/pnpm-store:/home/runner/_work/.pnpm-store");
|
|
172
|
+
expect(binds.some((b) => b.includes("/.npm"))).toBe(false);
|
|
173
|
+
expect(binds.some((b) => b.includes(".bun/install"))).toBe(false);
|
|
174
|
+
});
|
|
175
|
+
it("bun project: only mounts .bun, no .pnpm-store or .npm", async () => {
|
|
176
|
+
const { buildContainerBinds } = await import("../docker/container-config.js");
|
|
177
|
+
const binds = buildContainerBinds({
|
|
178
|
+
hostWorkDir: "/tmp/work",
|
|
179
|
+
shimsDir: "/tmp/shims",
|
|
180
|
+
diagDir: "/tmp/diag",
|
|
181
|
+
toolCacheDir: "/tmp/toolcache",
|
|
182
|
+
bunCacheDir: "/tmp/bun-cache",
|
|
183
|
+
// pnpmStoreDir and npmCacheDir intentionally omitted (bun project)
|
|
184
|
+
playwrightCacheDir: "/tmp/playwright",
|
|
185
|
+
warmModulesDir: "/tmp/warm",
|
|
186
|
+
hostRunnerDir: "/tmp/runner",
|
|
187
|
+
useDirectContainer: false,
|
|
188
|
+
});
|
|
189
|
+
expect(binds).toContain("/tmp/bun-cache:/home/runner/.bun/install/cache");
|
|
190
|
+
expect(binds.some((b) => b.includes(".pnpm-store"))).toBe(false);
|
|
191
|
+
expect(binds.some((b) => b.includes("/.npm"))).toBe(false);
|
|
192
|
+
});
|
|
193
|
+
it("yarn project: no PM-specific mounts at all", async () => {
|
|
194
|
+
const { buildContainerBinds } = await import("../docker/container-config.js");
|
|
195
|
+
const binds = buildContainerBinds({
|
|
196
|
+
hostWorkDir: "/tmp/work",
|
|
197
|
+
shimsDir: "/tmp/shims",
|
|
198
|
+
diagDir: "/tmp/diag",
|
|
199
|
+
toolCacheDir: "/tmp/toolcache",
|
|
200
|
+
// all PM dirs omitted (yarn has no dedicated mount)
|
|
201
|
+
playwrightCacheDir: "/tmp/playwright",
|
|
202
|
+
warmModulesDir: "/tmp/warm",
|
|
203
|
+
hostRunnerDir: "/tmp/runner",
|
|
204
|
+
useDirectContainer: false,
|
|
205
|
+
});
|
|
206
|
+
expect(binds.some((b) => b.includes(".pnpm-store"))).toBe(false);
|
|
207
|
+
expect(binds.some((b) => b.includes("/.npm"))).toBe(false);
|
|
208
|
+
expect(binds.some((b) => b.includes(".bun/install"))).toBe(false);
|
|
209
|
+
});
|
|
210
|
+
});
|
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
|
|
19
|
+
import { buildJobResult } from "./result-builder.js";
|
|
20
20
|
import { wrapJobSteps, appendOutputCaptureStep } from "./step-wrapper.js";
|
|
21
21
|
import { syncWorkspaceForRetry } from "./sync.js";
|
|
22
22
|
// ─── Docker setup ─────────────────────────────────────────────────────────────
|
|
@@ -127,6 +127,15 @@ export async function executeLocalJob(job, options) {
|
|
|
127
127
|
const dtuUrl = ephemeralDtu?.url ?? config.GITHUB_API_URL;
|
|
128
128
|
const dtuContainerUrl = ephemeralDtu?.containerUrl ?? dtuUrl;
|
|
129
129
|
t0 = bt("dtu-start", t0);
|
|
130
|
+
// ── Create run directories ────────────────────────────────────────────────
|
|
131
|
+
// Done before DTU registration so we can use the detected package manager
|
|
132
|
+
// to scope virtualCachePatterns to only the relevant PM.
|
|
133
|
+
const dirs = createRunDirectories({
|
|
134
|
+
runDir,
|
|
135
|
+
githubRepo: job.githubRepo,
|
|
136
|
+
workflowPath: job.workflowPath,
|
|
137
|
+
});
|
|
138
|
+
debugRunner(`Detected package manager: ${dirs.detectedPM ?? "none (mounting all PM caches)"}`);
|
|
130
139
|
await fetch(`${dtuUrl}/_dtu/start-runner`, {
|
|
131
140
|
method: "POST",
|
|
132
141
|
headers: { "Content-Type": "application/json" },
|
|
@@ -138,7 +147,7 @@ export async function executeLocalJob(job, options) {
|
|
|
138
147
|
// no need for the runner to tar/gzip them. Tell the DTU to return a
|
|
139
148
|
// synthetic hit for any cache key matching these patterns — skipping the
|
|
140
149
|
// 60s+ tar entirely.
|
|
141
|
-
virtualCachePatterns: ["pnpm", "npm", "yarn", "bun"],
|
|
150
|
+
virtualCachePatterns: dirs.detectedPM ? [dirs.detectedPM] : ["pnpm", "npm", "yarn", "bun"],
|
|
142
151
|
}),
|
|
143
152
|
}).catch(() => {
|
|
144
153
|
/* non-fatal */
|
|
@@ -148,12 +157,6 @@ export async function executeLocalJob(job, options) {
|
|
|
148
157
|
writeJobMetadata({ logDir, containerName, job });
|
|
149
158
|
// Open debug stream to capture raw container output
|
|
150
159
|
const debugStream = fs.createWriteStream(debugLogPath);
|
|
151
|
-
// ── Create run directories ────────────────────────────────────────────────
|
|
152
|
-
const dirs = createRunDirectories({
|
|
153
|
-
runDir,
|
|
154
|
-
githubRepo: job.githubRepo,
|
|
155
|
-
workflowPath: job.workflowPath,
|
|
156
|
-
});
|
|
157
160
|
// Signal handler: ensure cleanup runs even when killed.
|
|
158
161
|
const signalCleanup = () => {
|
|
159
162
|
killRunnerContainers(containerName);
|
|
@@ -305,11 +308,81 @@ export async function executeLocalJob(job, options) {
|
|
|
305
308
|
if (err) {
|
|
306
309
|
return reject(err);
|
|
307
310
|
}
|
|
311
|
+
// Track per-layer progress across download and extraction phases
|
|
312
|
+
const downloadProgress = new Map();
|
|
313
|
+
const extractProgress = new Map();
|
|
314
|
+
let lastProgressUpdate = 0;
|
|
315
|
+
let currentPhase = "downloading";
|
|
316
|
+
const flushProgress = (force = false) => {
|
|
317
|
+
const map = currentPhase === "downloading" ? downloadProgress : extractProgress;
|
|
318
|
+
if (map.size === 0) {
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const now = Date.now();
|
|
322
|
+
if (!force && now - lastProgressUpdate < 250) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
lastProgressUpdate = now;
|
|
326
|
+
let totalBytes = 0;
|
|
327
|
+
let currentBytes = 0;
|
|
328
|
+
for (const layer of map.values()) {
|
|
329
|
+
totalBytes += layer.total;
|
|
330
|
+
currentBytes += layer.current;
|
|
331
|
+
}
|
|
332
|
+
store?.updateJob(containerName, {
|
|
333
|
+
pullProgress: { phase: currentPhase, currentBytes, totalBytes },
|
|
334
|
+
});
|
|
335
|
+
};
|
|
308
336
|
docker.modem.followProgress(stream, (err) => {
|
|
309
337
|
if (err) {
|
|
310
338
|
return reject(err);
|
|
311
339
|
}
|
|
340
|
+
store?.updateJob(containerName, { pullProgress: undefined });
|
|
312
341
|
resolve();
|
|
342
|
+
}, (event) => {
|
|
343
|
+
if (!event.id) {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const detail = event.progressDetail;
|
|
347
|
+
const hasByteCounts = detail &&
|
|
348
|
+
typeof detail.current === "number" &&
|
|
349
|
+
typeof detail.total === "number" &&
|
|
350
|
+
detail.total > 0;
|
|
351
|
+
if (event.status === "Downloading" && hasByteCounts) {
|
|
352
|
+
downloadProgress.set(event.id, {
|
|
353
|
+
current: detail.current,
|
|
354
|
+
total: detail.total,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
else if (event.status === "Download complete") {
|
|
358
|
+
const existing = downloadProgress.get(event.id);
|
|
359
|
+
if (existing) {
|
|
360
|
+
existing.current = existing.total;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
else if (event.status === "Extracting" && hasByteCounts) {
|
|
364
|
+
const phaseChanged = currentPhase !== "extracting";
|
|
365
|
+
currentPhase = "extracting";
|
|
366
|
+
extractProgress.set(event.id, {
|
|
367
|
+
current: detail.current,
|
|
368
|
+
total: detail.total,
|
|
369
|
+
});
|
|
370
|
+
// Force update on first extraction event so the phase change is visible immediately
|
|
371
|
+
if (phaseChanged) {
|
|
372
|
+
flushProgress(true);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
else if (event.status === "Pull complete") {
|
|
377
|
+
const existing = extractProgress.get(event.id);
|
|
378
|
+
if (existing) {
|
|
379
|
+
existing.current = existing.total;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
flushProgress();
|
|
313
386
|
});
|
|
314
387
|
});
|
|
315
388
|
});
|
|
@@ -422,44 +495,24 @@ export async function executeLocalJob(job, options) {
|
|
|
422
495
|
const lines = content.split("\n");
|
|
423
496
|
pausedStepName = lines[0] || null;
|
|
424
497
|
const attempt = parseInt(lines[1] || "1", 10);
|
|
425
|
-
|
|
498
|
+
const isNewAttempt = attempt !== lastSeenAttempt;
|
|
499
|
+
if (isNewAttempt) {
|
|
426
500
|
lastSeenAttempt = attempt;
|
|
427
501
|
isPaused = true;
|
|
428
502
|
pausedAtMs = Date.now();
|
|
429
503
|
setupStdinRetry();
|
|
430
|
-
// Read last output lines from the failed step's log
|
|
431
|
-
let tailLines = [];
|
|
432
|
-
if (pausedStepName) {
|
|
433
|
-
const stepsDir = path.join(logDir, "steps");
|
|
434
|
-
const sanitized = sanitizeStepName(pausedStepName);
|
|
435
|
-
const byName = path.join(stepsDir, `${sanitized}.log`);
|
|
436
|
-
tailLines = tailLogFile(byName, 20);
|
|
437
|
-
if (tailLines.length === 0 && fs.existsSync(stepsDir)) {
|
|
438
|
-
let newest = "";
|
|
439
|
-
let newestMtime = 0;
|
|
440
|
-
for (const f of fs.readdirSync(stepsDir)) {
|
|
441
|
-
if (!f.endsWith(".log")) {
|
|
442
|
-
continue;
|
|
443
|
-
}
|
|
444
|
-
const mt = fs.statSync(path.join(stepsDir, f)).mtimeMs;
|
|
445
|
-
if (mt > newestMtime) {
|
|
446
|
-
newestMtime = mt;
|
|
447
|
-
newest = f;
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
if (newest) {
|
|
451
|
-
tailLines = tailLogFile(path.join(stepsDir, newest), 20);
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
store?.updateJob(containerName, {
|
|
456
|
-
status: "paused",
|
|
457
|
-
pausedAtStep: pausedStepName || undefined,
|
|
458
|
-
pausedAtMs: new Date(pausedAtMs).toISOString(),
|
|
459
|
-
attempt: lastSeenAttempt,
|
|
460
|
-
lastOutputLines: tailLines,
|
|
461
|
-
});
|
|
462
504
|
}
|
|
505
|
+
// Read output captured by the wrapper script's tee — written directly
|
|
506
|
+
// to the signals dir so it's always available when paused.
|
|
507
|
+
const tailLines = tailLogFile(path.join(dirs.signalsDir, "step-output"), 20);
|
|
508
|
+
store?.updateJob(containerName, {
|
|
509
|
+
status: "paused",
|
|
510
|
+
pausedAtStep: pausedStepName || undefined,
|
|
511
|
+
...(isNewAttempt && pausedAtMs !== null
|
|
512
|
+
? { pausedAtMs: new Date(pausedAtMs).toISOString(), attempt: lastSeenAttempt }
|
|
513
|
+
: {}),
|
|
514
|
+
lastOutputLines: tailLines,
|
|
515
|
+
});
|
|
463
516
|
}
|
|
464
517
|
else if (isPaused && !fs.existsSync(pausedSignalPath)) {
|
|
465
518
|
// Pause signal removed — job is retrying
|
|
@@ -697,7 +750,13 @@ export async function executeLocalJob(job, options) {
|
|
|
697
750
|
}
|
|
698
751
|
}
|
|
699
752
|
if (jobSucceeded && fs.existsSync(dirs.containerWorkDir)) {
|
|
700
|
-
|
|
753
|
+
try {
|
|
754
|
+
fs.rmSync(dirs.containerWorkDir, { recursive: true, force: true });
|
|
755
|
+
}
|
|
756
|
+
catch {
|
|
757
|
+
// Best-effort cleanup — ENOTEMPTY can occur when container
|
|
758
|
+
// processes haven't fully released file handles yet.
|
|
759
|
+
}
|
|
701
760
|
}
|
|
702
761
|
await ephemeralDtu?.close().catch(() => { });
|
|
703
762
|
return buildJobResult({
|
|
@@ -33,6 +33,10 @@ describe("wrapStepScript", () => {
|
|
|
33
33
|
const wrapped = wrapStepScript("npm test", "My Step", 1);
|
|
34
34
|
expect(wrapped).toContain(`"$__FROM_STEP" != '*'`);
|
|
35
35
|
});
|
|
36
|
+
it("captures output via tee to signals dir", () => {
|
|
37
|
+
const wrapped = wrapStepScript("npm test", "Run tests", 1);
|
|
38
|
+
expect(wrapped).toContain('> >(tee "$__SIGNALS/step-output") 2>&1');
|
|
39
|
+
});
|
|
36
40
|
});
|
|
37
41
|
// ── wrapJobSteps ──────────────────────────────────────────────────────────────
|
|
38
42
|
describe("wrapJobSteps", () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@redwoodjs/agent-ci",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Local GitHub Actions runner — pause on failure, ~0ms cache, official runner binary. Built for AI coding agents.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"act-alternative",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"log-update": "^7.2.0",
|
|
41
41
|
"minimatch": "^10.2.1",
|
|
42
42
|
"yaml": "^2.8.2",
|
|
43
|
-
"dtu-github-actions": "0.
|
|
43
|
+
"dtu-github-actions": "0.7.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@types/dockerode": "^3.3.34",
|