javi-forge 1.20.0 → 1.21.1
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 +65 -3
- package/dist/lib/ci-config.js +31 -34
- package/dist/lib/docker.d.ts +8 -0
- package/dist/lib/docker.js +14 -3
- package/package.json +1 -1
package/dist/commands/ci.js
CHANGED
|
@@ -8,7 +8,7 @@ import fs from "fs-extra";
|
|
|
8
8
|
import { HOOK_ASSETS_DIR } from "../constants.js";
|
|
9
9
|
import { CI_STACKS, findCIConfig, GATE_MODE, GATE_SCOPE, loadCIConfig, } from "../lib/ci-config.js";
|
|
10
10
|
import { refreshContextDir } from "../lib/context.js";
|
|
11
|
-
import { ensureImage, isDockerAvailable, openShell, runInContainer, } from "../lib/docker.js";
|
|
11
|
+
import { CONTAINER_WORKDIR, ensureImage, isDockerAvailable, openShell, runInContainer, } from "../lib/docker.js";
|
|
12
12
|
import { execFileAsync } from "../lib/exec.js";
|
|
13
13
|
import { changedFiles, resolveBaseRef } from "../lib/git-diff.js";
|
|
14
14
|
// =============================================================================
|
|
@@ -880,9 +880,32 @@ const GATE_TIMEOUT_EXIT_CODE = 124;
|
|
|
880
880
|
* would corrupt line-based parsing on the gate side. This is a low-likelihood
|
|
881
881
|
* edge — repo paths with embedded newlines are pathological — and is accepted as
|
|
882
882
|
* a documented caveat rather than switched to NUL-joining, which would force
|
|
883
|
-
* every gate consumer to change its parser.
|
|
883
|
+
* every gate consumer to change its parser. A NUL-joined variant is also
|
|
884
|
+
* infeasible for a deeper reason (see {@link CHANGED_FILES_ABS_ENV}): a NUL byte
|
|
885
|
+
* cannot be carried in an environment variable at all.
|
|
884
886
|
*/
|
|
885
887
|
const CHANGED_FILES_ENV = "JAVI_FORGE_CHANGED_FILES";
|
|
888
|
+
/**
|
|
889
|
+
* Env var carrying the SAME changed-file set as {@link CHANGED_FILES_ENV}, but as
|
|
890
|
+
* ABSOLUTE paths, newline-joined, in the SAME order.
|
|
891
|
+
*
|
|
892
|
+
* WHY absolute and not "cwd-relative": a literal cwd-relative variant is
|
|
893
|
+
* impossible for the engine to produce. A gate chooses its own working directory
|
|
894
|
+
* at runtime (its script may `cd` anywhere), while at injection time the engine's
|
|
895
|
+
* cwd is always the repo root — so a "cwd-relative" var would just equal the
|
|
896
|
+
* repo-root-relative {@link CHANGED_FILES_ENV}. The cwd-INDEPENDENT form that lets
|
|
897
|
+
* a gate resolve changed files from ANY working directory is absolute paths.
|
|
898
|
+
*
|
|
899
|
+
* CONTEXT-DEPENDENT base (JDA-001): this is the ONE injected var whose VALUE must
|
|
900
|
+
* differ between execution modes, so it is NOT placed in the shared `injected`
|
|
901
|
+
* map. A NATIVE gate runs at the host repo root, so its base is the HOST
|
|
902
|
+
* `<projectDir>/<relpath>`. A CONTAINER gate runs with the repo bind-mounted at
|
|
903
|
+
* `CONTAINER_WORKDIR` (docker.ts mount target + WORKDIR), so its base is
|
|
904
|
+
* `<CONTAINER_WORKDIR>/<relpath>` — the HOST projectDir does not exist inside the
|
|
905
|
+
* container. Same var NAME in both; each gate sees the value valid for its own
|
|
906
|
+
* execution context.
|
|
907
|
+
*/
|
|
908
|
+
const CHANGED_FILES_ABS_ENV = "JAVI_FORGE_CHANGED_FILES_ABS";
|
|
886
909
|
/** Env var carrying a gate's optional baseline artifact path. */
|
|
887
910
|
const BASELINE_ENV = "JAVI_FORGE_BASELINE";
|
|
888
911
|
/**
|
|
@@ -942,7 +965,7 @@ async function runGateCommand(gate, cmd, projectDir, nativeEnv, containerEnv) {
|
|
|
942
965
|
projectDir,
|
|
943
966
|
image: gate.image,
|
|
944
967
|
// Gates run at the mount root (native gates run at the repo root).
|
|
945
|
-
command: `cd
|
|
968
|
+
command: `cd ${CONTAINER_WORKDIR} && ${cmd}`,
|
|
946
969
|
timeout: gate.timeout, // undefined ⇒ unbounded (docker.ts gate 7)
|
|
947
970
|
env: containerEnv,
|
|
948
971
|
stream: true,
|
|
@@ -1063,19 +1086,58 @@ docker, onOutcome) {
|
|
|
1063
1086
|
}
|
|
1064
1087
|
gateChangedFiles = scope.files;
|
|
1065
1088
|
injected[CHANGED_FILES_ENV] = scope.files.join("\n");
|
|
1089
|
+
// ABSOLUTE-path variant (GATE-4 / JDA-001) is DELIBERATELY NOT put in the
|
|
1090
|
+
// shared `injected` map: it is the ONE var whose value MUST differ between
|
|
1091
|
+
// native and container execution. A native gate runs at the host repo root
|
|
1092
|
+
// (cwd = projectDir), so its base is the HOST projectDir. A containerized
|
|
1093
|
+
// gate runs with the repo bind-mounted at CONTAINER_WORKDIR (docker.ts mount
|
|
1094
|
+
// target + WORKDIR), so `<projectDir>/<relpath>` would point at a
|
|
1095
|
+
// non-existent HOST path inside the container. Instead it is computed twice
|
|
1096
|
+
// below — once per execution context — and added to nativeEnv / containerEnv
|
|
1097
|
+
// separately. See the nativeEnv / containerEnv construction.
|
|
1098
|
+
// NUL-joined variant (GATE-5) is DELIBERATELY NOT INJECTED. A `git -z`
|
|
1099
|
+
// style NUL separator is unambiguous for paths containing a literal
|
|
1100
|
+
// newline, BUT a NUL byte cannot live in an environment variable: execve's
|
|
1101
|
+
// `environ` is an array of NUL-terminated C strings, so a NUL inside a
|
|
1102
|
+
// value truncates it. Node's child_process refuses it outright — it throws
|
|
1103
|
+
// ERR_INVALID_ARG_VALUE ("must be a string without null bytes") for a NUL
|
|
1104
|
+
// in BOTH an argv element (container `-e KEY=VALUE`) AND a spawn env-map
|
|
1105
|
+
// value (native gates). Empirically verified against Node + `docker run`.
|
|
1106
|
+
// Injecting JAVI_FORGE_CHANGED_FILES_Z would therefore CRASH every
|
|
1107
|
+
// scope:changed gate at spawn time (native and containerized alike), so it
|
|
1108
|
+
// is omitted entirely rather than shipped broken. The documented caveat on
|
|
1109
|
+
// CHANGED_FILES_ENV (paths with embedded newlines corrupt line parsing)
|
|
1110
|
+
// stands; the cwd-independent alternative that IS deliverable is
|
|
1111
|
+
// CHANGED_FILES_ABS_ENV above.
|
|
1066
1112
|
}
|
|
1067
1113
|
if (gate.baseline !== undefined) {
|
|
1068
1114
|
injected[BASELINE_ENV] = gate.baseline;
|
|
1069
1115
|
}
|
|
1070
1116
|
const gateOverrides = gate.env ?? {};
|
|
1117
|
+
// Context-dependent ABSOLUTE-path variant (JDA-001): the SAME env var NAME,
|
|
1118
|
+
// but a DIFFERENT base per execution mode. Native gates resolve against the
|
|
1119
|
+
// HOST projectDir (cwd = repo root); container gates resolve against
|
|
1120
|
+
// CONTAINER_WORKDIR — the exact mount target from docker.ts (single source of
|
|
1121
|
+
// truth), so the "absolute" path is valid INSIDE the container. Both use the
|
|
1122
|
+
// SAME relpaths in the SAME order, newline-joined, only for scope:changed
|
|
1123
|
+
// (gateChangedFiles defined). path.join for native normalizes a trailing
|
|
1124
|
+
// slash; the container form is a POSIX join under a known-absolute root.
|
|
1125
|
+
const nativeAbs = gateChangedFiles &&
|
|
1126
|
+
gateChangedFiles.map((rel) => path.join(projectDir, rel)).join("\n");
|
|
1127
|
+
const containerAbs = gateChangedFiles &&
|
|
1128
|
+
gateChangedFiles.map((rel) => `${CONTAINER_WORKDIR}/${rel}`).join("\n");
|
|
1071
1129
|
const nativeEnv = {
|
|
1072
1130
|
...baseEnv,
|
|
1073
1131
|
...injected,
|
|
1132
|
+
...(nativeAbs !== undefined && { [CHANGED_FILES_ABS_ENV]: nativeAbs }),
|
|
1074
1133
|
...gateOverrides,
|
|
1075
1134
|
};
|
|
1076
1135
|
const containerEnv = {
|
|
1077
1136
|
CI: "true",
|
|
1078
1137
|
...injected,
|
|
1138
|
+
...(containerAbs !== undefined && {
|
|
1139
|
+
[CHANGED_FILES_ABS_ENV]: containerAbs,
|
|
1140
|
+
}),
|
|
1079
1141
|
...gateOverrides,
|
|
1080
1142
|
};
|
|
1081
1143
|
let exitCode = 0;
|
package/dist/lib/ci-config.js
CHANGED
|
@@ -77,6 +77,35 @@ const RUNNER_FIELDS = new Set([
|
|
|
77
77
|
function isRecord(value) {
|
|
78
78
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
79
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* Validate an optional container `image` ref shared by the runner and gate
|
|
82
|
+
* paths (IMG-1). Rejects non-string / empty-after-trim and leading-dash refs
|
|
83
|
+
* (an image like "--privileged" or "-v /:/host" would be parsed by `docker run`
|
|
84
|
+
* as a FLAG, not an image, if it reached argv — JDB-004) with a named error at
|
|
85
|
+
* `fieldPath`. Returns the TRIMMED value to store, so a whitespace-padded ref
|
|
86
|
+
* fails cleanly at validation instead of reaching docker argv untrimmed.
|
|
87
|
+
* Returns undefined when the field is absent or invalid.
|
|
88
|
+
*/
|
|
89
|
+
function validateImageRef(value, fieldPath, errors) {
|
|
90
|
+
if (value === undefined)
|
|
91
|
+
return undefined;
|
|
92
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
93
|
+
errors.push({
|
|
94
|
+
path: fieldPath,
|
|
95
|
+
message: "image must be a non-empty string",
|
|
96
|
+
});
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
const trimmed = value.trim();
|
|
100
|
+
if (trimmed.startsWith("-")) {
|
|
101
|
+
errors.push({
|
|
102
|
+
path: fieldPath,
|
|
103
|
+
message: "image must not start with '-' (would be parsed as a docker flag)",
|
|
104
|
+
});
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
return trimmed;
|
|
108
|
+
}
|
|
80
109
|
function normalizeCommands(value, fieldPath, errors) {
|
|
81
110
|
if (value === undefined)
|
|
82
111
|
return [];
|
|
@@ -159,18 +188,7 @@ function validateRunner(raw, index, errors) {
|
|
|
159
188
|
}
|
|
160
189
|
}
|
|
161
190
|
}
|
|
162
|
-
|
|
163
|
-
if (raw.image !== undefined) {
|
|
164
|
-
if (typeof raw.image !== "string" || !raw.image.trim()) {
|
|
165
|
-
errors.push({
|
|
166
|
-
path: `${base}.image`,
|
|
167
|
-
message: "image must be a non-empty string",
|
|
168
|
-
});
|
|
169
|
-
}
|
|
170
|
-
else {
|
|
171
|
-
image = raw.image;
|
|
172
|
-
}
|
|
173
|
-
}
|
|
191
|
+
const image = validateImageRef(raw.image, `${base}.image`, errors);
|
|
174
192
|
let buildContext;
|
|
175
193
|
if (raw["build-context"] !== undefined) {
|
|
176
194
|
if (typeof raw["build-context"] !== "string" ||
|
|
@@ -319,28 +337,7 @@ function validateGate(raw, index, errors) {
|
|
|
319
337
|
env = raw.env;
|
|
320
338
|
}
|
|
321
339
|
}
|
|
322
|
-
|
|
323
|
-
if (raw.image !== undefined) {
|
|
324
|
-
if (typeof raw.image !== "string" || !raw.image.trim()) {
|
|
325
|
-
errors.push({
|
|
326
|
-
path: `${base}.image`,
|
|
327
|
-
message: "image must be a non-empty string",
|
|
328
|
-
});
|
|
329
|
-
}
|
|
330
|
-
else if (raw.image.trim().startsWith("-")) {
|
|
331
|
-
// Harden against docker-flag injection: an image ref like "--privileged"
|
|
332
|
-
// or "-v /:/host" would be parsed by `docker run` as a FLAG, not an
|
|
333
|
-
// image argument, if it reached argv. Reject leading-dash refs at
|
|
334
|
-
// validation with a named error (JDB-004).
|
|
335
|
-
errors.push({
|
|
336
|
-
path: `${base}.image`,
|
|
337
|
-
message: "image must not start with '-' (would be parsed as a docker flag)",
|
|
338
|
-
});
|
|
339
|
-
}
|
|
340
|
-
else {
|
|
341
|
-
image = raw.image;
|
|
342
|
-
}
|
|
343
|
-
}
|
|
340
|
+
const image = validateImageRef(raw.image, `${base}.image`, errors);
|
|
344
341
|
let timeout;
|
|
345
342
|
if (raw.timeout !== undefined) {
|
|
346
343
|
if (typeof raw.timeout !== "number" ||
|
package/dist/lib/docker.d.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
import type { Stack } from "../types/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* The in-container path the repo is bind-mounted at (and the WORKDIR gates run
|
|
4
|
+
* from). This is the SINGLE source of truth for the mount target: the `--mount`
|
|
5
|
+
* bind target below and the container-side `JAVI_FORGE_CHANGED_FILES_ABS` base
|
|
6
|
+
* in ci.ts MUST reference this same constant so they can never drift — the
|
|
7
|
+
* container absolute-path invariant is "abs base === mount target".
|
|
8
|
+
*/
|
|
9
|
+
export declare const CONTAINER_WORKDIR = "/home/runner/work";
|
|
2
10
|
export interface DockerRunOptions {
|
|
3
11
|
/** Absolute path to mount as /home/runner/work */
|
|
4
12
|
projectDir: string;
|
package/dist/lib/docker.js
CHANGED
|
@@ -4,6 +4,17 @@ import path from "node:path";
|
|
|
4
4
|
import fs from "fs-extra";
|
|
5
5
|
import { execFileAsync } from "./exec.js";
|
|
6
6
|
// =============================================================================
|
|
7
|
+
// Constants
|
|
8
|
+
// =============================================================================
|
|
9
|
+
/**
|
|
10
|
+
* The in-container path the repo is bind-mounted at (and the WORKDIR gates run
|
|
11
|
+
* from). This is the SINGLE source of truth for the mount target: the `--mount`
|
|
12
|
+
* bind target below and the container-side `JAVI_FORGE_CHANGED_FILES_ABS` base
|
|
13
|
+
* in ci.ts MUST reference this same constant so they can never drift — the
|
|
14
|
+
* container absolute-path invariant is "abs base === mount target".
|
|
15
|
+
*/
|
|
16
|
+
export const CONTAINER_WORKDIR = "/home/runner/work";
|
|
17
|
+
// =============================================================================
|
|
7
18
|
// Image name
|
|
8
19
|
// =============================================================================
|
|
9
20
|
export function getImageName(stack) {
|
|
@@ -236,7 +247,7 @@ export async function runInContainer(options) {
|
|
|
236
247
|
"",
|
|
237
248
|
...(runAsUser ? ["--user", runAsUser] : []),
|
|
238
249
|
"--mount",
|
|
239
|
-
`type=bind,source=${projectDir},target
|
|
250
|
+
`type=bind,source=${projectDir},target=${CONTAINER_WORKDIR}`,
|
|
240
251
|
...envArgs,
|
|
241
252
|
imageName,
|
|
242
253
|
"bash",
|
|
@@ -328,13 +339,13 @@ export async function openShell(projectDir, image) {
|
|
|
328
339
|
...(runAsUser ? ["--user", runAsUser] : []),
|
|
329
340
|
// --mount is colon-safe; see runInContainer for the rationale.
|
|
330
341
|
"--mount",
|
|
331
|
-
`type=bind,source=${projectDir},target
|
|
342
|
+
`type=bind,source=${projectDir},target=${CONTAINER_WORKDIR}`,
|
|
332
343
|
"-e",
|
|
333
344
|
"CI=true",
|
|
334
345
|
imageName,
|
|
335
346
|
"bash",
|
|
336
347
|
"-c",
|
|
337
|
-
|
|
348
|
+
`cd ${CONTAINER_WORKDIR} && exec bash`,
|
|
338
349
|
], { stdio: "inherit" });
|
|
339
350
|
proc.on("close", () => resolve());
|
|
340
351
|
proc.on("error", reject);
|