javi-forge 1.16.0 → 1.17.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 +63 -9
- 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
|
@@ -900,6 +900,44 @@ const BASELINE_ENV = "JAVI_FORGE_BASELINE";
|
|
|
900
900
|
* `onOutcome` (optional) receives each gate's structured result for the headless
|
|
901
901
|
* JSON run path.
|
|
902
902
|
*/
|
|
903
|
+
/**
|
|
904
|
+
* Route a single gate command to its execution path and normalize the result to
|
|
905
|
+
* the EXACT `GateRunResult` shape the collector consumes, so the JSON/reason
|
|
906
|
+
* semantics are byte-identical for native and containerized gates.
|
|
907
|
+
*
|
|
908
|
+
* - `gate.image === undefined` → `runGateNative` (UNCHANGED), fed the full host
|
|
909
|
+
* env MAP (`nativeEnv`). A spawn env map never lands in argv, so the host env
|
|
910
|
+
* is safe there.
|
|
911
|
+
* - `gate.image !== undefined` → `runInContainer`, fed ONLY `containerEnv` — the
|
|
912
|
+
* EXPLICIT ALLOWLIST (`CI` + injected JAVI_FORGE_* + gate.env), NEVER
|
|
913
|
+
* `process.env` (JDB-001: no host secret in the `-e` argv / `ps aux`).
|
|
914
|
+
*
|
|
915
|
+
* The container's `timedOut` flag is normalized to `GATE_TIMEOUT_EXIT_CODE` (124)
|
|
916
|
+
* HERE — the one place gate semantics live — so `docker.ts` stays gate-agnostic
|
|
917
|
+
* and the collector's existing `timeoutReason` branch fires identically.
|
|
918
|
+
*
|
|
919
|
+
* NOTE: fail-closed (image gate + no Docker → refuse) is slice 3. In slice 2 an
|
|
920
|
+
* image gate always routes to the container when reached.
|
|
921
|
+
*/
|
|
922
|
+
async function runGateCommand(gate, cmd, projectDir, nativeEnv, containerEnv) {
|
|
923
|
+
if (gate.image === undefined) {
|
|
924
|
+
return await runGateNative(cmd, projectDir, nativeEnv, gate.timeout);
|
|
925
|
+
}
|
|
926
|
+
const result = await runInContainer({
|
|
927
|
+
projectDir,
|
|
928
|
+
image: gate.image,
|
|
929
|
+
// Gates run at the mount root (native gates run at the repo root).
|
|
930
|
+
command: `cd /home/runner/work && ${cmd}`,
|
|
931
|
+
timeout: gate.timeout, // undefined ⇒ unbounded (docker.ts gate 7)
|
|
932
|
+
env: containerEnv,
|
|
933
|
+
stream: true,
|
|
934
|
+
});
|
|
935
|
+
// Enforce the native invariant in ONE place: timedOut ⇒ 124.
|
|
936
|
+
return {
|
|
937
|
+
code: result.timedOut ? GATE_TIMEOUT_EXIT_CODE : result.exitCode,
|
|
938
|
+
timedOut: result.timedOut,
|
|
939
|
+
};
|
|
940
|
+
}
|
|
903
941
|
async function runGates(gates, projectDir, onStep, onOutcome) {
|
|
904
942
|
if (gates.length === 0)
|
|
905
943
|
return;
|
|
@@ -951,8 +989,17 @@ async function runGates(gates, projectDir, onStep, onOutcome) {
|
|
|
951
989
|
...extra,
|
|
952
990
|
});
|
|
953
991
|
report(onStep, stepId, label, "running");
|
|
954
|
-
// Per-gate env
|
|
955
|
-
|
|
992
|
+
// Per-gate env: build the INJECTED allowlist (engine keys + baseline) once,
|
|
993
|
+
// then split into two maps (JDB-001):
|
|
994
|
+
// - nativeEnv: full host env + injected + gate.env — a spawn env MAP (never
|
|
995
|
+
// argv), so the host env is safe there.
|
|
996
|
+
// - containerEnv: EXPLICIT ALLOWLIST ONLY (CI + injected + gate.env), NEVER
|
|
997
|
+
// process.env — every entry becomes a `-e KEY=VALUE` argv element, so
|
|
998
|
+
// forwarding process.env would leak host secrets to `ps aux` and defeat
|
|
999
|
+
// the container's isolation.
|
|
1000
|
+
// Gate env spreads LAST in BOTH — a gate MAY override CI / CHANGED_FILES /
|
|
1001
|
+
// BASELINE (documented last-wins).
|
|
1002
|
+
const injected = {};
|
|
956
1003
|
let gateChangedFiles;
|
|
957
1004
|
if (gate.scope === GATE_SCOPE.CHANGED) {
|
|
958
1005
|
const scope = await resolveChangedScope();
|
|
@@ -970,15 +1017,22 @@ async function runGates(gates, projectDir, onStep, onOutcome) {
|
|
|
970
1017
|
continue;
|
|
971
1018
|
}
|
|
972
1019
|
gateChangedFiles = scope.files;
|
|
973
|
-
|
|
1020
|
+
injected[CHANGED_FILES_ENV] = scope.files.join("\n");
|
|
974
1021
|
}
|
|
975
1022
|
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);
|
|
1023
|
+
injected[BASELINE_ENV] = gate.baseline;
|
|
981
1024
|
}
|
|
1025
|
+
const gateOverrides = gate.env ?? {};
|
|
1026
|
+
const nativeEnv = {
|
|
1027
|
+
...baseEnv,
|
|
1028
|
+
...injected,
|
|
1029
|
+
...gateOverrides,
|
|
1030
|
+
};
|
|
1031
|
+
const containerEnv = {
|
|
1032
|
+
CI: "true",
|
|
1033
|
+
...injected,
|
|
1034
|
+
...gateOverrides,
|
|
1035
|
+
};
|
|
982
1036
|
let exitCode = 0;
|
|
983
1037
|
let timedOut = false;
|
|
984
1038
|
let spawnError;
|
|
@@ -987,7 +1041,7 @@ async function runGates(gates, projectDir, onStep, onOutcome) {
|
|
|
987
1041
|
// timeout is per-command (matches the fail-fast model): each command
|
|
988
1042
|
// gets its own wall-clock budget. A timed-out command is killed and
|
|
989
1043
|
// resolves non-zero, so fail-fast stops the gate here.
|
|
990
|
-
const result = await
|
|
1044
|
+
const result = await runGateCommand(gate, cmd, projectDir, nativeEnv, containerEnv);
|
|
991
1045
|
exitCode = result.code;
|
|
992
1046
|
timedOut = result.timedOut;
|
|
993
1047
|
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).
|