javi-forge 1.10.0 → 1.11.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/dispatch/ci.js +13 -1
- package/dist/commands/ci-validate.d.ts +8 -0
- package/dist/commands/ci-validate.js +12 -1
- package/dist/commands/ci.js +42 -18
- package/dist/lib/ci-config.d.ts +37 -0
- package/dist/lib/ci-config.js +195 -9
- package/dist/lib/docker.js +21 -1
- package/package.json +1 -1
package/dist/cli/dispatch/ci.js
CHANGED
|
@@ -33,14 +33,26 @@ export async function handleCi(cli, ctx) {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
else if (cli.flags.json) {
|
|
36
|
-
|
|
36
|
+
const gates = result.gates ?? [];
|
|
37
|
+
console.log(JSON.stringify({
|
|
38
|
+
ok: true,
|
|
39
|
+
runners: result.runners,
|
|
40
|
+
...(gates.length > 0 ? { gates } : {}),
|
|
41
|
+
}, null, 2));
|
|
37
42
|
}
|
|
38
43
|
else {
|
|
44
|
+
const gates = result.gates ?? [];
|
|
39
45
|
console.log(`✓ CI config valid: ${result.configPath}`);
|
|
40
46
|
console.log(` ${result.runners.length} runner(s):`);
|
|
41
47
|
for (const runner of result.runners) {
|
|
42
48
|
console.log(` - ${runner.name} (${runner.stack})`);
|
|
43
49
|
}
|
|
50
|
+
if (gates.length > 0) {
|
|
51
|
+
console.log(` ${gates.length} gate(s):`);
|
|
52
|
+
for (const gate of gates) {
|
|
53
|
+
console.log(` - ${gate.id} (${gate.mode}, scope: ${gate.scope})`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
44
56
|
}
|
|
45
57
|
process.exit(0);
|
|
46
58
|
}
|
|
@@ -12,6 +12,12 @@ export interface CIValidateRunnerSummary {
|
|
|
12
12
|
name: string;
|
|
13
13
|
stack: string;
|
|
14
14
|
}
|
|
15
|
+
/** One validated gate, reduced to what the report shows. */
|
|
16
|
+
export interface CIValidateGateSummary {
|
|
17
|
+
id: string;
|
|
18
|
+
mode: string;
|
|
19
|
+
scope: string;
|
|
20
|
+
}
|
|
15
21
|
export interface CIValidateOk {
|
|
16
22
|
ok: true;
|
|
17
23
|
/**
|
|
@@ -24,6 +30,8 @@ export interface CIValidateOk {
|
|
|
24
30
|
/** Resolved config path, or null in auto-detect mode. */
|
|
25
31
|
configPath: string | null;
|
|
26
32
|
runners: CIValidateRunnerSummary[];
|
|
33
|
+
/** Declared gates (version 2). Empty when none are declared. */
|
|
34
|
+
gates: CIValidateGateSummary[];
|
|
27
35
|
}
|
|
28
36
|
export interface CIValidateErr {
|
|
29
37
|
ok: false;
|
|
@@ -35,7 +35,13 @@ export async function validateCIConfig(projectDir, config) {
|
|
|
35
35
|
else {
|
|
36
36
|
const discovered = await findCIConfig(projectDir);
|
|
37
37
|
if (!discovered) {
|
|
38
|
-
return {
|
|
38
|
+
return {
|
|
39
|
+
ok: true,
|
|
40
|
+
mode: "auto-detect",
|
|
41
|
+
configPath: null,
|
|
42
|
+
runners: [],
|
|
43
|
+
gates: [],
|
|
44
|
+
};
|
|
39
45
|
}
|
|
40
46
|
configPath = discovered;
|
|
41
47
|
}
|
|
@@ -49,6 +55,11 @@ export async function validateCIConfig(projectDir, config) {
|
|
|
49
55
|
name: r.name,
|
|
50
56
|
stack: r.stack,
|
|
51
57
|
})),
|
|
58
|
+
gates: (ciConfig.gates ?? []).map((g) => ({
|
|
59
|
+
id: g.id,
|
|
60
|
+
mode: g.mode,
|
|
61
|
+
scope: g.scope,
|
|
62
|
+
})),
|
|
52
63
|
};
|
|
53
64
|
}
|
|
54
65
|
catch (e) {
|
package/dist/commands/ci.js
CHANGED
|
@@ -83,13 +83,13 @@ async function buildCICommands(stack, buildTool, projectDir) {
|
|
|
83
83
|
case "java-gradle":
|
|
84
84
|
return {
|
|
85
85
|
lintCmd: "./gradlew spotlessCheck --no-daemon",
|
|
86
|
-
compileCmd: "./gradlew clean classes testClasses --no-daemon
|
|
86
|
+
compileCmd: "./gradlew clean classes testClasses --no-daemon",
|
|
87
87
|
testCmd: "./gradlew test --no-daemon",
|
|
88
88
|
};
|
|
89
89
|
case "java-maven":
|
|
90
90
|
return {
|
|
91
91
|
lintCmd: "./mvnw spotless:check",
|
|
92
|
-
compileCmd: "./mvnw clean compile test-compile
|
|
92
|
+
compileCmd: "./mvnw clean compile test-compile",
|
|
93
93
|
testCmd: "./mvnw test",
|
|
94
94
|
};
|
|
95
95
|
case "node": {
|
|
@@ -101,14 +101,14 @@ async function buildCICommands(stack, buildTool, projectDir) {
|
|
|
101
101
|
catch {
|
|
102
102
|
/* no package.json */
|
|
103
103
|
}
|
|
104
|
-
// Clean dist/ before build
|
|
105
|
-
//
|
|
104
|
+
// Clean dist/ before build so a stale directory never masks a broken
|
|
105
|
+
// build. The container runs as the host uid (see runInContainer,
|
|
106
|
+
// ENV-1), so output lands host-owned — no chown needed.
|
|
106
107
|
const buildPrefix = "rm -rf dist/ && ";
|
|
107
|
-
const buildSuffix = " && chown -R runner:runner dist/ 2>/dev/null || true";
|
|
108
108
|
return {
|
|
109
109
|
lintCmd: pkgContent.includes('"lint"') ? `${buildTool} run lint` : null,
|
|
110
110
|
compileCmd: pkgContent.includes('"build"')
|
|
111
|
-
? `${buildPrefix}${buildTool} run build
|
|
111
|
+
? `${buildPrefix}${buildTool} run build`
|
|
112
112
|
: null,
|
|
113
113
|
testCmd: pkgContent.includes('"test"')
|
|
114
114
|
? `${buildTool} ${buildTool === "npm" ? "test" : "run test"}`
|
|
@@ -124,13 +124,13 @@ async function buildCICommands(stack, buildTool, projectDir) {
|
|
|
124
124
|
case "go":
|
|
125
125
|
return {
|
|
126
126
|
lintCmd: "golangci-lint run",
|
|
127
|
-
compileCmd: "go clean -cache && go build ./...
|
|
127
|
+
compileCmd: "go clean -cache && go build ./...",
|
|
128
128
|
testCmd: "go test ./...",
|
|
129
129
|
};
|
|
130
130
|
case "rust":
|
|
131
131
|
return {
|
|
132
132
|
lintCmd: "cargo clippy -- -D warnings",
|
|
133
|
-
compileCmd: "cargo clean && cargo build
|
|
133
|
+
compileCmd: "cargo clean && cargo build",
|
|
134
134
|
testCmd: "cargo test",
|
|
135
135
|
};
|
|
136
136
|
default:
|
|
@@ -349,10 +349,26 @@ export async function runCI(options, onStep) {
|
|
|
349
349
|
report(onStep, "docker-image", "Building Docker image", "running");
|
|
350
350
|
let shellImage;
|
|
351
351
|
try {
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
352
|
+
// B2: honor a per-runner pinned image / build context the same way the
|
|
353
|
+
// runner loop does, instead of always deriving the stack default. An
|
|
354
|
+
// explicit image passes through verbatim; a build context is built with
|
|
355
|
+
// the deterministic per-runner tag; otherwise fall back to the stack image.
|
|
356
|
+
if (primary.image) {
|
|
357
|
+
shellImage = primary.image;
|
|
358
|
+
}
|
|
359
|
+
else if (primary.buildContext) {
|
|
360
|
+
shellImage = await ensureImage({
|
|
361
|
+
stack: primary.stack,
|
|
362
|
+
buildContext: path.resolve(projectDir, primary.buildContext),
|
|
363
|
+
imageTag: `javi-forge-ci-${primary.name}`,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
shellImage = await ensureImage({
|
|
368
|
+
stack: stackInfo.stackType,
|
|
369
|
+
javaVersion: stackInfo.javaVersion,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
356
372
|
report(onStep, "docker-image", "Docker image ready", "done");
|
|
357
373
|
}
|
|
358
374
|
catch (e) {
|
|
@@ -411,10 +427,16 @@ export async function runCI(options, onStep) {
|
|
|
411
427
|
report(onStep, stepContext, "Refresh .context/ directory", "error", String(e));
|
|
412
428
|
}
|
|
413
429
|
// ── Runner execution ─────────────────────────────────────────────────────────
|
|
414
|
-
// ONE executor for every resolution source. Naming is a function of
|
|
415
|
-
//
|
|
416
|
-
// silently rename a single-runner CONFIG
|
|
417
|
-
|
|
430
|
+
// ONE executor for every resolution source. Naming is a function of whether
|
|
431
|
+
// the runner NAME is IMPLICIT or EXPLICIT — never of `resolved.runners.length`,
|
|
432
|
+
// which would silently rename a single-runner CONFIG (B1):
|
|
433
|
+
// - IMPLICIT name → BARE ids: `auto` (zero-config) OR `stack-override`
|
|
434
|
+
// (`--stack`, the user never named the runner).
|
|
435
|
+
// - EXPLICIT name → SUFFIXED ids: `config` (a runner named in ci.yaml).
|
|
436
|
+
const implicitName = resolved.source === "auto" || resolved.source === "stack-override";
|
|
437
|
+
const naming = implicitName
|
|
438
|
+
? NAMING_MODE.BARE
|
|
439
|
+
: NAMING_MODE.SUFFIXED;
|
|
418
440
|
for (const runner of resolved.runners) {
|
|
419
441
|
await runRunner(runner, {
|
|
420
442
|
projectDir,
|
|
@@ -499,7 +521,10 @@ async function runRunner(runner, ctx) {
|
|
|
499
521
|
imageName = ctx.preresolvedImage;
|
|
500
522
|
}
|
|
501
523
|
else {
|
|
502
|
-
|
|
524
|
+
// Under an IMPLICIT name (bare) the image step id is unsuffixed too, so a
|
|
525
|
+
// `--stack` run reads identically to zero-config auto. A CONFIG runner
|
|
526
|
+
// (suffixed) keeps `docker-image:<name>` (the R3 guard).
|
|
527
|
+
const stepImage = bare ? "docker-image" : `docker-image:${runner.name}`;
|
|
503
528
|
try {
|
|
504
529
|
if (runner.buildContext) {
|
|
505
530
|
report(onStep, stepImage, `Building image for ${runner.name} from ${runner.buildContext}`, "running");
|
|
@@ -565,7 +590,6 @@ async function runRunner(runner, ctx) {
|
|
|
565
590
|
id: "compile",
|
|
566
591
|
label: "Compile",
|
|
567
592
|
cmds: runner.compileCmds,
|
|
568
|
-
user: "root",
|
|
569
593
|
skip: false,
|
|
570
594
|
},
|
|
571
595
|
{
|
package/dist/lib/ci-config.d.ts
CHANGED
|
@@ -10,6 +10,20 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import type { Stack } from "../types/index.js";
|
|
12
12
|
export declare const CI_CONFIG_VERSION = 1;
|
|
13
|
+
/** Config schema versions this loader accepts. v2 is additive (unlocks gates). */
|
|
14
|
+
export declare const CI_CONFIG_VERSIONS: readonly number[];
|
|
15
|
+
/** Gate outcome mode: a blocking gate fails the build, informative degrades. */
|
|
16
|
+
export declare const GATE_MODE: {
|
|
17
|
+
readonly BLOCKING: "blocking";
|
|
18
|
+
readonly INFORMATIVE: "informative";
|
|
19
|
+
};
|
|
20
|
+
export type GateMode = (typeof GATE_MODE)[keyof typeof GATE_MODE];
|
|
21
|
+
/** Gate file scope: all files, or only the changed set (slice 4 wiring). */
|
|
22
|
+
export declare const GATE_SCOPE: {
|
|
23
|
+
readonly ALL: "all";
|
|
24
|
+
readonly CHANGED: "changed";
|
|
25
|
+
};
|
|
26
|
+
export type GateScope = (typeof GATE_SCOPE)[keyof typeof GATE_SCOPE];
|
|
13
27
|
/** Default config locations, in discovery order, relative to the project root. */
|
|
14
28
|
export declare const CI_CONFIG_CANDIDATES: readonly [".javi-forge/ci.yaml", ".javi-forge/ci.yml"];
|
|
15
29
|
export interface CIRunnerConfig {
|
|
@@ -32,9 +46,26 @@ export interface CIRunnerConfig {
|
|
|
32
46
|
/** Tools that must exist in the runner environment (validated fail-closed) */
|
|
33
47
|
requires: string[];
|
|
34
48
|
}
|
|
49
|
+
/** A declarable named quality gate (version 2 only). Runs host-native. */
|
|
50
|
+
export interface CIGateConfig {
|
|
51
|
+
/** Unique, tag-safe identifier (reuses RUNNER_NAME_RE). */
|
|
52
|
+
id: string;
|
|
53
|
+
/** Command(s) to run — string or list, normalized to a list. */
|
|
54
|
+
run: string[];
|
|
55
|
+
/** blocking (default) fails the build; informative degrades to a warning. */
|
|
56
|
+
mode: GateMode;
|
|
57
|
+
/** all (default) or changed — the file scope the gate cares about. */
|
|
58
|
+
scope: GateScope;
|
|
59
|
+
/** Optional baseline artifact path (slice 4). */
|
|
60
|
+
baseline?: string;
|
|
61
|
+
/** Optional env injected via the child-process env map (slice 4). */
|
|
62
|
+
env?: Record<string, string>;
|
|
63
|
+
}
|
|
35
64
|
export interface CIConfig {
|
|
36
65
|
version: number;
|
|
37
66
|
runners: CIRunnerConfig[];
|
|
67
|
+
/** Present only under version 2 when `gates:` is declared. */
|
|
68
|
+
gates?: CIGateConfig[];
|
|
38
69
|
}
|
|
39
70
|
export interface CIConfigValidationError {
|
|
40
71
|
path: string;
|
|
@@ -46,6 +77,12 @@ export declare class CIConfigError extends Error {
|
|
|
46
77
|
}
|
|
47
78
|
/** Stacks accepted by the CI config schema and the --stack override. */
|
|
48
79
|
export declare const CI_STACKS: readonly string[];
|
|
80
|
+
/**
|
|
81
|
+
* Validate the `gates:` block (version 2 only). Every schema error names the
|
|
82
|
+
* offending field; a duplicate id is reported once. Returns the parsed gates
|
|
83
|
+
* (the caller discards them all if `errors` is non-empty — fail closed).
|
|
84
|
+
*/
|
|
85
|
+
export declare function validateGates(raw: unknown, errors: CIConfigValidationError[]): CIGateConfig[];
|
|
49
86
|
/**
|
|
50
87
|
* Parse and validate CI config YAML text. Throws CIConfigError listing every
|
|
51
88
|
* validation problem; never returns a partially valid config.
|
package/dist/lib/ci-config.js
CHANGED
|
@@ -15,6 +15,18 @@ import YAML from "yaml";
|
|
|
15
15
|
// Types
|
|
16
16
|
// =============================================================================
|
|
17
17
|
export const CI_CONFIG_VERSION = 1;
|
|
18
|
+
/** Config schema versions this loader accepts. v2 is additive (unlocks gates). */
|
|
19
|
+
export const CI_CONFIG_VERSIONS = [1, 2];
|
|
20
|
+
/** Gate outcome mode: a blocking gate fails the build, informative degrades. */
|
|
21
|
+
export const GATE_MODE = {
|
|
22
|
+
BLOCKING: "blocking",
|
|
23
|
+
INFORMATIVE: "informative",
|
|
24
|
+
};
|
|
25
|
+
/** Gate file scope: all files, or only the changed set (slice 4 wiring). */
|
|
26
|
+
export const GATE_SCOPE = {
|
|
27
|
+
ALL: "all",
|
|
28
|
+
CHANGED: "changed",
|
|
29
|
+
};
|
|
18
30
|
/** Default config locations, in discovery order, relative to the project root. */
|
|
19
31
|
export const CI_CONFIG_CANDIDATES = [
|
|
20
32
|
".javi-forge/ci.yaml",
|
|
@@ -202,6 +214,144 @@ function validateRunner(raw, index, errors) {
|
|
|
202
214
|
requires,
|
|
203
215
|
};
|
|
204
216
|
}
|
|
217
|
+
const GATE_FIELDS = new Set(["id", "run", "mode", "scope", "baseline", "env"]);
|
|
218
|
+
function validateGate(raw, index, errors) {
|
|
219
|
+
const base = `gates[${index}]`;
|
|
220
|
+
if (!isRecord(raw)) {
|
|
221
|
+
errors.push({ path: base, message: "gate must be an object" });
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
for (const key of Object.keys(raw)) {
|
|
225
|
+
if (!GATE_FIELDS.has(key)) {
|
|
226
|
+
errors.push({
|
|
227
|
+
path: `${base}.${key}`,
|
|
228
|
+
message: `unknown field "${key}"`,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const id = raw.id;
|
|
233
|
+
if (typeof id !== "string" || !id.trim()) {
|
|
234
|
+
errors.push({
|
|
235
|
+
path: `${base}.id`,
|
|
236
|
+
message: "id is required and must be a non-empty string",
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
else if (!RUNNER_NAME_RE.test(id.trim())) {
|
|
240
|
+
errors.push({
|
|
241
|
+
path: `${base}.id`,
|
|
242
|
+
message: "id must start with a letter, digit or underscore and contain only [a-zA-Z0-9._-] (it is used as a tag)",
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
const run = normalizeCommands(raw.run, `${base}.run`, errors);
|
|
246
|
+
if (raw.run === undefined) {
|
|
247
|
+
errors.push({
|
|
248
|
+
path: `${base}.run`,
|
|
249
|
+
message: "run is required and must be a non-empty string or a list of non-empty strings",
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
else if (run.length === 0 &&
|
|
253
|
+
!errors.some((e) => e.path === `${base}.run`)) {
|
|
254
|
+
errors.push({
|
|
255
|
+
path: `${base}.run`,
|
|
256
|
+
message: "run must be a non-empty string or a list of non-empty strings",
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
let mode = GATE_MODE.BLOCKING;
|
|
260
|
+
if (raw.mode !== undefined) {
|
|
261
|
+
if (raw.mode === GATE_MODE.BLOCKING || raw.mode === GATE_MODE.INFORMATIVE) {
|
|
262
|
+
mode = raw.mode;
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
errors.push({
|
|
266
|
+
path: `${base}.mode`,
|
|
267
|
+
message: `mode must be one of: ${GATE_MODE.BLOCKING}, ${GATE_MODE.INFORMATIVE} (got "${String(raw.mode)}")`,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
let scope = GATE_SCOPE.ALL;
|
|
272
|
+
if (raw.scope !== undefined) {
|
|
273
|
+
if (raw.scope === GATE_SCOPE.ALL || raw.scope === GATE_SCOPE.CHANGED) {
|
|
274
|
+
scope = raw.scope;
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
errors.push({
|
|
278
|
+
path: `${base}.scope`,
|
|
279
|
+
message: `scope must be one of: ${GATE_SCOPE.ALL}, ${GATE_SCOPE.CHANGED} (got "${String(raw.scope)}")`,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
let baseline;
|
|
284
|
+
if (raw.baseline !== undefined) {
|
|
285
|
+
if (typeof raw.baseline !== "string" || !raw.baseline.trim()) {
|
|
286
|
+
errors.push({
|
|
287
|
+
path: `${base}.baseline`,
|
|
288
|
+
message: "baseline must be a non-empty string (path)",
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
baseline = raw.baseline;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
let env;
|
|
296
|
+
if (raw.env !== undefined) {
|
|
297
|
+
if (!isRecord(raw.env)) {
|
|
298
|
+
errors.push({
|
|
299
|
+
path: `${base}.env`,
|
|
300
|
+
message: "env must be a mapping of string keys to string values",
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
else if (Object.values(raw.env).some((v) => typeof v !== "string")) {
|
|
304
|
+
errors.push({
|
|
305
|
+
path: `${base}.env`,
|
|
306
|
+
message: "env values must all be strings",
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
env = raw.env;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return {
|
|
314
|
+
id: typeof id === "string" ? id.trim() : "",
|
|
315
|
+
run,
|
|
316
|
+
mode,
|
|
317
|
+
scope,
|
|
318
|
+
baseline,
|
|
319
|
+
env,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Validate the `gates:` block (version 2 only). Every schema error names the
|
|
324
|
+
* offending field; a duplicate id is reported once. Returns the parsed gates
|
|
325
|
+
* (the caller discards them all if `errors` is non-empty — fail closed).
|
|
326
|
+
*/
|
|
327
|
+
export function validateGates(raw, errors) {
|
|
328
|
+
if (!Array.isArray(raw)) {
|
|
329
|
+
errors.push({ path: "gates", message: "gates must be a non-empty list" });
|
|
330
|
+
return [];
|
|
331
|
+
}
|
|
332
|
+
if (raw.length === 0) {
|
|
333
|
+
errors.push({ path: "gates", message: "gates must be a non-empty list" });
|
|
334
|
+
return [];
|
|
335
|
+
}
|
|
336
|
+
const gates = [];
|
|
337
|
+
const seen = new Set();
|
|
338
|
+
raw.forEach((item, index) => {
|
|
339
|
+
const gate = validateGate(item, index, errors);
|
|
340
|
+
if (!gate)
|
|
341
|
+
return;
|
|
342
|
+
if (gate.id) {
|
|
343
|
+
if (seen.has(gate.id)) {
|
|
344
|
+
errors.push({
|
|
345
|
+
path: `gates[${index}].id`,
|
|
346
|
+
message: `duplicate gate id "${gate.id}"`,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
seen.add(gate.id);
|
|
350
|
+
}
|
|
351
|
+
gates.push(gate);
|
|
352
|
+
});
|
|
353
|
+
return gates;
|
|
354
|
+
}
|
|
205
355
|
// =============================================================================
|
|
206
356
|
// Public API
|
|
207
357
|
// =============================================================================
|
|
@@ -226,23 +376,54 @@ export function parseCIConfig(rawYaml, source) {
|
|
|
226
376
|
if (!isRecord(doc)) {
|
|
227
377
|
throw new CIConfigError([{ path: "<document>", message: "config must be a YAML object/mapping" }], source);
|
|
228
378
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
379
|
+
// Read the version FIRST: the allowed-key set is a function of the accepted
|
|
380
|
+
// version, so `gates` under v1 reports the named "gates require version: 2"
|
|
381
|
+
// error, never the generic unknown-field error (JDA-006).
|
|
382
|
+
const version = typeof doc.version === "number" && CI_CONFIG_VERSIONS.includes(doc.version)
|
|
383
|
+
? doc.version
|
|
384
|
+
: undefined;
|
|
385
|
+
if (version === undefined) {
|
|
235
386
|
errors.push({
|
|
236
387
|
path: "version",
|
|
237
|
-
message: `version is required and must be
|
|
388
|
+
message: `version is required and must be one of: ${CI_CONFIG_VERSIONS.join(", ")}`,
|
|
238
389
|
});
|
|
239
390
|
}
|
|
240
|
-
|
|
391
|
+
const isV2 = version === 2;
|
|
392
|
+
const hasGates = doc.gates !== undefined;
|
|
393
|
+
for (const key of Object.keys(doc)) {
|
|
394
|
+
if (TOP_LEVEL_FIELDS.has(key))
|
|
395
|
+
continue;
|
|
396
|
+
if (key === "gates") {
|
|
397
|
+
// `gates` is only a known key under v2; under any other version it is a
|
|
398
|
+
// named schema error that takes precedence over the generic path.
|
|
399
|
+
if (!isV2) {
|
|
400
|
+
errors.push({ path: "gates", message: "gates require version: 2" });
|
|
401
|
+
}
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
errors.push({ path: key, message: `unknown field "${key}"` });
|
|
405
|
+
}
|
|
406
|
+
if (isV2) {
|
|
407
|
+
// v2: runners OPTIONAL when gates present; NEITHER runners nor gates fails
|
|
408
|
+
// closed (nothing to run).
|
|
409
|
+
if (!hasGates &&
|
|
410
|
+
(!Array.isArray(doc.runners) || doc.runners.length === 0)) {
|
|
411
|
+
errors.push({
|
|
412
|
+
path: "runners",
|
|
413
|
+
message: "a version 2 config must declare runners or gates (nothing to run otherwise)",
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
else if (!Array.isArray(doc.runners) || doc.runners.length === 0) {
|
|
418
|
+
// v1 (and invalid-version) still hard-require a non-empty runners list.
|
|
241
419
|
errors.push({
|
|
242
420
|
path: "runners",
|
|
243
421
|
message: "runners is required and must be a non-empty list",
|
|
244
422
|
});
|
|
245
423
|
}
|
|
424
|
+
// Gates are validated ONLY under v2 — a v1+gates config is already rejected
|
|
425
|
+
// above and must not surface a second, confusing wave of gate-field errors.
|
|
426
|
+
const gates = isV2 && hasGates ? validateGates(doc.gates, errors) : [];
|
|
246
427
|
const runners = [];
|
|
247
428
|
if (Array.isArray(doc.runners)) {
|
|
248
429
|
doc.runners.forEach((raw, index) => {
|
|
@@ -266,7 +447,12 @@ export function parseCIConfig(rawYaml, source) {
|
|
|
266
447
|
if (errors.length > 0) {
|
|
267
448
|
throw new CIConfigError(errors, source);
|
|
268
449
|
}
|
|
269
|
-
|
|
450
|
+
// `version` is defined here (an undefined version pushed an error above and
|
|
451
|
+
// would have thrown). A v1 config carries NO gates key — byte-identical shape.
|
|
452
|
+
const config = { version: version ?? CI_CONFIG_VERSION, runners };
|
|
453
|
+
if (gates.length > 0)
|
|
454
|
+
config.gates = gates;
|
|
455
|
+
return config;
|
|
270
456
|
}
|
|
271
457
|
/**
|
|
272
458
|
* Load and validate a CI config file. Fails closed: a missing file or any
|
package/dist/lib/docker.js
CHANGED
|
@@ -189,6 +189,20 @@ export async function runInContainer(options) {
|
|
|
189
189
|
// detection happens on this path — ever.
|
|
190
190
|
const imageName = image;
|
|
191
191
|
const isInteractive = process.stdin.isTTY && stream;
|
|
192
|
+
// ENV-1: match the container process to the HOST user. The images bake a
|
|
193
|
+
// `runner` user whose uid depends on the base (1001 on node:22-slim, where
|
|
194
|
+
// uid 1000 is already `node`). When that uid differs from the host uid,
|
|
195
|
+
// everything the container writes to the bind-mounted workspace (dist/,
|
|
196
|
+
// node_modules/.vite-temp, build output) lands owned by the wrong user,
|
|
197
|
+
// and the host's local vitest then fails with EACCES. Running as the host
|
|
198
|
+
// uid:gid makes artifacts host-owned — no chown dance, ever. An explicit
|
|
199
|
+
// `user` override still wins (e.g. a caller that needs root). getuid/getgid
|
|
200
|
+
// are undefined on non-POSIX platforms (Windows); there we omit the flag
|
|
201
|
+
// and keep the image default.
|
|
202
|
+
const uid = process.getuid?.();
|
|
203
|
+
const gid = process.getgid?.();
|
|
204
|
+
const runAsUser = user ??
|
|
205
|
+
(uid !== undefined && gid !== undefined ? `${uid}:${gid}` : undefined);
|
|
192
206
|
// Use --mount instead of -v: the -v form parses the value as a single
|
|
193
207
|
// "src:dst[:opt]" colon-separated string, which breaks (and could be
|
|
194
208
|
// hijacked) when projectDir itself contains a colon. --mount takes
|
|
@@ -201,7 +215,7 @@ export async function runInContainer(options) {
|
|
|
201
215
|
"30",
|
|
202
216
|
"--entrypoint",
|
|
203
217
|
"",
|
|
204
|
-
...(
|
|
218
|
+
...(runAsUser ? ["--user", runAsUser] : []),
|
|
205
219
|
"--mount",
|
|
206
220
|
`type=bind,source=${projectDir},target=/home/runner/work`,
|
|
207
221
|
"-e",
|
|
@@ -237,6 +251,11 @@ export async function runInContainer(options) {
|
|
|
237
251
|
*/
|
|
238
252
|
export async function openShell(projectDir, image) {
|
|
239
253
|
const imageName = image;
|
|
254
|
+
// ENV-1: run the interactive shell as the host user too, so anything
|
|
255
|
+
// written from the debug shell stays host-owned. See runInContainer.
|
|
256
|
+
const uid = process.getuid?.();
|
|
257
|
+
const gid = process.getgid?.();
|
|
258
|
+
const runAsUser = uid !== undefined && gid !== undefined ? `${uid}:${gid}` : undefined;
|
|
240
259
|
await new Promise((resolve, reject) => {
|
|
241
260
|
const proc = spawn("docker", [
|
|
242
261
|
"run",
|
|
@@ -244,6 +263,7 @@ export async function openShell(projectDir, image) {
|
|
|
244
263
|
"-it",
|
|
245
264
|
"--entrypoint",
|
|
246
265
|
"",
|
|
266
|
+
...(runAsUser ? ["--user", runAsUser] : []),
|
|
247
267
|
// --mount is colon-safe; see runInContainer for the rationale.
|
|
248
268
|
"--mount",
|
|
249
269
|
`type=bind,source=${projectDir},target=/home/runner/work`,
|