javi-forge 1.16.0 → 1.18.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/commands/ci.js +111 -12
- package/dist/lib/docker.d.ts +19 -1
- package/dist/lib/docker.js +69 -7
- package/package.json +1 -1
package/dist/commands/ci.js
CHANGED
|
@@ -316,6 +316,18 @@ function describeRunners(resolved) {
|
|
|
316
316
|
}
|
|
317
317
|
export async function runCI(options, onStep, onGateOutcome) {
|
|
318
318
|
const { projectDir = process.cwd(), mode = "full", noDocker = false, noGhagga = false, noSecurity = false, timeout = 600, } = options;
|
|
319
|
+
// ── Run-scoped Docker availability (lazy-memoized) ─────────────────────────
|
|
320
|
+
// Computed at most ONCE per run and only when an image gate needs it. The
|
|
321
|
+
// full/quick prologue below assigns its own `isDockerAvailable()` result back
|
|
322
|
+
// into this cache so `runGates` never re-probes; the gates-only path leaves it
|
|
323
|
+
// undefined until an image gate lazily triggers the probe (a native-only
|
|
324
|
+
// gates-only repo never touches Docker — behaves exactly as before slice 3).
|
|
325
|
+
let dockerAvailableCache;
|
|
326
|
+
const dockerAvailable = async () => (dockerAvailableCache ??= await isDockerAvailable());
|
|
327
|
+
const dockerGate = {
|
|
328
|
+
noDocker,
|
|
329
|
+
isAvailable: dockerAvailable,
|
|
330
|
+
};
|
|
319
331
|
// ── Resolve runners (once — nothing downstream re-detects) ─────────────────
|
|
320
332
|
const stepDetect = "detect";
|
|
321
333
|
report(onStep, stepDetect, "Detecting stack", "running");
|
|
@@ -343,7 +355,7 @@ export async function runCI(options, onStep, onGateOutcome) {
|
|
|
343
355
|
report(onStep, mode, `${mode} mode`, "error", detail);
|
|
344
356
|
throw new Error(`no runners resolved — ${mode} mode requires at least one runner`);
|
|
345
357
|
}
|
|
346
|
-
await runGates(resolved.gates, projectDir, onStep, onGateOutcome);
|
|
358
|
+
await runGates(resolved.gates, projectDir, onStep, dockerGate, onGateOutcome);
|
|
347
359
|
return;
|
|
348
360
|
}
|
|
349
361
|
// Legacy single-runner view for the zero-config auto path. Keeping this
|
|
@@ -409,6 +421,9 @@ export async function runCI(options, onStep, onGateOutcome) {
|
|
|
409
421
|
const stepDocker = "docker-check";
|
|
410
422
|
report(onStep, stepDocker, "Checking Docker", "running");
|
|
411
423
|
const dockerOk = await isDockerAvailable();
|
|
424
|
+
// Reuse this result for the gate fail-closed check — `runGates` must not
|
|
425
|
+
// run a second `docker info` (memoization seam).
|
|
426
|
+
dockerAvailableCache = dockerOk;
|
|
412
427
|
if (!dockerOk) {
|
|
413
428
|
report(onStep, stepDocker, "Docker not available", "error", "Start Docker or use --no-docker");
|
|
414
429
|
throw new Error("Docker is not available");
|
|
@@ -515,7 +530,7 @@ export async function runCI(options, onStep, onGateOutcome) {
|
|
|
515
530
|
// already `full` or `quick` here; the guard makes the contract explicit.
|
|
516
531
|
// `runGates` no-ops on an empty gate list (a v1 repo carries none).
|
|
517
532
|
if (mode === "full" || mode === "quick") {
|
|
518
|
-
await runGates(resolved.gates, projectDir, onStep, onGateOutcome);
|
|
533
|
+
await runGates(resolved.gates, projectDir, onStep, dockerGate, onGateOutcome);
|
|
519
534
|
}
|
|
520
535
|
}
|
|
521
536
|
// =============================================================================
|
|
@@ -900,7 +915,48 @@ const BASELINE_ENV = "JAVI_FORGE_BASELINE";
|
|
|
900
915
|
* `onOutcome` (optional) receives each gate's structured result for the headless
|
|
901
916
|
* JSON run path.
|
|
902
917
|
*/
|
|
903
|
-
|
|
918
|
+
/**
|
|
919
|
+
* Route a single gate command to its execution path and normalize the result to
|
|
920
|
+
* the EXACT `GateRunResult` shape the collector consumes, so the JSON/reason
|
|
921
|
+
* semantics are byte-identical for native and containerized gates.
|
|
922
|
+
*
|
|
923
|
+
* - `gate.image === undefined` → `runGateNative` (UNCHANGED), fed the full host
|
|
924
|
+
* env MAP (`nativeEnv`). A spawn env map never lands in argv, so the host env
|
|
925
|
+
* is safe there.
|
|
926
|
+
* - `gate.image !== undefined` → `runInContainer`, fed ONLY `containerEnv` — the
|
|
927
|
+
* EXPLICIT ALLOWLIST (`CI` + injected JAVI_FORGE_* + gate.env), NEVER
|
|
928
|
+
* `process.env` (JDB-001: no host secret in the `-e` argv / `ps aux`).
|
|
929
|
+
*
|
|
930
|
+
* The container's `timedOut` flag is normalized to `GATE_TIMEOUT_EXIT_CODE` (124)
|
|
931
|
+
* HERE — the one place gate semantics live — so `docker.ts` stays gate-agnostic
|
|
932
|
+
* and the collector's existing `timeoutReason` branch fires identically.
|
|
933
|
+
*
|
|
934
|
+
* NOTE: fail-closed (image gate + no Docker → refuse) is slice 3. In slice 2 an
|
|
935
|
+
* image gate always routes to the container when reached.
|
|
936
|
+
*/
|
|
937
|
+
async function runGateCommand(gate, cmd, projectDir, nativeEnv, containerEnv) {
|
|
938
|
+
if (gate.image === undefined) {
|
|
939
|
+
return await runGateNative(cmd, projectDir, nativeEnv, gate.timeout);
|
|
940
|
+
}
|
|
941
|
+
const result = await runInContainer({
|
|
942
|
+
projectDir,
|
|
943
|
+
image: gate.image,
|
|
944
|
+
// Gates run at the mount root (native gates run at the repo root).
|
|
945
|
+
command: `cd /home/runner/work && ${cmd}`,
|
|
946
|
+
timeout: gate.timeout, // undefined ⇒ unbounded (docker.ts gate 7)
|
|
947
|
+
env: containerEnv,
|
|
948
|
+
stream: true,
|
|
949
|
+
});
|
|
950
|
+
// Enforce the native invariant in ONE place: timedOut ⇒ 124.
|
|
951
|
+
return {
|
|
952
|
+
code: result.timedOut ? GATE_TIMEOUT_EXIT_CODE : result.exitCode,
|
|
953
|
+
timedOut: result.timedOut,
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
async function runGates(gates, projectDir, onStep,
|
|
957
|
+
// REQUIRED, so it MUST precede the optional `onOutcome` (TS1016: a required
|
|
958
|
+
// parameter cannot follow an optional one). Both call sites pass it positionally.
|
|
959
|
+
docker, onOutcome) {
|
|
904
960
|
if (gates.length === 0)
|
|
905
961
|
return;
|
|
906
962
|
const blockingFailures = [];
|
|
@@ -951,8 +1007,44 @@ async function runGates(gates, projectDir, onStep, onOutcome) {
|
|
|
951
1007
|
...extra,
|
|
952
1008
|
});
|
|
953
1009
|
report(onStep, stepId, label, "running");
|
|
954
|
-
//
|
|
955
|
-
|
|
1010
|
+
// Fail-closed matrix (slice 3): an image gate that cannot reach Docker is
|
|
1011
|
+
// REFUSED — it MUST NOT fall through to native/unpinned execution and MUST
|
|
1012
|
+
// NOT be silently skipped/passed. Blocking → build failure (feeds the
|
|
1013
|
+
// aggregate throw); informative → `warning` (never a false-green). A gate
|
|
1014
|
+
// WITHOUT `image` is unaffected and runs native regardless of --no-docker.
|
|
1015
|
+
//
|
|
1016
|
+
// `isAvailable()` is touched ONLY for an image gate under Docker: the
|
|
1017
|
+
// `noDocker` short-circuit means an image-less set (or a --no-docker run)
|
|
1018
|
+
// never shells out to `docker info`, keeping the native path zero-cost.
|
|
1019
|
+
if (gate.image !== undefined) {
|
|
1020
|
+
if (docker.noDocker || !(await docker.isAvailable())) {
|
|
1021
|
+
const why = docker.noDocker
|
|
1022
|
+
? "--no-docker set"
|
|
1023
|
+
: "Docker not available";
|
|
1024
|
+
const reason = `gate "${gate.id}" requires image "${gate.image}" but ${why} — refusing (never runs native/unpinned)`;
|
|
1025
|
+
if (blocking) {
|
|
1026
|
+
blockingFailures.push(gate.id);
|
|
1027
|
+
report(onStep, stepId, `${label} failed`, "error", reason);
|
|
1028
|
+
emit("error", { reason });
|
|
1029
|
+
}
|
|
1030
|
+
else {
|
|
1031
|
+
report(onStep, stepId, `${label} failed (informative)`, "warning", reason);
|
|
1032
|
+
emit("warning", { reason });
|
|
1033
|
+
}
|
|
1034
|
+
continue; // NEVER falls through to native execution.
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
// Per-gate env: build the INJECTED allowlist (engine keys + baseline) once,
|
|
1038
|
+
// then split into two maps (JDB-001):
|
|
1039
|
+
// - nativeEnv: full host env + injected + gate.env — a spawn env MAP (never
|
|
1040
|
+
// argv), so the host env is safe there.
|
|
1041
|
+
// - containerEnv: EXPLICIT ALLOWLIST ONLY (CI + injected + gate.env), NEVER
|
|
1042
|
+
// process.env — every entry becomes a `-e KEY=VALUE` argv element, so
|
|
1043
|
+
// forwarding process.env would leak host secrets to `ps aux` and defeat
|
|
1044
|
+
// the container's isolation.
|
|
1045
|
+
// Gate env spreads LAST in BOTH — a gate MAY override CI / CHANGED_FILES /
|
|
1046
|
+
// BASELINE (documented last-wins).
|
|
1047
|
+
const injected = {};
|
|
956
1048
|
let gateChangedFiles;
|
|
957
1049
|
if (gate.scope === GATE_SCOPE.CHANGED) {
|
|
958
1050
|
const scope = await resolveChangedScope();
|
|
@@ -970,15 +1062,22 @@ async function runGates(gates, projectDir, onStep, onOutcome) {
|
|
|
970
1062
|
continue;
|
|
971
1063
|
}
|
|
972
1064
|
gateChangedFiles = scope.files;
|
|
973
|
-
|
|
1065
|
+
injected[CHANGED_FILES_ENV] = scope.files.join("\n");
|
|
974
1066
|
}
|
|
975
1067
|
if (gate.baseline !== undefined) {
|
|
976
|
-
|
|
977
|
-
}
|
|
978
|
-
if (gate.env !== undefined) {
|
|
979
|
-
// Gate env spreads LAST — a gate MAY override CI / CHANGED_FILES / BASELINE.
|
|
980
|
-
Object.assign(gateEnv, gate.env);
|
|
1068
|
+
injected[BASELINE_ENV] = gate.baseline;
|
|
981
1069
|
}
|
|
1070
|
+
const gateOverrides = gate.env ?? {};
|
|
1071
|
+
const nativeEnv = {
|
|
1072
|
+
...baseEnv,
|
|
1073
|
+
...injected,
|
|
1074
|
+
...gateOverrides,
|
|
1075
|
+
};
|
|
1076
|
+
const containerEnv = {
|
|
1077
|
+
CI: "true",
|
|
1078
|
+
...injected,
|
|
1079
|
+
...gateOverrides,
|
|
1080
|
+
};
|
|
982
1081
|
let exitCode = 0;
|
|
983
1082
|
let timedOut = false;
|
|
984
1083
|
let spawnError;
|
|
@@ -987,7 +1086,7 @@ async function runGates(gates, projectDir, onStep, onOutcome) {
|
|
|
987
1086
|
// timeout is per-command (matches the fail-fast model): each command
|
|
988
1087
|
// gets its own wall-clock budget. A timed-out command is killed and
|
|
989
1088
|
// resolves non-zero, so fail-fast stops the gate here.
|
|
990
|
-
const result = await
|
|
1089
|
+
const result = await runGateCommand(gate, cmd, projectDir, nativeEnv, containerEnv);
|
|
991
1090
|
exitCode = result.code;
|
|
992
1091
|
timedOut = result.timedOut;
|
|
993
1092
|
if (exitCode !== 0)
|
package/dist/lib/docker.d.ts
CHANGED
|
@@ -10,17 +10,35 @@ export interface DockerRunOptions {
|
|
|
10
10
|
image: string;
|
|
11
11
|
/** Command to run inside the container */
|
|
12
12
|
command: string;
|
|
13
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* Wall-clock timeout in seconds, enforced HOST-SIDE (a host timer → `docker
|
|
15
|
+
* stop`), NOT by an in-container `timeout` binary. Omitted → the container
|
|
16
|
+
* runs UNBOUNDED (no timer armed) — there is no silent default cap.
|
|
17
|
+
*/
|
|
14
18
|
timeout?: number;
|
|
15
19
|
/** Stream output to stdout/stderr (default: true) */
|
|
16
20
|
stream?: boolean;
|
|
17
21
|
/** Override the user to run as inside the container (default: runner) */
|
|
18
22
|
user?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Extra env vars injected as discrete `-e KEY=VALUE` argv elements (one per
|
|
25
|
+
* entry), NEVER shell-spliced. Values with spaces/`=`/newlines survive
|
|
26
|
+
* verbatim (argv, not a shell string); Docker splits only on the first `=`.
|
|
27
|
+
* A caller passing no map yields the same argv as today (only `-e CI=true`).
|
|
28
|
+
*/
|
|
29
|
+
env?: Record<string, string>;
|
|
19
30
|
}
|
|
20
31
|
export interface DockerRunResult {
|
|
21
32
|
exitCode: number;
|
|
22
33
|
stdout: string;
|
|
23
34
|
stderr: string;
|
|
35
|
+
/**
|
|
36
|
+
* `true` IFF the host wall-clock timer fired and terminated the container —
|
|
37
|
+
* set DETERMINISTICALLY by the host BEFORE the kill, never inferred from a
|
|
38
|
+
* raw 124 exit code. Lets a caller tell a real timeout apart from a command
|
|
39
|
+
* that itself exits 124 (both surface non-zero, only one is a timeout).
|
|
40
|
+
*/
|
|
41
|
+
timedOut: boolean;
|
|
24
42
|
}
|
|
25
43
|
export interface DockerImageOptions {
|
|
26
44
|
stack: Stack;
|
package/dist/lib/docker.js
CHANGED
|
@@ -183,7 +183,7 @@ export async function ensureImage(options) {
|
|
|
183
183
|
* Streams output to process.stdout/stderr by default.
|
|
184
184
|
*/
|
|
185
185
|
export async function runInContainer(options) {
|
|
186
|
-
const { projectDir, image, command, timeout
|
|
186
|
+
const { projectDir, image, command, timeout, stream = true, user, env, } = options;
|
|
187
187
|
// The image is always pre-resolved by the caller (resolveCIRunners →
|
|
188
188
|
// ensureImage or an explicit/digest-pinned config image). No marker
|
|
189
189
|
// detection happens on this path — ever.
|
|
@@ -203,14 +203,33 @@ export async function runInContainer(options) {
|
|
|
203
203
|
const gid = process.getgid?.();
|
|
204
204
|
const runAsUser = user ??
|
|
205
205
|
(uid !== undefined && gid !== undefined ? `${uid}:${gid}` : undefined);
|
|
206
|
+
// Env injection (containerized-gates gate 2): CI=true FIRST, then one
|
|
207
|
+
// discrete `-e KEY=VALUE` argv pair per caller entry. These are argv
|
|
208
|
+
// elements handed to spawn (no shell), so values with spaces/`=`/newlines
|
|
209
|
+
// survive verbatim — Docker splits only on the first `=`. A caller passing
|
|
210
|
+
// no map yields the same argv as before (only `-e CI=true`).
|
|
211
|
+
const envArgs = ["-e", "CI=true"];
|
|
212
|
+
for (const [k, v] of Object.entries(env ?? {})) {
|
|
213
|
+
envArgs.push("-e", `${k}=${v}`);
|
|
214
|
+
}
|
|
215
|
+
// A unique container name so the HOST-SIDE timeout can `docker stop <cid>`
|
|
216
|
+
// a concrete target (design gate 1) rather than guessing.
|
|
217
|
+
const cid = `javi-forge-ci-${crypto.randomBytes(6).toString("hex")}`;
|
|
206
218
|
// Use --mount instead of -v: the -v form parses the value as a single
|
|
207
219
|
// "src:dst[:opt]" colon-separated string, which breaks (and could be
|
|
208
220
|
// hijacked) when projectDir itself contains a colon. --mount takes
|
|
209
221
|
// comma-separated key=value pairs and is colon-safe.
|
|
222
|
+
//
|
|
223
|
+
// NOTE (containerized-gates gate 1): the in-container `timeout <N>` wrapper
|
|
224
|
+
// is REMOVED. The timeout is enforced host-side below (host wall-clock timer
|
|
225
|
+
// → `docker stop`), so the container command is just `bash -c <cmd>`. This
|
|
226
|
+
// mirrors runGateNative (ci.ts) one level up, at the docker-run process.
|
|
210
227
|
const dockerArgs = [
|
|
211
228
|
"run",
|
|
212
229
|
"--rm",
|
|
213
230
|
...(isInteractive ? ["-it"] : []),
|
|
231
|
+
"--name",
|
|
232
|
+
cid,
|
|
214
233
|
"--stop-timeout",
|
|
215
234
|
"30",
|
|
216
235
|
"--entrypoint",
|
|
@@ -218,11 +237,8 @@ export async function runInContainer(options) {
|
|
|
218
237
|
...(runAsUser ? ["--user", runAsUser] : []),
|
|
219
238
|
"--mount",
|
|
220
239
|
`type=bind,source=${projectDir},target=/home/runner/work`,
|
|
221
|
-
|
|
222
|
-
"CI=true",
|
|
240
|
+
...envArgs,
|
|
223
241
|
imageName,
|
|
224
|
-
"timeout",
|
|
225
|
-
String(timeout),
|
|
226
242
|
"bash",
|
|
227
243
|
"-c",
|
|
228
244
|
command,
|
|
@@ -233,6 +249,40 @@ export async function runInContainer(options) {
|
|
|
233
249
|
});
|
|
234
250
|
let stdout = "";
|
|
235
251
|
let stderr = "";
|
|
252
|
+
// HOST-SIDE timeout (design gate 1): arm a host wall-clock timer ONLY when
|
|
253
|
+
// a timeout is provided. When it fires, set `timedOut = true` BEFORE the
|
|
254
|
+
// kill (authoritative — never inferred from the exit code), then
|
|
255
|
+
// `docker stop -t <grace> <cid>` (SIGTERM → SIGKILL after grace) to tear
|
|
256
|
+
// the CONTAINER down by name — NOT `proc.kill()` on the client, which would
|
|
257
|
+
// orphan the container. A `backstopTimer` SIGKILLs the docker-run CLIENT
|
|
258
|
+
// only as a LAST RESORT: if `docker stop` itself wedges (dead daemon) the
|
|
259
|
+
// client would never `close` and the gate would hang forever. `--rm` +
|
|
260
|
+
// `--name <cid>` keep any orphan bounded and identifiable. No timeout →
|
|
261
|
+
// no timer → the container runs UNBOUNDED (no silent cap).
|
|
262
|
+
let killTimer;
|
|
263
|
+
let backstopTimer;
|
|
264
|
+
let timedOut = false;
|
|
265
|
+
const clearTimers = () => {
|
|
266
|
+
if (killTimer !== undefined)
|
|
267
|
+
clearTimeout(killTimer);
|
|
268
|
+
if (backstopTimer !== undefined)
|
|
269
|
+
clearTimeout(backstopTimer);
|
|
270
|
+
};
|
|
271
|
+
if (timeout !== undefined) {
|
|
272
|
+
killTimer = setTimeout(() => {
|
|
273
|
+
timedOut = true;
|
|
274
|
+
// Fire-and-forget teardown: swallow spawn errors (e.g. the docker
|
|
275
|
+
// binary vanished mid-run) so an unhandled 'error' event can't crash
|
|
276
|
+
// the process. The armed backstopTimer still guarantees the run
|
|
277
|
+
// promise resolves via the client SIGKILL. (jd A+B convergent finding)
|
|
278
|
+
spawn("docker", ["stop", "-t", String(DOCKER_STOP_GRACE_SEC), cid], {
|
|
279
|
+
stdio: "ignore",
|
|
280
|
+
}).on("error", () => { });
|
|
281
|
+
backstopTimer = setTimeout(() => {
|
|
282
|
+
proc.kill("SIGKILL");
|
|
283
|
+
}, (DOCKER_STOP_GRACE_SEC + 1) * 1000);
|
|
284
|
+
}, timeout * 1000);
|
|
285
|
+
}
|
|
236
286
|
if (!stream) {
|
|
237
287
|
proc.stdout?.on("data", (d) => {
|
|
238
288
|
stdout += d.toString();
|
|
@@ -241,10 +291,22 @@ export async function runInContainer(options) {
|
|
|
241
291
|
stderr += d.toString();
|
|
242
292
|
});
|
|
243
293
|
}
|
|
244
|
-
proc.on("close", (code) =>
|
|
245
|
-
|
|
294
|
+
proc.on("close", (code) => {
|
|
295
|
+
clearTimers();
|
|
296
|
+
resolve({ exitCode: code ?? 1, stdout, stderr, timedOut });
|
|
297
|
+
});
|
|
298
|
+
proc.on("error", (e) => {
|
|
299
|
+
clearTimers();
|
|
300
|
+
reject(e);
|
|
301
|
+
});
|
|
246
302
|
});
|
|
247
303
|
}
|
|
304
|
+
/**
|
|
305
|
+
* Grace (seconds) passed to `docker stop -t` between the container SIGTERM and
|
|
306
|
+
* the daemon's escalated SIGKILL. The client-side backstop fires one second
|
|
307
|
+
* after this window to guarantee the run promise always resolves.
|
|
308
|
+
*/
|
|
309
|
+
const DOCKER_STOP_GRACE_SEC = 10;
|
|
248
310
|
/**
|
|
249
311
|
* Open an interactive shell inside the CI container.
|
|
250
312
|
* The image must be pre-resolved by the caller (no marker detection here).
|