javi-forge 1.10.1 → 1.12.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 +34 -9
- package/dist/lib/ci-config.d.ts +37 -0
- package/dist/lib/ci-config.js +195 -9
- package/dist/lib/git-diff.d.ts +29 -0
- package/dist/lib/git-diff.js +132 -0
- 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
|
@@ -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");
|
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
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the base ref to diff HEAD against, forge-agnostic. Precedence:
|
|
3
|
+
* 1. `$CI_MERGE_REQUEST_DIFF_BASE_SHA` (GitLab MR) when non-empty.
|
|
4
|
+
* 2. `$CI_COMMIT_BEFORE_SHA` (GitLab push) when non-empty AND not the
|
|
5
|
+
* all-zeros new-branch sentinel.
|
|
6
|
+
* 3. `$GITHUB_BASE_REF` (GitHub Actions PR) → `git merge-base origin/<ref> HEAD`.
|
|
7
|
+
* 4. Local fallback: `git merge-base <candidate> HEAD` over
|
|
8
|
+
* `origin/main`, `origin/master`, `main`, `master` — first that resolves.
|
|
9
|
+
* 5. Nothing resolves → `null` (caller loud-degrades).
|
|
10
|
+
*/
|
|
11
|
+
export declare function resolveBaseRef(env: Record<string, string | undefined>, cwd: string): Promise<string | null>;
|
|
12
|
+
/**
|
|
13
|
+
* The union (deduped) of files changed relative to `base`:
|
|
14
|
+
* - committed: `git diff --name-only --diff-filter=ACMR <base>...HEAD`
|
|
15
|
+
* (three-dot; ACMR keeps Added/Copied/Modified/Renamed, drops deletions)
|
|
16
|
+
* - unstaged: `git diff --name-only`
|
|
17
|
+
* - staged: `git diff --name-only --cached`
|
|
18
|
+
*
|
|
19
|
+
* Invoked as `execFileAsync("git", [...], { cwd })` — an argv array, never a
|
|
20
|
+
* shell string.
|
|
21
|
+
*
|
|
22
|
+
* THROWS if any git invocation fails. A base sha absent from local history
|
|
23
|
+
* (CI shallow clone / bad object) makes the committed diff error; that failure
|
|
24
|
+
* MUST propagate so the caller can skip the scope:changed gate with a named
|
|
25
|
+
* warning. It MUST NOT be swallowed into an empty set — an empty set means
|
|
26
|
+
* "no changed files" and would silently pass a scope:changed gate.
|
|
27
|
+
*/
|
|
28
|
+
export declare function changedFiles(base: string, cwd: string): Promise<string[]>;
|
|
29
|
+
//# sourceMappingURL=git-diff.d.ts.map
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { execFileAsync } from "./exec.js";
|
|
2
|
+
/**
|
|
3
|
+
* Forge-agnostic changed-file diff engine for `scope: changed` gates.
|
|
4
|
+
*
|
|
5
|
+
* Two injectable functions:
|
|
6
|
+
* - {@link resolveBaseRef} — resolve the base commit to diff HEAD against,
|
|
7
|
+
* following a forge-agnostic precedence chain (GitLab MR / GitLab push /
|
|
8
|
+
* GitHub PR / local merge-base). Returns `null` when nothing resolves so the
|
|
9
|
+
* caller can loud-degrade (skip the scope:changed gate with a named warning).
|
|
10
|
+
* - {@link changedFiles} — the union of committed (Added/Copied/Modified/Renamed,
|
|
11
|
+
* deletions dropped), unstaged, and staged changes. THROWS on a git failure
|
|
12
|
+
* (e.g. a base sha absent from local history under a CI shallow clone) so the
|
|
13
|
+
* caller can skip-with-warning; it MUST NOT swallow the failure into an empty
|
|
14
|
+
* set (that would look like "no changes" and silently pass a scope gate).
|
|
15
|
+
*
|
|
16
|
+
* This module is UNWIRED: nothing in the run path imports it yet. The gate
|
|
17
|
+
* phase consumes it in a later slice.
|
|
18
|
+
*/
|
|
19
|
+
/** The all-zeros sha git emits for a brand-new branch's "before" ref. */
|
|
20
|
+
const NEW_BRANCH_SENTINEL = "0".repeat(40);
|
|
21
|
+
/**
|
|
22
|
+
* Local base-ref candidates, tried in order. The first whose `git merge-base
|
|
23
|
+
* <candidate> HEAD` resolves wins.
|
|
24
|
+
*/
|
|
25
|
+
const LOCAL_BASE_CANDIDATES = [
|
|
26
|
+
"origin/main",
|
|
27
|
+
"origin/master",
|
|
28
|
+
"main",
|
|
29
|
+
"master",
|
|
30
|
+
];
|
|
31
|
+
function isNonEmpty(value) {
|
|
32
|
+
return typeof value === "string" && value.length > 0;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Compute `git merge-base <ref> HEAD` in `cwd`, returning the resolved sha or
|
|
36
|
+
* `null` when the ref does not exist / has no common ancestor.
|
|
37
|
+
*/
|
|
38
|
+
async function tryMergeBase(ref, cwd) {
|
|
39
|
+
try {
|
|
40
|
+
const { stdout } = await execFileAsync("git", ["merge-base", ref, "HEAD"], {
|
|
41
|
+
cwd,
|
|
42
|
+
});
|
|
43
|
+
const sha = stdout.trim();
|
|
44
|
+
return sha.length > 0 ? sha : null;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the base ref to diff HEAD against, forge-agnostic. Precedence:
|
|
52
|
+
* 1. `$CI_MERGE_REQUEST_DIFF_BASE_SHA` (GitLab MR) when non-empty.
|
|
53
|
+
* 2. `$CI_COMMIT_BEFORE_SHA` (GitLab push) when non-empty AND not the
|
|
54
|
+
* all-zeros new-branch sentinel.
|
|
55
|
+
* 3. `$GITHUB_BASE_REF` (GitHub Actions PR) → `git merge-base origin/<ref> HEAD`.
|
|
56
|
+
* 4. Local fallback: `git merge-base <candidate> HEAD` over
|
|
57
|
+
* `origin/main`, `origin/master`, `main`, `master` — first that resolves.
|
|
58
|
+
* 5. Nothing resolves → `null` (caller loud-degrades).
|
|
59
|
+
*/
|
|
60
|
+
export async function resolveBaseRef(env, cwd) {
|
|
61
|
+
// 1. GitLab merge request — the base sha is provided directly.
|
|
62
|
+
if (isNonEmpty(env.CI_MERGE_REQUEST_DIFF_BASE_SHA)) {
|
|
63
|
+
return env.CI_MERGE_REQUEST_DIFF_BASE_SHA;
|
|
64
|
+
}
|
|
65
|
+
// 2. GitLab push — the previous sha, unless it is the new-branch sentinel.
|
|
66
|
+
if (isNonEmpty(env.CI_COMMIT_BEFORE_SHA) &&
|
|
67
|
+
env.CI_COMMIT_BEFORE_SHA !== NEW_BRANCH_SENTINEL) {
|
|
68
|
+
return env.CI_COMMIT_BEFORE_SHA;
|
|
69
|
+
}
|
|
70
|
+
// 3. GitHub Actions — a PR sets GITHUB_BASE_REF (merge-base against the
|
|
71
|
+
// target branch); a push has no base ref and falls back to GITHUB_SHA.
|
|
72
|
+
if (isNonEmpty(env.GITHUB_BASE_REF)) {
|
|
73
|
+
const base = await tryMergeBase(`origin/${env.GITHUB_BASE_REF}`, cwd);
|
|
74
|
+
if (base !== null)
|
|
75
|
+
return base;
|
|
76
|
+
}
|
|
77
|
+
else if (isNonEmpty(env.GITHUB_SHA)) {
|
|
78
|
+
return env.GITHUB_SHA;
|
|
79
|
+
}
|
|
80
|
+
// 4. Local fallback — first candidate whose merge-base resolves.
|
|
81
|
+
for (const candidate of LOCAL_BASE_CANDIDATES) {
|
|
82
|
+
const base = await tryMergeBase(candidate, cwd);
|
|
83
|
+
if (base !== null)
|
|
84
|
+
return base;
|
|
85
|
+
}
|
|
86
|
+
// 5. Nothing resolved.
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Parse `git diff --name-only` stdout into a list of repo-root-relative paths,
|
|
91
|
+
* dropping blank lines.
|
|
92
|
+
*/
|
|
93
|
+
function parseNameOnly(stdout) {
|
|
94
|
+
return stdout
|
|
95
|
+
.split("\n")
|
|
96
|
+
.map((line) => line.trim())
|
|
97
|
+
.filter((line) => line.length > 0);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The union (deduped) of files changed relative to `base`:
|
|
101
|
+
* - committed: `git diff --name-only --diff-filter=ACMR <base>...HEAD`
|
|
102
|
+
* (three-dot; ACMR keeps Added/Copied/Modified/Renamed, drops deletions)
|
|
103
|
+
* - unstaged: `git diff --name-only`
|
|
104
|
+
* - staged: `git diff --name-only --cached`
|
|
105
|
+
*
|
|
106
|
+
* Invoked as `execFileAsync("git", [...], { cwd })` — an argv array, never a
|
|
107
|
+
* shell string.
|
|
108
|
+
*
|
|
109
|
+
* THROWS if any git invocation fails. A base sha absent from local history
|
|
110
|
+
* (CI shallow clone / bad object) makes the committed diff error; that failure
|
|
111
|
+
* MUST propagate so the caller can skip the scope:changed gate with a named
|
|
112
|
+
* warning. It MUST NOT be swallowed into an empty set — an empty set means
|
|
113
|
+
* "no changed files" and would silently pass a scope:changed gate.
|
|
114
|
+
*/
|
|
115
|
+
export async function changedFiles(base, cwd) {
|
|
116
|
+
const invocations = [
|
|
117
|
+
["diff", "--name-only", "--diff-filter=ACMR", `${base}...HEAD`],
|
|
118
|
+
["diff", "--name-only"],
|
|
119
|
+
["diff", "--name-only", "--cached"],
|
|
120
|
+
];
|
|
121
|
+
const seen = new Set();
|
|
122
|
+
for (const args of invocations) {
|
|
123
|
+
// Deliberately NOT wrapped in try/catch: a git failure here (shallow
|
|
124
|
+
// clone / missing base object) must surface to the caller.
|
|
125
|
+
const { stdout } = await execFileAsync("git", args, { cwd });
|
|
126
|
+
for (const file of parseNameOnly(stdout)) {
|
|
127
|
+
seen.add(file);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return [...seen];
|
|
131
|
+
}
|
|
132
|
+
//# sourceMappingURL=git-diff.js.map
|