@redwoodjs/agent-ci 0.8.1 → 0.9.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 +43 -12
- package/dist/config.js +36 -24
- package/dist/config.test.js +77 -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/image-pull.js +42 -0
- package/dist/docker/image-pull.test.js +38 -0
- package/dist/docker/shutdown.js +43 -0
- package/dist/docker/shutdown.test.js +73 -1
- package/dist/output/reporter.js +31 -8
- package/dist/output/reporter.test.js +44 -0
- package/dist/output/state-renderer.js +7 -7
- package/dist/output/state-renderer.test.js +47 -4
- package/dist/runner/directory-setup.test.js +4 -0
- package/dist/runner/dirty-sha.js +64 -0
- package/dist/runner/dirty-sha.test.js +101 -0
- package/dist/runner/git-shim.js +0 -8
- package/dist/runner/git-shim.test.js +0 -15
- package/dist/runner/local-job.js +22 -8
- package/dist/workflow/workflow-parser.js +1 -1
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -7,17 +7,18 @@ import { getNextLogNum } from "./output/logger.js";
|
|
|
7
7
|
import { setWorkingDirectory, DEFAULT_WORKING_DIR, PROJECT_ROOT, } from "./output/working-directory.js";
|
|
8
8
|
import { debugCli } from "./output/debug.js";
|
|
9
9
|
import { executeLocalJob } from "./runner/local-job.js";
|
|
10
|
-
import { parseWorkflowSteps, parseWorkflowServices, parseWorkflowContainer, validateSecrets, parseMatrixDef, expandMatrixCombinations, collapseMatrixToSingle, isWorkflowRelevant, getChangedFiles, parseJobOutputDefs, parseJobIf, evaluateJobIf, parseFailFast, expandExpressions, } from "./workflow/workflow-parser.js";
|
|
10
|
+
import { parseWorkflowSteps, parseWorkflowServices, parseWorkflowContainer, validateSecrets, extractSecretRefs, parseMatrixDef, expandMatrixCombinations, collapseMatrixToSingle, isWorkflowRelevant, getChangedFiles, parseJobOutputDefs, parseJobIf, evaluateJobIf, parseFailFast, expandExpressions, } from "./workflow/workflow-parser.js";
|
|
11
11
|
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";
|
|
19
19
|
import { printSummary } from "./output/reporter.js";
|
|
20
20
|
import { syncWorkspaceForRetry } from "./runner/sync.js";
|
|
21
|
+
import { computeDirtySha } from "./runner/dirty-sha.js";
|
|
21
22
|
import { RunStateStore } from "./output/run-state.js";
|
|
22
23
|
import { renderRunState } from "./output/state-renderer.js";
|
|
23
24
|
import { isAgentMode, setQuietMode } from "./output/agent-mode.js";
|
|
@@ -489,9 +490,12 @@ async function handleWorkflow(options) {
|
|
|
489
490
|
const { headSha, shaRef } = sha
|
|
490
491
|
? resolveHeadSha(repoRoot, sha)
|
|
491
492
|
: { headSha: undefined, shaRef: undefined };
|
|
492
|
-
// Always resolve
|
|
493
|
-
//
|
|
494
|
-
|
|
493
|
+
// Always resolve a SHA that represents the code being executed.
|
|
494
|
+
// When the working tree is dirty and no explicit --sha was given, compute an
|
|
495
|
+
// ephemeral commit SHA that captures the dirty state (including untracked files).
|
|
496
|
+
// This is purely informational — actions/checkout is always stubbed, so no
|
|
497
|
+
// workflow will ever try to fetch this SHA from a remote.
|
|
498
|
+
const realHeadSha = headSha ?? computeDirtySha(repoRoot) ?? resolveHeadSha(repoRoot, "HEAD").headSha;
|
|
495
499
|
const baseSha = resolveBaseSha(repoRoot, realHeadSha);
|
|
496
500
|
const githubRepo = config.GITHUB_REPO ?? resolveRepoSlug(repoRoot);
|
|
497
501
|
config.GITHUB_REPO = githubRepo;
|
|
@@ -546,7 +550,11 @@ async function handleWorkflow(options) {
|
|
|
546
550
|
if (expandedJobs.length === 1) {
|
|
547
551
|
const ej = expandedJobs[0];
|
|
548
552
|
const actualTaskName = ej.sourceTaskName ?? ej.taskName;
|
|
549
|
-
const
|
|
553
|
+
const requiredRefs = extractSecretRefs(ej.workflowPath, actualTaskName);
|
|
554
|
+
const secrets = loadMachineSecrets(repoRoot, requiredRefs);
|
|
555
|
+
if (githubToken && !secrets["GITHUB_TOKEN"]) {
|
|
556
|
+
secrets["GITHUB_TOKEN"] = githubToken;
|
|
557
|
+
}
|
|
550
558
|
const secretsFilePath = path.join(repoRoot, ".env.agent-ci");
|
|
551
559
|
validateSecrets(ej.workflowPath, actualTaskName, secrets, secretsFilePath);
|
|
552
560
|
// Resolve inputs for called workflow jobs
|
|
@@ -608,7 +616,11 @@ async function handleWorkflow(options) {
|
|
|
608
616
|
let globalIdx = 0;
|
|
609
617
|
const buildJob = (ej) => {
|
|
610
618
|
const actualTaskName = ej.sourceTaskName ?? ej.taskName;
|
|
611
|
-
const
|
|
619
|
+
const requiredRefs = extractSecretRefs(ej.workflowPath, actualTaskName);
|
|
620
|
+
const secrets = loadMachineSecrets(repoRoot, requiredRefs);
|
|
621
|
+
if (githubToken && !secrets["GITHUB_TOKEN"]) {
|
|
622
|
+
secrets["GITHUB_TOKEN"] = githubToken;
|
|
623
|
+
}
|
|
612
624
|
const secretsFilePath = path.join(repoRoot, ".env.agent-ci");
|
|
613
625
|
validateSecrets(ej.workflowPath, actualTaskName, secrets, secretsFilePath);
|
|
614
626
|
const idx = globalIdx++;
|
|
@@ -668,7 +680,11 @@ async function handleWorkflow(options) {
|
|
|
668
680
|
const { taskName, matrixContext } = ej;
|
|
669
681
|
const actualTaskName = ej.sourceTaskName ?? taskName;
|
|
670
682
|
debugCli(`Running: ${path.basename(ej.workflowPath)} | Task: ${taskName}${matrixContext ? ` | Matrix: ${JSON.stringify(Object.fromEntries(Object.entries(matrixContext).filter(([k]) => !k.startsWith("__"))))}` : ""}`);
|
|
671
|
-
const
|
|
683
|
+
const requiredRefs = extractSecretRefs(ej.workflowPath, actualTaskName);
|
|
684
|
+
const secrets = loadMachineSecrets(repoRoot, requiredRefs);
|
|
685
|
+
if (githubToken && !secrets["GITHUB_TOKEN"]) {
|
|
686
|
+
secrets["GITHUB_TOKEN"] = githubToken;
|
|
687
|
+
}
|
|
672
688
|
const secretsFilePath = path.join(repoRoot, ".env.agent-ci");
|
|
673
689
|
validateSecrets(ej.workflowPath, actualTaskName, secrets, secretsFilePath);
|
|
674
690
|
const inputsContext = resolveInputsForJob(ej, secrets, needsContext);
|
|
@@ -692,6 +708,8 @@ async function handleWorkflow(options) {
|
|
|
692
708
|
return result;
|
|
693
709
|
};
|
|
694
710
|
pruneOrphanedDockerResources();
|
|
711
|
+
killOrphanedContainers();
|
|
712
|
+
pruneStaleWorkspaces(getWorkingDirectory(), 24 * 60 * 60 * 1000);
|
|
695
713
|
const limiter = createConcurrencyLimiter(maxJobs);
|
|
696
714
|
const allResults = [];
|
|
697
715
|
// Accumulate job outputs across waves for needs.*.outputs.* resolution
|
|
@@ -833,6 +851,7 @@ async function handleWorkflow(options) {
|
|
|
833
851
|
collectOutputs(result, ej.taskName);
|
|
834
852
|
return result;
|
|
835
853
|
};
|
|
854
|
+
const seenErrorMessages = new Set();
|
|
836
855
|
for (let wi = 0; wi < filteredWaves.length; wi++) {
|
|
837
856
|
const waveJobIds = new Set(filteredWaves[wi]);
|
|
838
857
|
const waveJobs = expandedJobs.filter((j) => waveJobIds.has(j.taskName));
|
|
@@ -854,8 +873,11 @@ async function handleWorkflow(options) {
|
|
|
854
873
|
else {
|
|
855
874
|
const taskName = isJobError(r.reason) ? r.reason.taskName : "unknown";
|
|
856
875
|
const errorMessage = isJobError(r.reason) ? r.reason.message : String(r.reason);
|
|
857
|
-
|
|
858
|
-
|
|
876
|
+
if (!seenErrorMessages.has(errorMessage)) {
|
|
877
|
+
seenErrorMessages.add(errorMessage);
|
|
878
|
+
console.error(`\n[Agent CI] Job failed with error: ${taskName}`);
|
|
879
|
+
console.error(` Error: ${errorMessage}`);
|
|
880
|
+
}
|
|
859
881
|
allResults.push(createFailedJobResult(taskName, workflowPath, r.reason));
|
|
860
882
|
}
|
|
861
883
|
}
|
|
@@ -872,8 +894,11 @@ async function handleWorkflow(options) {
|
|
|
872
894
|
else {
|
|
873
895
|
const taskName = isJobError(r.reason) ? r.reason.taskName : "unknown";
|
|
874
896
|
const errorMessage = isJobError(r.reason) ? r.reason.message : String(r.reason);
|
|
875
|
-
|
|
876
|
-
|
|
897
|
+
if (!seenErrorMessages.has(errorMessage)) {
|
|
898
|
+
seenErrorMessages.add(errorMessage);
|
|
899
|
+
console.error(`\n[Agent CI] Job failed with error: ${taskName}`);
|
|
900
|
+
console.error(` Error: ${errorMessage}`);
|
|
901
|
+
}
|
|
877
902
|
allResults.push(createFailedJobResult(taskName, workflowPath, r.reason));
|
|
878
903
|
}
|
|
879
904
|
}
|
|
@@ -920,6 +945,12 @@ function printUsage() {
|
|
|
920
945
|
console.log(" (auto-resolves via `gh auth token` if no value given)");
|
|
921
946
|
console.log(" Or set: AGENT_CI_GITHUB_TOKEN env var");
|
|
922
947
|
console.log(" --commit-status Post a GitHub commit status after the run (requires --github-token)");
|
|
948
|
+
console.log("");
|
|
949
|
+
console.log("Secrets:");
|
|
950
|
+
console.log(" Workflow secrets (${{ secrets.FOO }}) are resolved from:");
|
|
951
|
+
console.log(" 1. .env.agent-ci file in the repo root");
|
|
952
|
+
console.log(" 2. Environment variables (shell env acts as fallback)");
|
|
953
|
+
console.log(" 3. --github-token automatically provides secrets.GITHUB_TOKEN");
|
|
923
954
|
}
|
|
924
955
|
function resolveRepoRoot() {
|
|
925
956
|
let repoRoot = process.cwd();
|
package/dist/config.js
CHANGED
|
@@ -55,35 +55,47 @@ export const config = {
|
|
|
55
55
|
GITHUB_API_URL: process.env.GITHUB_API_URL || "http://localhost:8910",
|
|
56
56
|
};
|
|
57
57
|
/**
|
|
58
|
-
* Load machine-local secrets from `.env.
|
|
58
|
+
* Load machine-local secrets from `.env.agent-ci` at the given base directory.
|
|
59
59
|
* The file uses KEY=VALUE syntax (lines starting with # are ignored).
|
|
60
|
-
*
|
|
60
|
+
*
|
|
61
|
+
* When `envFallbackKeys` is provided, any key in that list that is NOT already
|
|
62
|
+
* present in the file will be filled from `process.env` (shell environment
|
|
63
|
+
* variables act as a fallback for the .env file).
|
|
64
|
+
*
|
|
65
|
+
* Returns an empty object if the file doesn't exist and no env fallbacks match.
|
|
61
66
|
*/
|
|
62
|
-
export function loadMachineSecrets(baseDir) {
|
|
67
|
+
export function loadMachineSecrets(baseDir, envFallbackKeys) {
|
|
63
68
|
const envMachinePath = path.join(baseDir ?? PROJECT_ROOT, ".env.agent-ci");
|
|
64
|
-
if (!fs.existsSync(envMachinePath)) {
|
|
65
|
-
return {};
|
|
66
|
-
}
|
|
67
69
|
const secrets = {};
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
(value.startsWith("'
|
|
83
|
-
|
|
70
|
+
if (fs.existsSync(envMachinePath)) {
|
|
71
|
+
const lines = fs.readFileSync(envMachinePath, "utf-8").split("\n");
|
|
72
|
+
for (const line of lines) {
|
|
73
|
+
const trimmed = line.trim();
|
|
74
|
+
if (!trimmed || trimmed.startsWith("#")) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const eqIdx = trimmed.indexOf("=");
|
|
78
|
+
if (eqIdx < 1) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
82
|
+
let value = trimmed.slice(eqIdx + 1).trim();
|
|
83
|
+
// Strip optional surrounding quotes
|
|
84
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
85
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
86
|
+
value = value.slice(1, -1);
|
|
87
|
+
}
|
|
88
|
+
if (key) {
|
|
89
|
+
secrets[key] = value;
|
|
90
|
+
}
|
|
84
91
|
}
|
|
85
|
-
|
|
86
|
-
|
|
92
|
+
}
|
|
93
|
+
// Fill missing secrets from process.env (shell env vars act as fallback)
|
|
94
|
+
if (envFallbackKeys) {
|
|
95
|
+
for (const key of envFallbackKeys) {
|
|
96
|
+
if (!secrets[key] && process.env[key]) {
|
|
97
|
+
secrets[key] = process.env[key];
|
|
98
|
+
}
|
|
87
99
|
}
|
|
88
100
|
}
|
|
89
101
|
return secrets;
|
package/dist/config.test.js
CHANGED
|
@@ -3,7 +3,7 @@ import fs from "fs";
|
|
|
3
3
|
import os from "os";
|
|
4
4
|
import path from "path";
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
|
-
import { config, getFirstRemoteUrl, parseRepoSlug, resolveRepoSlug } from "./config.js";
|
|
6
|
+
import { config, getFirstRemoteUrl, loadMachineSecrets, parseRepoSlug, resolveRepoSlug, } from "./config.js";
|
|
7
7
|
describe("parseRepoSlug", () => {
|
|
8
8
|
it.each([
|
|
9
9
|
["https://github.com/redwoodjs/agent-ci.git", "redwoodjs/agent-ci"],
|
|
@@ -155,3 +155,79 @@ describe("GITHUB_REPO env var override priority", () => {
|
|
|
155
155
|
}).toThrow(/Could not detect GitHub repository/);
|
|
156
156
|
});
|
|
157
157
|
});
|
|
158
|
+
// ─── loadMachineSecrets ──────────────────────────────────────────────────────
|
|
159
|
+
describe("loadMachineSecrets", () => {
|
|
160
|
+
let tmpDir;
|
|
161
|
+
const savedEnv = {};
|
|
162
|
+
function saveEnv(...keys) {
|
|
163
|
+
for (const k of keys) {
|
|
164
|
+
savedEnv[k] = process.env[k];
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
afterEach(() => {
|
|
168
|
+
if (tmpDir) {
|
|
169
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
170
|
+
}
|
|
171
|
+
for (const [k, v] of Object.entries(savedEnv)) {
|
|
172
|
+
if (v === undefined) {
|
|
173
|
+
delete process.env[k];
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
process.env[k] = v;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
function writeEnvFile(content) {
|
|
181
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "secrets-test-"));
|
|
182
|
+
fs.writeFileSync(path.join(tmpDir, ".env.agent-ci"), content);
|
|
183
|
+
return tmpDir;
|
|
184
|
+
}
|
|
185
|
+
function makeTmpDir() {
|
|
186
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "secrets-test-"));
|
|
187
|
+
return tmpDir;
|
|
188
|
+
}
|
|
189
|
+
it("returns empty object when .env.agent-ci does not exist", () => {
|
|
190
|
+
const dir = makeTmpDir();
|
|
191
|
+
expect(loadMachineSecrets(dir)).toEqual({});
|
|
192
|
+
});
|
|
193
|
+
it("parses KEY=VALUE pairs from file", () => {
|
|
194
|
+
const dir = writeEnvFile("FOO=bar\nBAZ=qux\n");
|
|
195
|
+
expect(loadMachineSecrets(dir)).toEqual({ FOO: "bar", BAZ: "qux" });
|
|
196
|
+
});
|
|
197
|
+
it("fills missing secrets from process.env when envFallbackKeys provided", () => {
|
|
198
|
+
const dir = makeTmpDir();
|
|
199
|
+
saveEnv("TEST_SECRET_ABC");
|
|
200
|
+
process.env.TEST_SECRET_ABC = "from-env";
|
|
201
|
+
const secrets = loadMachineSecrets(dir, ["TEST_SECRET_ABC"]);
|
|
202
|
+
expect(secrets.TEST_SECRET_ABC).toBe("from-env");
|
|
203
|
+
});
|
|
204
|
+
it("file values take precedence over process.env", () => {
|
|
205
|
+
const dir = writeEnvFile("MY_TOKEN=from-file\n");
|
|
206
|
+
saveEnv("MY_TOKEN");
|
|
207
|
+
process.env.MY_TOKEN = "from-env";
|
|
208
|
+
const secrets = loadMachineSecrets(dir, ["MY_TOKEN"]);
|
|
209
|
+
expect(secrets.MY_TOKEN).toBe("from-file");
|
|
210
|
+
});
|
|
211
|
+
it("does not pull from process.env for keys not in envFallbackKeys", () => {
|
|
212
|
+
const dir = makeTmpDir();
|
|
213
|
+
saveEnv("UNRELATED_VAR");
|
|
214
|
+
process.env.UNRELATED_VAR = "should-not-appear";
|
|
215
|
+
const secrets = loadMachineSecrets(dir, ["OTHER_KEY"]);
|
|
216
|
+
expect(secrets.UNRELATED_VAR).toBeUndefined();
|
|
217
|
+
expect(secrets.OTHER_KEY).toBeUndefined();
|
|
218
|
+
});
|
|
219
|
+
it("does not pull from process.env when envFallbackKeys is omitted", () => {
|
|
220
|
+
const dir = makeTmpDir();
|
|
221
|
+
saveEnv("SOME_SECRET");
|
|
222
|
+
process.env.SOME_SECRET = "env-value";
|
|
223
|
+
const secrets = loadMachineSecrets(dir);
|
|
224
|
+
expect(secrets.SOME_SECRET).toBeUndefined();
|
|
225
|
+
});
|
|
226
|
+
it("merges file secrets and env fallbacks", () => {
|
|
227
|
+
const dir = writeEnvFile("FROM_FILE=file-val\n");
|
|
228
|
+
saveEnv("FROM_ENV");
|
|
229
|
+
process.env.FROM_ENV = "env-val";
|
|
230
|
+
const secrets = loadMachineSecrets(dir, ["FROM_FILE", "FROM_ENV"]);
|
|
231
|
+
expect(secrets).toEqual({ FROM_FILE: "file-val", FROM_ENV: "env-val" });
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -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;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ensures a Docker image is present locally, pulling it if not.
|
|
3
|
+
*
|
|
4
|
+
* Docker's createContainer() returns a 404 "No such image" error when the
|
|
5
|
+
* image is absent — it does not pull automatically. This helper mirrors the
|
|
6
|
+
* pattern already used by service-containers.ts and must be called before
|
|
7
|
+
* any createContainer() call.
|
|
8
|
+
*
|
|
9
|
+
* Reproduces: https://github.com/redwoodjs/agent-ci/issues/203
|
|
10
|
+
*/
|
|
11
|
+
export async function ensureImagePulled(docker, image) {
|
|
12
|
+
try {
|
|
13
|
+
await docker.getImage(image).inspect();
|
|
14
|
+
return; // already present
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
// Not found locally — fall through to pull
|
|
18
|
+
}
|
|
19
|
+
await new Promise((resolve, reject) => {
|
|
20
|
+
docker.pull(image, (err, stream) => {
|
|
21
|
+
if (err) {
|
|
22
|
+
return reject(wrapPullError(image, err));
|
|
23
|
+
}
|
|
24
|
+
docker.modem.followProgress(stream, (err) => {
|
|
25
|
+
if (err) {
|
|
26
|
+
reject(wrapPullError(image, err));
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
resolve();
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
function wrapPullError(image, cause) {
|
|
36
|
+
return new Error(`Failed to pull Docker image '${image}': ${cause.message}\n` +
|
|
37
|
+
"\n" +
|
|
38
|
+
" Possible causes:\n" +
|
|
39
|
+
" • The image name is misspelled or does not exist in the registry\n" +
|
|
40
|
+
" • The image is private — authenticate first: docker login <registry>\n" +
|
|
41
|
+
" • No network connection");
|
|
42
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from "vitest";
|
|
2
|
+
import Docker from "dockerode";
|
|
3
|
+
import { ensureImagePulled } from "./image-pull.js";
|
|
4
|
+
import { resolveDockerSocket } from "./docker-socket.js";
|
|
5
|
+
// Integration test: requires a running Docker daemon and network access.
|
|
6
|
+
// Uses hello-world (~13 KB) to keep pull time minimal.
|
|
7
|
+
const TEST_IMAGE = "hello-world:latest";
|
|
8
|
+
describe("ensureImagePulled", () => {
|
|
9
|
+
let docker;
|
|
10
|
+
beforeAll(async () => {
|
|
11
|
+
const socket = resolveDockerSocket();
|
|
12
|
+
docker = new Docker({ socketPath: socket.socketPath });
|
|
13
|
+
await docker.ping();
|
|
14
|
+
});
|
|
15
|
+
it("pulls the image when it is not present locally", { timeout: 60000 }, async () => {
|
|
16
|
+
// Arrange: remove the image so it is definitely absent
|
|
17
|
+
try {
|
|
18
|
+
await docker.getImage(TEST_IMAGE).remove({ force: true });
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// Already absent — fine
|
|
22
|
+
}
|
|
23
|
+
// Act
|
|
24
|
+
await ensureImagePulled(docker, TEST_IMAGE);
|
|
25
|
+
// Assert: image must now be inspectable
|
|
26
|
+
const info = await docker.getImage(TEST_IMAGE).inspect();
|
|
27
|
+
expect(info.RepoTags).toContain(TEST_IMAGE);
|
|
28
|
+
});
|
|
29
|
+
it("rejects with an error when the image does not exist in the registry", { timeout: 30000 }, async () => {
|
|
30
|
+
await expect(ensureImagePulled(docker, "ghcr.io/redwoodjs/agent-ci-does-not-exist:latest")).rejects.toThrow("Failed to pull Docker image 'ghcr.io/redwoodjs/agent-ci-does-not-exist:latest'");
|
|
31
|
+
});
|
|
32
|
+
it("does nothing when the image is already present", async () => {
|
|
33
|
+
// Arrange: ensure the image is present (previous test or pre-cached)
|
|
34
|
+
await ensureImagePulled(docker, TEST_IMAGE);
|
|
35
|
+
// Act: calling again must not throw
|
|
36
|
+
await expect(ensureImagePulled(docker, TEST_IMAGE)).resolves.toBeUndefined();
|
|
37
|
+
});
|
|
38
|
+
});
|
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(() => {
|
package/dist/output/reporter.js
CHANGED
|
@@ -10,25 +10,48 @@ function formatDuration(ms) {
|
|
|
10
10
|
return rem > 0 ? `${m}m ${rem}s` : `${m}m`;
|
|
11
11
|
}
|
|
12
12
|
// ─── Failures-first summary (emitted after all jobs complete) ─────────────────
|
|
13
|
+
function getErrorContent(f) {
|
|
14
|
+
if (f.failedStepLogPath && fs.existsSync(f.failedStepLogPath)) {
|
|
15
|
+
return fs.readFileSync(f.failedStepLogPath, "utf-8");
|
|
16
|
+
}
|
|
17
|
+
if (f.lastOutputLines && f.lastOutputLines.length > 0) {
|
|
18
|
+
return f.lastOutputLines.join("\n") + "\n";
|
|
19
|
+
}
|
|
20
|
+
return "";
|
|
21
|
+
}
|
|
22
|
+
function formatFailureHeader(f) {
|
|
23
|
+
if (f.failedStep) {
|
|
24
|
+
return ` ✗ ${f.workflow} > ${f.taskId} > "${f.failedStep}"`;
|
|
25
|
+
}
|
|
26
|
+
return ` ✗ ${f.workflow} > ${f.taskId}`;
|
|
27
|
+
}
|
|
13
28
|
export function printSummary(results, runDir) {
|
|
14
29
|
const failures = results.filter((r) => !r.succeeded);
|
|
15
30
|
const passes = results.filter((r) => r.succeeded);
|
|
16
31
|
const totalMs = results.reduce((sum, r) => sum + r.durationMs, 0);
|
|
17
32
|
if (failures.length > 0) {
|
|
18
33
|
process.stdout.write("\n━━━ FAILURES ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n");
|
|
34
|
+
// Group failures by error content to avoid repeating identical errors
|
|
35
|
+
const groups = [];
|
|
36
|
+
const seen = new Map();
|
|
19
37
|
for (const f of failures) {
|
|
20
|
-
|
|
21
|
-
|
|
38
|
+
const content = getErrorContent(f);
|
|
39
|
+
const existing = seen.get(content);
|
|
40
|
+
if (existing) {
|
|
41
|
+
existing.failures.push(f);
|
|
22
42
|
}
|
|
23
43
|
else {
|
|
24
|
-
|
|
44
|
+
const group = { failures: [f], errorContent: content };
|
|
45
|
+
groups.push(group);
|
|
46
|
+
seen.set(content, group);
|
|
25
47
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
48
|
+
}
|
|
49
|
+
for (const group of groups) {
|
|
50
|
+
for (const f of group.failures) {
|
|
51
|
+
process.stdout.write(formatFailureHeader(f) + "\n");
|
|
29
52
|
}
|
|
30
|
-
|
|
31
|
-
process.stdout.write("\n" +
|
|
53
|
+
if (group.errorContent) {
|
|
54
|
+
process.stdout.write("\n" + group.errorContent);
|
|
32
55
|
}
|
|
33
56
|
process.stdout.write("\n");
|
|
34
57
|
}
|
|
@@ -65,6 +65,50 @@ describe("printSummary", () => {
|
|
|
65
65
|
]);
|
|
66
66
|
expect(output).toContain('✗ retry-proof.yml > test > "Run assertion test"');
|
|
67
67
|
});
|
|
68
|
+
it("deduplicates failures with identical error content", () => {
|
|
69
|
+
printSummary([
|
|
70
|
+
makeResult({
|
|
71
|
+
taskId: "test (1)",
|
|
72
|
+
failedStep: "[Job startup failed]",
|
|
73
|
+
lastOutputLines: ["Missing secrets"],
|
|
74
|
+
}),
|
|
75
|
+
makeResult({
|
|
76
|
+
taskId: "test (2)",
|
|
77
|
+
failedStep: "[Job startup failed]",
|
|
78
|
+
lastOutputLines: ["Missing secrets"],
|
|
79
|
+
}),
|
|
80
|
+
makeResult({
|
|
81
|
+
taskId: "test (3)",
|
|
82
|
+
failedStep: "[Job startup failed]",
|
|
83
|
+
lastOutputLines: ["Missing secrets"],
|
|
84
|
+
}),
|
|
85
|
+
]);
|
|
86
|
+
// Error content should appear only once
|
|
87
|
+
const matches = output.match(/Missing secrets/g);
|
|
88
|
+
expect(matches).toHaveLength(1);
|
|
89
|
+
// All job headers should still appear
|
|
90
|
+
expect(output).toContain('test (1) > "[Job startup failed]"');
|
|
91
|
+
expect(output).toContain('test (2) > "[Job startup failed]"');
|
|
92
|
+
expect(output).toContain('test (3) > "[Job startup failed]"');
|
|
93
|
+
// Summary should show correct count
|
|
94
|
+
expect(output).toContain("3 failed");
|
|
95
|
+
});
|
|
96
|
+
it("keeps distinct errors separate", () => {
|
|
97
|
+
printSummary([
|
|
98
|
+
makeResult({
|
|
99
|
+
taskId: "build",
|
|
100
|
+
failedStep: "Compile",
|
|
101
|
+
lastOutputLines: ["syntax error"],
|
|
102
|
+
}),
|
|
103
|
+
makeResult({
|
|
104
|
+
taskId: "lint",
|
|
105
|
+
failedStep: "ESLint",
|
|
106
|
+
lastOutputLines: ["unused variable"],
|
|
107
|
+
}),
|
|
108
|
+
]);
|
|
109
|
+
expect(output).toContain("syntax error");
|
|
110
|
+
expect(output).toContain("unused variable");
|
|
111
|
+
});
|
|
68
112
|
it("shows pass count in summary for a successful run", () => {
|
|
69
113
|
printSummary([makeResult({ succeeded: true })]);
|
|
70
114
|
expect(output).toContain("✓ 1 passed");
|
|
@@ -142,22 +142,22 @@ export function renderRunState(state) {
|
|
|
142
142
|
const totalJobs = state.workflows.reduce((sum, wf) => sum + wf.jobs.length, 0);
|
|
143
143
|
const singleJobMode = state.workflows.length === 1 && totalJobs === 1;
|
|
144
144
|
const roots = [];
|
|
145
|
-
let
|
|
145
|
+
let pausedJob;
|
|
146
146
|
for (const wf of state.workflows) {
|
|
147
147
|
const children = [];
|
|
148
148
|
for (const job of wf.jobs) {
|
|
149
149
|
children.push(...buildJobNodes(job, singleJobMode));
|
|
150
|
-
// Capture the first paused job for
|
|
151
|
-
if (
|
|
152
|
-
|
|
150
|
+
// Capture the first paused job for trailing output
|
|
151
|
+
if (job.status === "paused" && !pausedJob) {
|
|
152
|
+
pausedJob = job;
|
|
153
153
|
}
|
|
154
154
|
}
|
|
155
155
|
roots.push({ label: path.basename(wf.path), children });
|
|
156
156
|
}
|
|
157
157
|
let output = renderTree(roots);
|
|
158
|
-
// ──
|
|
159
|
-
if (
|
|
160
|
-
const { lastOutputLines, runnerId } =
|
|
158
|
+
// ── Paused job: append last output + retry/abort hints below tree ──────────
|
|
159
|
+
if (pausedJob) {
|
|
160
|
+
const { lastOutputLines, runnerId } = pausedJob;
|
|
161
161
|
if (lastOutputLines && lastOutputLines.length > 0) {
|
|
162
162
|
output += `\n\n ${DIM}Last output:${RESET}`;
|
|
163
163
|
for (const line of lastOutputLines) {
|
|
@@ -376,11 +376,54 @@ describe("renderRunState", () => {
|
|
|
376
376
|
],
|
|
377
377
|
});
|
|
378
378
|
const output = renderRunState(state);
|
|
379
|
-
// Retry hint is a child node
|
|
379
|
+
// Retry hint is a child node in the tree
|
|
380
380
|
expect(output).toContain("↻ retry: agent-ci retry --runner agent-ci-5-j2");
|
|
381
|
-
//
|
|
382
|
-
expect(output).
|
|
383
|
-
expect(output).
|
|
381
|
+
// Trailing "To retry:" / "To abort:" lines also shown in multi-job mode
|
|
382
|
+
expect(output).toContain("↻ To retry:");
|
|
383
|
+
expect(output).toContain("■ To abort:");
|
|
384
|
+
});
|
|
385
|
+
it("shows last output lines for paused job in multi-job mode", () => {
|
|
386
|
+
const state = makeState({
|
|
387
|
+
workflows: [
|
|
388
|
+
{
|
|
389
|
+
id: "ci.yml",
|
|
390
|
+
path: "/repo/.github/workflows/ci.yml",
|
|
391
|
+
status: "running",
|
|
392
|
+
jobs: [
|
|
393
|
+
{
|
|
394
|
+
id: "build",
|
|
395
|
+
runnerId: "agent-ci-5-j1",
|
|
396
|
+
status: "completed",
|
|
397
|
+
durationMs: 5000,
|
|
398
|
+
steps: [],
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
id: "test",
|
|
402
|
+
runnerId: "agent-ci-5-j2",
|
|
403
|
+
status: "paused",
|
|
404
|
+
pausedAtStep: "Run tests",
|
|
405
|
+
pausedAtMs: "1970-01-01T00:00:05.000Z",
|
|
406
|
+
attempt: 1,
|
|
407
|
+
lastOutputLines: ["FAIL src/app.test.ts", " Expected: true", " Received: false"],
|
|
408
|
+
bootDurationMs: 1000,
|
|
409
|
+
steps: [
|
|
410
|
+
{
|
|
411
|
+
name: "Run tests",
|
|
412
|
+
index: 1,
|
|
413
|
+
status: "paused",
|
|
414
|
+
startedAt: "1970-01-01T00:00:03.000Z",
|
|
415
|
+
},
|
|
416
|
+
],
|
|
417
|
+
},
|
|
418
|
+
],
|
|
419
|
+
},
|
|
420
|
+
],
|
|
421
|
+
});
|
|
422
|
+
const output = renderRunState(state);
|
|
423
|
+
expect(output).toContain("Last output:");
|
|
424
|
+
expect(output).toContain("FAIL src/app.test.ts");
|
|
425
|
+
expect(output).toContain("Expected: true");
|
|
426
|
+
expect(output).toContain("Received: false");
|
|
384
427
|
});
|
|
385
428
|
});
|
|
386
429
|
describe("multi-workflow (--all mode)", () => {
|
|
@@ -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);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { execSync } from "child_process";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
/**
|
|
5
|
+
* Compute a SHA that represents the current dirty working-tree state, as if
|
|
6
|
+
* it were committed. Uses a temporary index + `git write-tree` /
|
|
7
|
+
* `git commit-tree` so no refs are moved and real history is untouched.
|
|
8
|
+
*
|
|
9
|
+
* Returns `undefined` when the tree is clean (no uncommitted changes).
|
|
10
|
+
*/
|
|
11
|
+
export function computeDirtySha(repoRoot) {
|
|
12
|
+
try {
|
|
13
|
+
// Quick check: anything dirty?
|
|
14
|
+
const status = execSync("git status --porcelain", {
|
|
15
|
+
cwd: repoRoot,
|
|
16
|
+
stdio: "pipe",
|
|
17
|
+
})
|
|
18
|
+
.toString()
|
|
19
|
+
.trim();
|
|
20
|
+
if (!status) {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
const gitDir = execSync("git rev-parse --git-dir", {
|
|
24
|
+
cwd: repoRoot,
|
|
25
|
+
stdio: "pipe",
|
|
26
|
+
})
|
|
27
|
+
.toString()
|
|
28
|
+
.trim();
|
|
29
|
+
const absoluteGitDir = path.isAbsolute(gitDir) ? gitDir : path.join(repoRoot, gitDir);
|
|
30
|
+
const tmpIndex = path.join(absoluteGitDir, `index-agent-ci-${Date.now()}`);
|
|
31
|
+
try {
|
|
32
|
+
// Seed the temp index from the real one so we start from the current staging area.
|
|
33
|
+
fs.copyFileSync(path.join(absoluteGitDir, "index"), tmpIndex);
|
|
34
|
+
const env = { ...process.env, GIT_INDEX_FILE: tmpIndex };
|
|
35
|
+
// Stage everything (tracked + untracked, respecting .gitignore) into the temp index.
|
|
36
|
+
execSync("git add -A", { cwd: repoRoot, stdio: "pipe", env });
|
|
37
|
+
// Write a tree object from the temp index.
|
|
38
|
+
const tree = execSync("git write-tree", {
|
|
39
|
+
cwd: repoRoot,
|
|
40
|
+
stdio: "pipe",
|
|
41
|
+
env,
|
|
42
|
+
})
|
|
43
|
+
.toString()
|
|
44
|
+
.trim();
|
|
45
|
+
// Create an ephemeral commit object parented on HEAD — no ref is updated.
|
|
46
|
+
const sha = execSync(`git commit-tree ${tree} -p HEAD -m "agent-ci: dirty working tree"`, {
|
|
47
|
+
cwd: repoRoot,
|
|
48
|
+
stdio: "pipe",
|
|
49
|
+
})
|
|
50
|
+
.toString()
|
|
51
|
+
.trim();
|
|
52
|
+
return sha;
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
try {
|
|
56
|
+
fs.unlinkSync(tmpIndex);
|
|
57
|
+
}
|
|
58
|
+
catch { }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { execSync } from "child_process";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import { computeDirtySha } from "./dirty-sha.js";
|
|
7
|
+
describe("computeDirtySha", () => {
|
|
8
|
+
let repoDir;
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), "dirty-sha-test-"));
|
|
11
|
+
execSync("git init", { cwd: repoDir, stdio: "pipe" });
|
|
12
|
+
execSync('git config user.name "test"', { cwd: repoDir, stdio: "pipe" });
|
|
13
|
+
execSync('git config user.email "test@test.com"', { cwd: repoDir, stdio: "pipe" });
|
|
14
|
+
// Create an initial commit so HEAD exists
|
|
15
|
+
fs.writeFileSync(path.join(repoDir, "initial.txt"), "initial");
|
|
16
|
+
execSync("git add -A && git commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
|
|
17
|
+
});
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
fs.rmSync(repoDir, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
it("returns undefined for a clean working tree", () => {
|
|
22
|
+
expect(computeDirtySha(repoDir)).toBeUndefined();
|
|
23
|
+
});
|
|
24
|
+
it("returns a SHA when tracked files are modified", () => {
|
|
25
|
+
fs.writeFileSync(path.join(repoDir, "initial.txt"), "modified");
|
|
26
|
+
const sha = computeDirtySha(repoDir);
|
|
27
|
+
expect(sha).toBeDefined();
|
|
28
|
+
expect(sha).toMatch(/^[0-9a-f]{40}$/);
|
|
29
|
+
});
|
|
30
|
+
it("returns a SHA when untracked files are present", () => {
|
|
31
|
+
fs.writeFileSync(path.join(repoDir, "untracked.txt"), "new file");
|
|
32
|
+
const sha = computeDirtySha(repoDir);
|
|
33
|
+
expect(sha).toBeDefined();
|
|
34
|
+
expect(sha).toMatch(/^[0-9a-f]{40}$/);
|
|
35
|
+
});
|
|
36
|
+
it("returns a different SHA for different dirty states", () => {
|
|
37
|
+
fs.writeFileSync(path.join(repoDir, "a.txt"), "content a");
|
|
38
|
+
const sha1 = computeDirtySha(repoDir);
|
|
39
|
+
// Stage and commit a.txt, then modify differently
|
|
40
|
+
execSync("git add -A && git commit -m 'add a'", { cwd: repoDir, stdio: "pipe" });
|
|
41
|
+
fs.writeFileSync(path.join(repoDir, "a.txt"), "content b");
|
|
42
|
+
const sha2 = computeDirtySha(repoDir);
|
|
43
|
+
expect(sha1).toBeDefined();
|
|
44
|
+
expect(sha2).toBeDefined();
|
|
45
|
+
expect(sha1).not.toBe(sha2);
|
|
46
|
+
});
|
|
47
|
+
it("does not move HEAD or create refs", () => {
|
|
48
|
+
const headBefore = execSync("git rev-parse HEAD", { cwd: repoDir, stdio: "pipe" })
|
|
49
|
+
.toString()
|
|
50
|
+
.trim();
|
|
51
|
+
const refsBefore = execSync("git for-each-ref", { cwd: repoDir, stdio: "pipe" })
|
|
52
|
+
.toString()
|
|
53
|
+
.trim();
|
|
54
|
+
fs.writeFileSync(path.join(repoDir, "dirty.txt"), "dirty");
|
|
55
|
+
computeDirtySha(repoDir);
|
|
56
|
+
const headAfter = execSync("git rev-parse HEAD", { cwd: repoDir, stdio: "pipe" })
|
|
57
|
+
.toString()
|
|
58
|
+
.trim();
|
|
59
|
+
const refsAfter = execSync("git for-each-ref", { cwd: repoDir, stdio: "pipe" })
|
|
60
|
+
.toString()
|
|
61
|
+
.trim();
|
|
62
|
+
expect(headAfter).toBe(headBefore);
|
|
63
|
+
expect(refsAfter).toBe(refsBefore);
|
|
64
|
+
});
|
|
65
|
+
it("does not modify the real index", () => {
|
|
66
|
+
// Stage nothing, but have an untracked file
|
|
67
|
+
fs.writeFileSync(path.join(repoDir, "untracked.txt"), "new");
|
|
68
|
+
const statusBefore = execSync("git status --porcelain", { cwd: repoDir, stdio: "pipe" })
|
|
69
|
+
.toString()
|
|
70
|
+
.trim();
|
|
71
|
+
computeDirtySha(repoDir);
|
|
72
|
+
const statusAfter = execSync("git status --porcelain", { cwd: repoDir, stdio: "pipe" })
|
|
73
|
+
.toString()
|
|
74
|
+
.trim();
|
|
75
|
+
expect(statusAfter).toBe(statusBefore);
|
|
76
|
+
});
|
|
77
|
+
it("returns a valid commit object parented on HEAD", () => {
|
|
78
|
+
fs.writeFileSync(path.join(repoDir, "dirty.txt"), "content");
|
|
79
|
+
const sha = computeDirtySha(repoDir);
|
|
80
|
+
expect(sha).toBeDefined();
|
|
81
|
+
// Verify it's a valid commit object
|
|
82
|
+
const type = execSync(`git cat-file -t ${sha}`, { cwd: repoDir, stdio: "pipe" })
|
|
83
|
+
.toString()
|
|
84
|
+
.trim();
|
|
85
|
+
expect(type).toBe("commit");
|
|
86
|
+
// Read the parent SHA directly from the commit object (bypasses any git shims
|
|
87
|
+
// that intercept `git rev-parse HEAD` in CI environments).
|
|
88
|
+
const commitBody = execSync(`git cat-file -p ${sha}`, {
|
|
89
|
+
cwd: repoDir,
|
|
90
|
+
stdio: "pipe",
|
|
91
|
+
}).toString();
|
|
92
|
+
const parentMatch = commitBody.match(/^parent ([0-9a-f]{40})$/m);
|
|
93
|
+
expect(parentMatch).not.toBeNull();
|
|
94
|
+
// Read HEAD the same way to compare — resolve the ref from .git/HEAD.
|
|
95
|
+
const headContent = fs.readFileSync(path.join(repoDir, ".git", "HEAD"), "utf-8").trim();
|
|
96
|
+
const headSha = headContent.startsWith("ref: ")
|
|
97
|
+
? fs.readFileSync(path.join(repoDir, ".git", headContent.slice(5)), "utf-8").trim()
|
|
98
|
+
: headContent;
|
|
99
|
+
expect(parentMatch[1]).toBe(headSha);
|
|
100
|
+
});
|
|
101
|
+
});
|
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,11 +12,12 @@ 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";
|
|
19
19
|
import { buildJobResult, isJobSuccessful } from "./result-builder.js";
|
|
20
|
+
import { ensureImagePulled } from "../docker/image-pull.js";
|
|
20
21
|
import { wrapJobSteps, appendOutputCaptureStep } from "./step-wrapper.js";
|
|
21
22
|
import { syncWorkspaceForRetry } from "./sync.js";
|
|
22
23
|
// ─── Docker setup ─────────────────────────────────────────────────────────────
|
|
@@ -184,13 +185,23 @@ export async function executeLocalJob(job, options) {
|
|
|
184
185
|
writeJobMetadata({ logDir, containerName, job });
|
|
185
186
|
// Open debug stream to capture raw container output
|
|
186
187
|
const debugStream = fs.createWriteStream(debugLogPath);
|
|
188
|
+
// Hoisted for cleanup in `finally` — assigned inside the try block.
|
|
189
|
+
let container = null;
|
|
190
|
+
let serviceCtx;
|
|
191
|
+
const hostRunnerDir = path.resolve(runDir, "runner");
|
|
187
192
|
// Signal handler: ensure cleanup runs even when killed.
|
|
188
193
|
// Do NOT call process.exit() here — multiple jobs register handlers concurrently,
|
|
189
194
|
// and an early exit would prevent other jobs' handlers from cleaning up their containers.
|
|
190
195
|
// killRunnerContainers already handles the runner, its svc-* sidecars, and the network.
|
|
191
196
|
const signalCleanup = () => {
|
|
192
197
|
killRunnerContainers(containerName);
|
|
193
|
-
for (const d of [
|
|
198
|
+
for (const d of [
|
|
199
|
+
dirs.containerWorkDir,
|
|
200
|
+
dirs.shimsDir,
|
|
201
|
+
dirs.signalsDir,
|
|
202
|
+
dirs.diagDir,
|
|
203
|
+
hostRunnerDir,
|
|
204
|
+
]) {
|
|
194
205
|
try {
|
|
195
206
|
fs.rmSync(d, { recursive: true, force: true });
|
|
196
207
|
}
|
|
@@ -199,10 +210,6 @@ export async function executeLocalJob(job, options) {
|
|
|
199
210
|
};
|
|
200
211
|
process.on("SIGINT", signalCleanup);
|
|
201
212
|
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
213
|
try {
|
|
207
214
|
// 1. Seed the job to Local DTU
|
|
208
215
|
const [githubOwner, githubRepoName] = (job.githubRepo || "").split("/");
|
|
@@ -243,8 +250,7 @@ export async function executeLocalJob(job, options) {
|
|
|
243
250
|
// 4. Write git shim BEFORE container start so the entrypoint can install it
|
|
244
251
|
// immediately. On Linux, prepareWorkspace (rsync) is slow enough that the
|
|
245
252
|
// container entrypoint would race ahead and find an empty shims dir.
|
|
246
|
-
|
|
247
|
-
writeGitShim(dirs.shimsDir, fakeSha);
|
|
253
|
+
writeGitShim(dirs.shimsDir, job.realHeadSha);
|
|
248
254
|
// Prepare workspace files in parallel with container setup
|
|
249
255
|
const workspacePrepStart = Date.now();
|
|
250
256
|
const workspacePrepPromise = (async () => {
|
|
@@ -300,6 +306,10 @@ export async function executeLocalJob(job, options) {
|
|
|
300
306
|
const hostRunnerSeedDir = path.resolve(getWorkingDirectory(), "runner");
|
|
301
307
|
const useDirectContainer = !!job.container;
|
|
302
308
|
const containerImage = useDirectContainer ? job.container.image : IMAGE;
|
|
309
|
+
// Pull the runner image if not cached locally. Required in both modes:
|
|
310
|
+
// default mode uses it directly as the container image; direct-container
|
|
311
|
+
// mode uses it to seed the runner binary. Fixes: github.com/redwoodjs/agent-ci/issues/203
|
|
312
|
+
await ensureImagePulled(getDocker(), IMAGE);
|
|
303
313
|
if (useDirectContainer) {
|
|
304
314
|
await fs.promises.mkdir(hostRunnerSeedDir, { recursive: true });
|
|
305
315
|
const markerFile = path.join(hostRunnerSeedDir, ".seeded");
|
|
@@ -449,6 +459,7 @@ export async function executeLocalJob(job, options) {
|
|
|
449
459
|
warmModulesDir: dirs.warmModulesDir,
|
|
450
460
|
hostRunnerDir,
|
|
451
461
|
useDirectContainer,
|
|
462
|
+
githubRepo,
|
|
452
463
|
dockerSocketPath: getDockerSocket().socketPath || undefined,
|
|
453
464
|
});
|
|
454
465
|
const containerCmd = buildContainerCmd({
|
|
@@ -463,6 +474,9 @@ export async function executeLocalJob(job, options) {
|
|
|
463
474
|
container = await getDocker().createContainer({
|
|
464
475
|
Image: containerImage,
|
|
465
476
|
name: containerName,
|
|
477
|
+
Labels: {
|
|
478
|
+
"agent-ci.pid": String(process.pid),
|
|
479
|
+
},
|
|
466
480
|
Env: containerEnv,
|
|
467
481
|
...(useDirectContainer ? { Entrypoint: ["bash"] } : {}),
|
|
468
482
|
Cmd: containerCmd,
|
|
@@ -663,7 +663,7 @@ export function validateSecrets(filePath, taskName, secrets, secretsFilePath) {
|
|
|
663
663
|
return;
|
|
664
664
|
}
|
|
665
665
|
throw new Error(`[Agent CI] Missing secrets required by workflow job "${taskName}".\n` +
|
|
666
|
-
`Add the following to ${secretsFilePath}:\n\n` +
|
|
666
|
+
`Add the following to ${secretsFilePath} or set them as environment variables:\n\n` +
|
|
667
667
|
missing.map((n) => `${n}=`).join("\n") +
|
|
668
668
|
"\n");
|
|
669
669
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@redwoodjs/agent-ci",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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.9.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@types/dockerode": "^3.3.34",
|