mandrel-platform 1.3.0 → 1.4.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mandrel-platform",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Shared CI/deploy workflows, composite toolchain action, npm config package, Renovate preset, and operator runbook templates.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"provenance": true
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"mandrel": "^2.
|
|
47
|
+
"mandrel": "^2.31.0"
|
|
48
48
|
},
|
|
49
49
|
"scripts": {
|
|
50
50
|
"typecheck": "node --input-type=module --eval 'process.exit(0)'",
|
|
@@ -38,6 +38,22 @@
|
|
|
38
38
|
* <subpath>` names, plus every tracked working-tree file under it, so an added
|
|
39
39
|
* or removed sibling is drift too.
|
|
40
40
|
*
|
|
41
|
+
* ## Why a subpath can have COMPANIONS (Story #389)
|
|
42
|
+
*
|
|
43
|
+
* The subpaths this checker knows are exactly the ones named on `uses:` lines,
|
|
44
|
+
* which reopens the same blind spot one level up the moment an action's
|
|
45
|
+
* behaviour moves into a SIBLING DIRECTORY. Story #389 reduced
|
|
46
|
+
* `.github/actions/osv-track-issue` to a thin preset that executes
|
|
47
|
+
* `.github/actions/track-issue/track-issue.mjs` — nothing `uses:` the generic
|
|
48
|
+
* action, so a rewrite of that shared core would leave every `osv-track-issue`
|
|
49
|
+
* pin reading fresh while the pinned SHA runs the old state machine.
|
|
50
|
+
*
|
|
51
|
+
* `COMPANION_SUBPATHS` closes it: a subpath's companions are folded into the
|
|
52
|
+
* tree comparison and into the drift cache key, so an edit confined to the
|
|
53
|
+
* shared core marks every call site of every dependent preset stale. The map
|
|
54
|
+
* is deliberately explicit rather than inferred — a heuristic over `run:`
|
|
55
|
+
* bodies would both miss indirection and invent false drift.
|
|
56
|
+
*
|
|
41
57
|
* This checker closes it by classifying every first-party SHA pin into one of
|
|
42
58
|
* two failure classes — deliberately kept distinct, because their remedies
|
|
43
59
|
* differ:
|
|
@@ -325,6 +341,33 @@ export function createGit(repoRoot) {
|
|
|
325
341
|
// Subpath tree comparison
|
|
326
342
|
// ---------------------------------------------------------------------------
|
|
327
343
|
|
|
344
|
+
/**
|
|
345
|
+
* Subpaths whose runtime behaviour lives partly in ANOTHER directory that no
|
|
346
|
+
* `uses:` line names. Each key's companions are compared alongside it, so an
|
|
347
|
+
* edit confined to a shared core still marks the dependent pins stale.
|
|
348
|
+
*
|
|
349
|
+
* Keep this map in step with any preset/core split under `.github/actions/`:
|
|
350
|
+
* an entry omitted here is a pin that reads fresh while running old code.
|
|
351
|
+
*
|
|
352
|
+
* @type {Record<string, string[]>}
|
|
353
|
+
*/
|
|
354
|
+
export const COMPANION_SUBPATHS = {
|
|
355
|
+
// Story #389 — the preset executes ../track-issue/track-issue.mjs.
|
|
356
|
+
".github/actions/osv-track-issue": [".github/actions/track-issue"],
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Companion subpaths for a `uses:` subpath (empty when it stands alone).
|
|
361
|
+
* Trailing slashes are tolerated so `foo/` and `foo` resolve identically.
|
|
362
|
+
*
|
|
363
|
+
* @param {string} subpath
|
|
364
|
+
* @param {Record<string, string[]>} [map]
|
|
365
|
+
* @returns {string[]}
|
|
366
|
+
*/
|
|
367
|
+
export function companionsFor(subpath, map = COMPANION_SUBPATHS) {
|
|
368
|
+
return map[String(subpath).replace(/\/+$/, "")] || [];
|
|
369
|
+
}
|
|
370
|
+
|
|
328
371
|
/** How each drift kind reads in the report. */
|
|
329
372
|
const DRIFT_PHRASE = {
|
|
330
373
|
differs: "differs from the working-tree copy",
|
|
@@ -341,15 +384,21 @@ const DRIFT_PHRASE = {
|
|
|
341
384
|
* since the pinned revision is drift just as much as one whose bytes changed —
|
|
342
385
|
* all three change what the pinned revision actually executes.
|
|
343
386
|
*
|
|
387
|
+
* `companions` extends the comparison to directories the subpath EXECUTES but
|
|
388
|
+
* no `uses:` line names (Story #389). They are compared identically: a shared
|
|
389
|
+
* core that differs at the pinned SHA is exactly as inert as a sibling script.
|
|
390
|
+
*
|
|
344
391
|
* @param {{lsTree: Function, lsFiles: Function, show: Function}} git
|
|
345
392
|
* @param {string} repoRoot
|
|
346
393
|
* @param {string} sha
|
|
347
394
|
* @param {string} subpath
|
|
395
|
+
* @param {string[]} [companions]
|
|
348
396
|
* @returns {Array<{path: string, kind: "differs" | "added" | "removed" | "unreadable"}>}
|
|
349
397
|
*/
|
|
350
|
-
export function diffSubpathAtSha(git, repoRoot, sha, subpath) {
|
|
351
|
-
const
|
|
352
|
-
const
|
|
398
|
+
export function diffSubpathAtSha(git, repoRoot, sha, subpath, companions = []) {
|
|
399
|
+
const roots = [subpath, ...companions];
|
|
400
|
+
const pinned = new Set(roots.flatMap((root) => git.lsTree(sha, root)));
|
|
401
|
+
const working = new Set(roots.flatMap((root) => git.lsFiles(root)));
|
|
353
402
|
const drift = [];
|
|
354
403
|
|
|
355
404
|
for (const path of [...new Set([...pinned, ...working])].sort()) {
|
|
@@ -384,12 +433,17 @@ export function diffSubpathAtSha(git, repoRoot, sha, subpath) {
|
|
|
384
433
|
*
|
|
385
434
|
* @param {string} subpath
|
|
386
435
|
* @param {ReturnType<typeof diffSubpathAtSha>} drift
|
|
436
|
+
* @param {string[]} [companions]
|
|
387
437
|
* @returns {string}
|
|
388
438
|
*/
|
|
389
|
-
export function describeDrift(subpath, drift) {
|
|
439
|
+
export function describeDrift(subpath, drift, companions = []) {
|
|
390
440
|
const detail = drift.map((d) => `${d.path} ${DRIFT_PHRASE[d.kind]}`).join("; ");
|
|
441
|
+
const scope =
|
|
442
|
+
companions.length > 0
|
|
443
|
+
? `${subpath} (and the shared code it executes: ${companions.join(", ")})`
|
|
444
|
+
: subpath;
|
|
391
445
|
return (
|
|
392
|
-
`${drift.length} file(s) under ${
|
|
446
|
+
`${drift.length} file(s) under ${scope} lag the pinned SHA — the pinned ` +
|
|
393
447
|
`revision is what actually runs: ${detail}`
|
|
394
448
|
);
|
|
395
449
|
}
|
|
@@ -406,7 +460,11 @@ export function describeDrift(subpath, drift) {
|
|
|
406
460
|
* `git` is injectable so a caller can drive the classification against a
|
|
407
461
|
* substitute history; it defaults to the real git bound to `opts.cwd`.
|
|
408
462
|
*
|
|
409
|
-
*
|
|
463
|
+
* `opts.companions` overrides {@link COMPANION_SUBPATHS} — injectable so a
|
|
464
|
+
* fixture can exercise the shared-core relationship without depending on this
|
|
465
|
+
* repo's own action layout.
|
|
466
|
+
*
|
|
467
|
+
* @param {{cwd?: string, workflowsDir?: string, actionsDir?: string, firstPartyOwner?: string, ref?: string, companions?: Record<string, string[]>}} opts
|
|
410
468
|
* @param {ReturnType<typeof createGit>} [git]
|
|
411
469
|
* @returns {{ok: boolean, fatal: string|null, stale: object[], unreachable: object[], unpinnedRefs: object[], scanned: number, files: string[], headSha: string|null}}
|
|
412
470
|
*/
|
|
@@ -457,12 +515,15 @@ export function runCheck(opts = {}, git = createGit(resolve(opts.cwd || process.
|
|
|
457
515
|
|
|
458
516
|
// Every call site for a subpath must move together, so the same
|
|
459
517
|
// (sha, subpath) pair is compared repeatedly — `setup-toolchain` alone has
|
|
460
|
-
// five. Resolve each tree once.
|
|
518
|
+
// five. Resolve each tree once. The companions are part of the key: two
|
|
519
|
+
// subpaths sharing a core must not collide, and a companion map override
|
|
520
|
+
// must not read a cache entry computed without it.
|
|
521
|
+
const companionMap = opts.companions || COMPANION_SUBPATHS;
|
|
461
522
|
const driftCache = new Map();
|
|
462
|
-
const driftFor = (sha, subpath) => {
|
|
463
|
-
const key = `${sha}:${subpath}`;
|
|
523
|
+
const driftFor = (sha, subpath, companions) => {
|
|
524
|
+
const key = `${sha}:${[subpath, ...companions].join(",")}`;
|
|
464
525
|
if (!driftCache.has(key)) {
|
|
465
|
-
driftCache.set(key, diffSubpathAtSha(git, repoRoot, sha, subpath));
|
|
526
|
+
driftCache.set(key, diffSubpathAtSha(git, repoRoot, sha, subpath, companions));
|
|
466
527
|
}
|
|
467
528
|
return driftCache.get(key);
|
|
468
529
|
};
|
|
@@ -511,12 +572,13 @@ export function runCheck(opts = {}, git = createGit(resolve(opts.cwd || process.
|
|
|
511
572
|
continue;
|
|
512
573
|
}
|
|
513
574
|
|
|
514
|
-
const
|
|
575
|
+
const companions = companionsFor(pin.subpath, companionMap);
|
|
576
|
+
const drift = driftFor(pin.sha, pin.subpath, companions);
|
|
515
577
|
if (drift.length > 0) {
|
|
516
578
|
stale.push({
|
|
517
579
|
...pin,
|
|
518
580
|
manifest: manifest.path,
|
|
519
|
-
reason: describeDrift(pin.subpath, drift),
|
|
581
|
+
reason: describeDrift(pin.subpath, drift, companions),
|
|
520
582
|
});
|
|
521
583
|
}
|
|
522
584
|
}
|
|
@@ -37,8 +37,10 @@ import {
|
|
|
37
37
|
resolveManifest,
|
|
38
38
|
manifestsMatch,
|
|
39
39
|
diffSubpathAtSha,
|
|
40
|
+
companionsFor,
|
|
40
41
|
runCheck,
|
|
41
42
|
runCli,
|
|
43
|
+
COMPANION_SUBPATHS,
|
|
42
44
|
} from "./check-first-party-pin-freshness.mjs";
|
|
43
45
|
|
|
44
46
|
const OWNER = "test-owner/test-repo";
|
|
@@ -390,6 +392,165 @@ test("diffSubpathAtSha: a blob git cannot resolve at the pinned SHA is drift, no
|
|
|
390
392
|
assert.deepEqual(drift, [{ path, kind: "differs" }]);
|
|
391
393
|
});
|
|
392
394
|
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
// Story #389 — a subpath's COMPANIONS are compared alongside it
|
|
397
|
+
//
|
|
398
|
+
// The subpaths this checker knows are exactly the ones named on `uses:` lines,
|
|
399
|
+
// so the moment an action's behaviour moves into a sibling DIRECTORY the
|
|
400
|
+
// Story #379 blind spot reopens one level up. `osv-track-issue` is now a thin
|
|
401
|
+
// preset that executes `../track-issue/track-issue.mjs`, and nothing `uses:`
|
|
402
|
+
// the generic action — so without a companion map, a rewrite of that shared
|
|
403
|
+
// core leaves every preset call site reading fresh while the pinned SHA runs
|
|
404
|
+
// the old state machine.
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
const PRESET_SUBPATH = ".github/actions/osv-track-issue";
|
|
408
|
+
const CORE_SUBPATH = ".github/actions/track-issue";
|
|
409
|
+
const PRESET_COMPANIONS = { [PRESET_SUBPATH]: [CORE_SUBPATH] };
|
|
410
|
+
|
|
411
|
+
/** A workflow pinning `subpath@sha`, one step per call site. */
|
|
412
|
+
function workflowFor(subpath, sha) {
|
|
413
|
+
return [
|
|
414
|
+
"name: fixture",
|
|
415
|
+
"on:",
|
|
416
|
+
" push:",
|
|
417
|
+
" branches: [main]",
|
|
418
|
+
"jobs:",
|
|
419
|
+
" demo:",
|
|
420
|
+
" runs-on: ubuntu-latest",
|
|
421
|
+
" steps:",
|
|
422
|
+
` - uses: ${OWNER}/${subpath}@${sha}`,
|
|
423
|
+
"",
|
|
424
|
+
].join("\n");
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* A repo shaped like the post-#389 split: a preset directory whose manifest
|
|
429
|
+
* runs a script in a sibling core directory that no `uses:` line names. Both
|
|
430
|
+
* are committed; the caller then edits the CORE in the working tree only.
|
|
431
|
+
*/
|
|
432
|
+
function makePresetRepo(label) {
|
|
433
|
+
const root = mkdtempSync(join(tmpdir(), `pinfresh-${label}-`));
|
|
434
|
+
git(root, "init", "-b", "main");
|
|
435
|
+
git(root, "config", "user.email", "fixture@example.invalid");
|
|
436
|
+
git(root, "config", "user.name", "Pin Freshness Fixture");
|
|
437
|
+
git(root, "config", "commit.gpgsign", "false");
|
|
438
|
+
git(root, "config", "core.hooksPath", join(root, ".no-hooks"));
|
|
439
|
+
|
|
440
|
+
put(root, `${CORE_SUBPATH}/action.yml`, SIBLING_MANIFEST);
|
|
441
|
+
put(root, `${CORE_SUBPATH}/track-issue.mjs`, "export const verdict = () => 'noop';\n");
|
|
442
|
+
put(root, `${PRESET_SUBPATH}/action.yml`, SIBLING_MANIFEST);
|
|
443
|
+
put(root, `${PRESET_SUBPATH}/osv-track-issue.mjs`, "import '../track-issue/track-issue.mjs';\n");
|
|
444
|
+
git(root, "add", "-A");
|
|
445
|
+
git(root, "commit", "-m", "split the tracker into a core and a preset");
|
|
446
|
+
const after = git(root, "rev-parse", "HEAD");
|
|
447
|
+
|
|
448
|
+
return { root, after };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
test("stale: an edit confined to the shared core marks every preset call site stale", () => {
|
|
452
|
+
const { root, after } = makePresetRepo("companion-stale");
|
|
453
|
+
track(root);
|
|
454
|
+
// Two call sites, both pinning the preset — neither names the core.
|
|
455
|
+
put(root, ".github/workflows/one.yml", workflowFor(PRESET_SUBPATH, after));
|
|
456
|
+
put(root, ".github/workflows/two.yml", workflowFor(PRESET_SUBPATH, after));
|
|
457
|
+
|
|
458
|
+
// The edit is confined to the core, and nothing under the preset changes.
|
|
459
|
+
put(root, `${CORE_SUBPATH}/track-issue.mjs`, "export const verdict = () => 'create';\n");
|
|
460
|
+
|
|
461
|
+
// The premise, asserted rather than assumed: without the companion map this
|
|
462
|
+
// fixture reads perfectly fresh — which is the blind spot, not a pass.
|
|
463
|
+
const blind = runCheck({ cwd: root, firstPartyOwner: OWNER, companions: {} });
|
|
464
|
+
assert.equal(blind.ok, true, "premise: a preset-only comparison sees no drift");
|
|
465
|
+
|
|
466
|
+
const result = runCheck({ cwd: root, firstPartyOwner: OWNER, companions: PRESET_COMPANIONS });
|
|
467
|
+
|
|
468
|
+
assert.equal(result.ok, false);
|
|
469
|
+
assert.equal(result.scanned, 2);
|
|
470
|
+
assert.equal(result.stale.length, 2, "every call site of the preset is stale, not just one");
|
|
471
|
+
assert.equal(result.unreachable.length, 0);
|
|
472
|
+
for (const f of result.stale) {
|
|
473
|
+
assert.equal(f.subpath, PRESET_SUBPATH);
|
|
474
|
+
assert.match(f.reason, /track-issue\.mjs/, "the report names the drifting shared file");
|
|
475
|
+
assert.match(f.reason, /shared code it executes/, "and explains why a sibling tree is in scope");
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
test("stale: a file added to the shared core since the pinned SHA is drift", () => {
|
|
480
|
+
const { root, after } = makePresetRepo("companion-added");
|
|
481
|
+
track(root);
|
|
482
|
+
put(root, ".github/workflows/one.yml", workflowFor(PRESET_SUBPATH, after));
|
|
483
|
+
put(root, `${CORE_SUBPATH}/helper.mjs`, "export const help = () => 1;\n");
|
|
484
|
+
git(root, "add", "-A");
|
|
485
|
+
|
|
486
|
+
const result = runCheck({ cwd: root, firstPartyOwner: OWNER, companions: PRESET_COMPANIONS });
|
|
487
|
+
|
|
488
|
+
assert.equal(result.ok, false);
|
|
489
|
+
assert.match(result.stale[0].reason, /helper\.mjs/);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test("clean: a pin carrying the current core AND preset exits 0", () => {
|
|
493
|
+
const { root, after } = makePresetRepo("companion-clean");
|
|
494
|
+
track(root);
|
|
495
|
+
put(root, ".github/workflows/one.yml", workflowFor(PRESET_SUBPATH, after));
|
|
496
|
+
|
|
497
|
+
const result = runCheck({ cwd: root, firstPartyOwner: OWNER, companions: PRESET_COMPANIONS });
|
|
498
|
+
assert.equal(result.ok, true);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test("a subpath with no companions is compared exactly as before", () => {
|
|
502
|
+
const { root, after } = makePresetRepo("companion-unmapped");
|
|
503
|
+
track(root);
|
|
504
|
+
// Pin the CORE directly — it has no companions of its own, so a preset edit
|
|
505
|
+
// must not leak into its comparison.
|
|
506
|
+
put(root, ".github/workflows/one.yml", workflowFor(CORE_SUBPATH, after));
|
|
507
|
+
put(root, `${PRESET_SUBPATH}/osv-track-issue.mjs`, "// rewritten preset\n");
|
|
508
|
+
|
|
509
|
+
const result = runCheck({ cwd: root, firstPartyOwner: OWNER, companions: PRESET_COMPANIONS });
|
|
510
|
+
assert.equal(result.ok, true, "companions are directional, not symmetric");
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test("diffSubpathAtSha: companions are unioned into both sides of the comparison", () => {
|
|
514
|
+
const presetPath = `${PRESET_SUBPATH}/action.yml`;
|
|
515
|
+
const corePath = `${CORE_SUBPATH}/action.yml`;
|
|
516
|
+
const presetBody = readFileSync(join(REPO_ROOT, presetPath), "utf8");
|
|
517
|
+
// The pinned side knows only the preset; the working side also has the core.
|
|
518
|
+
const fake = {
|
|
519
|
+
lsTree: (_sha, root) => (root === PRESET_SUBPATH ? [presetPath] : []),
|
|
520
|
+
lsFiles: (root) => (root === PRESET_SUBPATH ? [presetPath] : [corePath]),
|
|
521
|
+
show: () => presetBody,
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
const without = diffSubpathAtSha(fake, REPO_ROOT, "0".repeat(40), PRESET_SUBPATH);
|
|
525
|
+
assert.deepEqual(without, [], "the preset tree alone is identical at both ends");
|
|
526
|
+
|
|
527
|
+
const withCore = diffSubpathAtSha(fake, REPO_ROOT, "0".repeat(40), PRESET_SUBPATH, [CORE_SUBPATH]);
|
|
528
|
+
assert.deepEqual(withCore, [{ path: corePath, kind: "added" }]);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test("companionsFor: unmapped subpaths stand alone, and trailing slashes resolve", () => {
|
|
532
|
+
assert.deepEqual(companionsFor(".github/actions/setup-toolchain"), []);
|
|
533
|
+
assert.deepEqual(companionsFor(PRESET_SUBPATH, PRESET_COMPANIONS), [CORE_SUBPATH]);
|
|
534
|
+
assert.deepEqual(companionsFor(`${PRESET_SUBPATH}/`, PRESET_COMPANIONS), [CORE_SUBPATH]);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
test("COMPANION_SUBPATHS: every mapped subpath and companion exists in this repo", () => {
|
|
538
|
+
// A map entry pointing at a path that no longer exists is a silently-dead
|
|
539
|
+
// guard — the pin would read fresh again with nothing flagging it.
|
|
540
|
+
for (const [subpath, companions] of Object.entries(COMPANION_SUBPATHS)) {
|
|
541
|
+
assert.ok(
|
|
542
|
+
resolveManifest(REPO_ROOT, subpath),
|
|
543
|
+
`${subpath} is mapped but resolves to no manifest`
|
|
544
|
+
);
|
|
545
|
+
for (const companion of companions) {
|
|
546
|
+
assert.ok(
|
|
547
|
+
resolveManifest(REPO_ROOT, companion),
|
|
548
|
+
`${subpath} names companion ${companion}, which resolves to no manifest`
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
|
|
393
554
|
// ---------------------------------------------------------------------------
|
|
394
555
|
// AC-7 — third-party / local / docker references are never classified
|
|
395
556
|
// ---------------------------------------------------------------------------
|
|
@@ -6,6 +6,14 @@
|
|
|
6
6
|
// clears. That contract is the pure `decideVerdict` function — these tests
|
|
7
7
|
// pin every branch of it, plus the marker round-trip and the gh-driven
|
|
8
8
|
// lookup, without any network access.
|
|
9
|
+
//
|
|
10
|
+
// Story #389 moved that state machine into the generic
|
|
11
|
+
// `.github/actions/track-issue` core and reduced this module to a preset over
|
|
12
|
+
// it. Every test below predates that move and is retained verbatim: passing
|
|
13
|
+
// unmodified is the evidence that the extraction preserved the OSV verdict
|
|
14
|
+
// contract rather than rewriting it. The sections appended at the end cover
|
|
15
|
+
// what the move newly put at risk — marker continuity against a LIVE advisory
|
|
16
|
+
// issue, and the findings-envelope adapter.
|
|
9
17
|
|
|
10
18
|
import { test } from "node:test";
|
|
11
19
|
import assert from "node:assert/strict";
|
|
@@ -15,8 +23,11 @@ import {
|
|
|
15
23
|
extractDigest,
|
|
16
24
|
digestMarker,
|
|
17
25
|
buildIssueBody,
|
|
26
|
+
buildEnvUpdates,
|
|
18
27
|
findTrackingIssue,
|
|
28
|
+
DIGEST_PREFIX,
|
|
19
29
|
TRACKER_MARKER,
|
|
30
|
+
TRACKER_MARKER_KEY,
|
|
20
31
|
} from "../.github/actions/osv-track-issue/osv-track-issue.mjs";
|
|
21
32
|
|
|
22
33
|
const issueWithDigest = (number, digest) => ({
|
|
@@ -89,3 +100,147 @@ test("findTrackingIssue returns null when nothing carries the marker", () => {
|
|
|
89
100
|
const runner = () => JSON.stringify([{ number: 1, body: "no marker" }]);
|
|
90
101
|
assert.equal(findTrackingIssue({ repo: "acme/app", labels: [] }, runner), null);
|
|
91
102
|
});
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Story #389 — marker continuity across the extraction
|
|
106
|
+
//
|
|
107
|
+
// These two strings are what a LIVE advisory issue in a consumer repo is keyed
|
|
108
|
+
// on. A changed byte in either would leave that issue undiscoverable, so the
|
|
109
|
+
// next scheduled run would open a SECOND one — the exact duplicate-spam
|
|
110
|
+
// failure this action exists to prevent. They are therefore asserted as
|
|
111
|
+
// literals, not derived from the module under test.
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
test("the tracker marker is byte-identical to the live one", () => {
|
|
115
|
+
assert.equal(TRACKER_MARKER, "<!-- mandrel:osv-advisory-tracker -->");
|
|
116
|
+
assert.equal(TRACKER_MARKER_KEY, "mandrel:osv-advisory-tracker");
|
|
117
|
+
assert.equal(DIGEST_PREFIX, "mandrel:osv-advisory-digest:");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("a rendered OSV body round-trips its digest through the live digest prefix", () => {
|
|
121
|
+
const body = buildIssueBody({
|
|
122
|
+
digest: "cafe1234-3",
|
|
123
|
+
summary: "| advisory | package |",
|
|
124
|
+
repo: "acme/app",
|
|
125
|
+
branch: "main",
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
assert.ok(body.includes("<!-- mandrel:osv-advisory-tracker -->"));
|
|
129
|
+
assert.ok(body.includes("<!-- mandrel:osv-advisory-digest: cafe1234-3 -->"));
|
|
130
|
+
assert.equal(extractDigest(body), "cafe1234-3");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("the OSV body is unchanged by the extraction — markers, prose, then summary", () => {
|
|
134
|
+
// Pinned against the shape the pre-#389 module produced. A silent body
|
|
135
|
+
// rewrite would churn every consumer's tracking issue on the next run.
|
|
136
|
+
const body = buildIssueBody({
|
|
137
|
+
digest: "d-1",
|
|
138
|
+
summary: "SUMMARY-BLOCK",
|
|
139
|
+
repo: "acme/app",
|
|
140
|
+
branch: "trunk",
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
assert.equal(
|
|
144
|
+
body,
|
|
145
|
+
[
|
|
146
|
+
"<!-- mandrel:osv-advisory-tracker -->",
|
|
147
|
+
"<!-- mandrel:osv-advisory-digest: d-1 -->",
|
|
148
|
+
"",
|
|
149
|
+
"Scheduled OSV advisory scan of `acme/app` (default branch `trunk`) found advisories at or",
|
|
150
|
+
"above the configured gate. This issue is maintained automatically by the",
|
|
151
|
+
"`advisory-scan.yml` reusable workflow — it is updated when the finding set changes",
|
|
152
|
+
"and closed automatically when the set clears. Do not edit the markers above.",
|
|
153
|
+
"",
|
|
154
|
+
"SUMMARY-BLOCK",
|
|
155
|
+
].join("\n"),
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("an empty summary still renders the historical placeholder", () => {
|
|
160
|
+
const body = buildIssueBody({ digest: "d-1", summary: "", repo: "a/b", branch: "main" });
|
|
161
|
+
assert.match(body, /_\(no summary provided\)_/);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Story #389 — the findings-envelope adapter
|
|
166
|
+
//
|
|
167
|
+
// This module no longer performs the upsert; it translates the osv-scan
|
|
168
|
+
// envelope into the generic TRACK_* contract and hands it to the shared core.
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
/** Collapse the adapter's entry list into a lookup for assertion. */
|
|
172
|
+
const asMap = (entries) => Object.fromEntries(entries);
|
|
173
|
+
|
|
174
|
+
test("the adapter forwards the live marker and digest prefix verbatim", () => {
|
|
175
|
+
const env = asMap(
|
|
176
|
+
buildEnvUpdates({ digest: "abc123", counts: { blocking: 2 }, failOn: "high", summary: "s" }, {
|
|
177
|
+
OSV_TRACK_REPO: "acme/app",
|
|
178
|
+
}),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
assert.equal(env.TRACK_MARKER, "mandrel:osv-advisory-tracker");
|
|
182
|
+
assert.equal(env.TRACK_DIGEST_PREFIX, "mandrel:osv-advisory-digest:");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("the adapter passes the scanner's own digest rather than deriving one", () => {
|
|
186
|
+
// The scanner digest depends on finding identity (id + package + version +
|
|
187
|
+
// source); a derived one over a count-shaped label would churn on nothing.
|
|
188
|
+
const env = asMap(
|
|
189
|
+
buildEnvUpdates({ digest: "scanner-digest", counts: { blocking: 3 }, failOn: "high" }, {
|
|
190
|
+
OSV_TRACK_REPO: "acme/app",
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
assert.equal(env.TRACK_DIGEST, "scanner-digest");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("a blocking count of zero yields an empty failing set", () => {
|
|
197
|
+
const env = asMap(
|
|
198
|
+
buildEnvUpdates({ digest: "empty-0", counts: { blocking: 0 }, failOn: "high" }, {
|
|
199
|
+
OSV_TRACK_REPO: "acme/app",
|
|
200
|
+
}),
|
|
201
|
+
);
|
|
202
|
+
assert.deepEqual(JSON.parse(env.TRACK_FAILED_ITEMS), []);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("a non-zero blocking count yields a non-empty failing set", () => {
|
|
206
|
+
const env = asMap(
|
|
207
|
+
buildEnvUpdates({ digest: "d", counts: { blocking: 4 }, failOn: "critical" }, {
|
|
208
|
+
OSV_TRACK_REPO: "acme/app",
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
const items = JSON.parse(env.TRACK_FAILED_ITEMS);
|
|
212
|
+
assert.equal(items.length, 1);
|
|
213
|
+
assert.match(items[0], /4 advisory finding\(s\) at or above the 'critical' gate/);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("the adapter neither refreshes on an unchanged set nor comments on change", () => {
|
|
217
|
+
// Both knobs default to today's OSV posture, so the preset must set neither.
|
|
218
|
+
const env = asMap(buildEnvUpdates({ digest: "d", counts: { blocking: 1 } }, { OSV_TRACK_REPO: "a/b" }));
|
|
219
|
+
assert.equal(env.TRACK_UNCHANGED_BEHAVIOR, undefined);
|
|
220
|
+
assert.equal(env.TRACK_COMMENT_ON_CHANGE, undefined);
|
|
221
|
+
assert.equal(env.TRACK_RUN_URL, undefined, "no run link — the body stays byte-stable across runs");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("the adapter carries the caller's title, labels and branch through", () => {
|
|
225
|
+
const env = asMap(
|
|
226
|
+
buildEnvUpdates({ digest: "d", counts: { blocking: 1 } }, {
|
|
227
|
+
OSV_TRACK_REPO: "acme/app",
|
|
228
|
+
OSV_TRACK_BRANCH: "trunk",
|
|
229
|
+
OSV_TRACK_TITLE: "Custom title",
|
|
230
|
+
OSV_TRACK_LABELS: "security,ops",
|
|
231
|
+
}),
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
assert.equal(env.TRACK_REPO, "acme/app");
|
|
235
|
+
assert.equal(env.TRACK_BRANCH, "trunk");
|
|
236
|
+
assert.equal(env.TRACK_TITLE, "Custom title");
|
|
237
|
+
assert.equal(env.TRACK_LABELS, "security,ops");
|
|
238
|
+
assert.match(env.TRACK_BODY_INTRO, /Scheduled OSV advisory scan of `acme\/app` \(default branch `trunk`\)/);
|
|
239
|
+
assert.match(env.TRACK_CLOSE_COMMENT, /no longer reports any finding at or above the gate/);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("the adapter defaults the title to the documented one when the caller omits it", () => {
|
|
243
|
+
const env = asMap(buildEnvUpdates({ digest: "d", counts: { blocking: 1 } }, { OSV_TRACK_REPO: "a/b" }));
|
|
244
|
+
assert.equal(env.TRACK_TITLE, "OSV advisory scan — default branch findings");
|
|
245
|
+
assert.equal(env.TRACK_BRANCH, "main");
|
|
246
|
+
});
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
// Unit coverage for the generic single-issue failure tracker (Story #389).
|
|
2
|
+
//
|
|
3
|
+
// The whole point of this action is a SINGLE tracked issue that does not spam:
|
|
4
|
+
// it must open once, stay quiet while the failing set is unchanged, update only
|
|
5
|
+
// on a real change, and close when the set clears. That contract is the pure
|
|
6
|
+
// `decideVerdict` function — these tests pin every branch of it, plus the
|
|
7
|
+
// failing-set derivation, the digest, the marker round-trip and the gh-driven
|
|
8
|
+
// lookup, without any network access.
|
|
9
|
+
//
|
|
10
|
+
// Run: node --test scripts/track-issue.test.mjs
|
|
11
|
+
|
|
12
|
+
import { test } from "node:test";
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
DEFAULT_CLOSE_COMMENT,
|
|
17
|
+
DEFAULT_INTRO,
|
|
18
|
+
buildIssueBody,
|
|
19
|
+
computeDigest,
|
|
20
|
+
decideVerdict,
|
|
21
|
+
defaultDigestPrefix,
|
|
22
|
+
digestMarker,
|
|
23
|
+
extractDigest,
|
|
24
|
+
failingJobs,
|
|
25
|
+
findTrackingIssue,
|
|
26
|
+
markerKey,
|
|
27
|
+
markerLine,
|
|
28
|
+
renderEnvEntry,
|
|
29
|
+
resolveConfig,
|
|
30
|
+
} from "../.github/actions/track-issue/track-issue.mjs";
|
|
31
|
+
|
|
32
|
+
const MARKER = "acme:nightly-tracker";
|
|
33
|
+
const PREFIX = defaultDigestPrefix(MARKER);
|
|
34
|
+
const OPTS = { digestPrefix: PREFIX };
|
|
35
|
+
|
|
36
|
+
/** An open tracked issue whose body carries `digest`. */
|
|
37
|
+
const issueWithDigest = (number, digest) => ({
|
|
38
|
+
number,
|
|
39
|
+
body: buildIssueBody({ marker: MARKER, digestPrefix: PREFIX, digest, detail: "…" }),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// AC-3 — the four-way state machine survives the extraction unchanged
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
test("CREATE when items are failing and no tracking issue is open", () => {
|
|
47
|
+
const v = decideVerdict(null, { failedCount: 2, digest: "abcd1234" }, OPTS);
|
|
48
|
+
assert.equal(v.action, "create");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("UPDATE when the failing-set digest changed since the issue was written", () => {
|
|
52
|
+
const existing = issueWithDigest(42, "abcd1234");
|
|
53
|
+
const v = decideVerdict(existing, { failedCount: 3, digest: "zzzz9999" }, OPTS);
|
|
54
|
+
assert.equal(v.action, "update");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("CLOSE when the failing set is now empty but an issue is still open", () => {
|
|
58
|
+
const existing = issueWithDigest(42, "abcd1234");
|
|
59
|
+
const v = decideVerdict(existing, { failedCount: 0, digest: "green-0" }, OPTS);
|
|
60
|
+
assert.equal(v.action, "close");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("NOOP when nothing is failing and there is no issue to close", () => {
|
|
64
|
+
const v = decideVerdict(null, { failedCount: 0, digest: "green-0" }, OPTS);
|
|
65
|
+
assert.equal(v.action, "noop");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// AC-4 — the never-a-second-issue invariant, asserted on the core
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
test("two consecutive failing runs against an open marked issue never yield CREATE", () => {
|
|
73
|
+
// Run 1 opens the issue; the tracker then finds it on every subsequent run.
|
|
74
|
+
assert.equal(decideVerdict(null, { failedCount: 1, digest: "d1" }, OPTS).action, "create");
|
|
75
|
+
const open = issueWithDigest(7, "d1");
|
|
76
|
+
|
|
77
|
+
// Run 2 — same failing set. Run 3 — a DIFFERENT failing set. Neither may
|
|
78
|
+
// create: a second issue is the duplicate-spam failure this action prevents.
|
|
79
|
+
const second = decideVerdict(open, { failedCount: 1, digest: "d1" }, OPTS);
|
|
80
|
+
const third = decideVerdict(open, { failedCount: 4, digest: "d2" }, OPTS);
|
|
81
|
+
|
|
82
|
+
assert.equal(second.action, "noop");
|
|
83
|
+
assert.equal(third.action, "update");
|
|
84
|
+
for (const v of [second, third]) assert.notEqual(v.action, "create");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("an unparseable body never yields CREATE while an issue is open", () => {
|
|
88
|
+
// A human editing the body away is not a licence to open a second issue: the
|
|
89
|
+
// marker still found the issue, so the worst case is a redundant UPDATE.
|
|
90
|
+
const mangled = { number: 9, body: "someone deleted the digest marker" };
|
|
91
|
+
assert.equal(decideVerdict(mangled, { failedCount: 2, digest: "d9" }, OPTS).action, "update");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// AC-5 — only `failure` counts; cancelled/skipped are not failures
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
test("failingJobs counts only entries whose result is exactly `failure`", () => {
|
|
99
|
+
const needs = {
|
|
100
|
+
build: { result: "success" },
|
|
101
|
+
test: { result: "failure" },
|
|
102
|
+
deploy: { result: "cancelled" },
|
|
103
|
+
docs: { result: "skipped" },
|
|
104
|
+
lint: { result: "failure" },
|
|
105
|
+
};
|
|
106
|
+
assert.deepEqual(failingJobs(needs), ["lint", "test"]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("a job-results payload of only cancelled/skipped never yields CREATE", () => {
|
|
110
|
+
// A self-hosted fleet going down produces a wall of `cancelled` — raising an
|
|
111
|
+
// issue for that is precisely the noise this tracker exists to avoid.
|
|
112
|
+
const noise = { a: { result: "cancelled" }, b: { result: "skipped" }, c: { result: "success" } };
|
|
113
|
+
const failed = failingJobs(noise);
|
|
114
|
+
assert.deepEqual(failed, []);
|
|
115
|
+
|
|
116
|
+
const digest = computeDigest(failed);
|
|
117
|
+
assert.equal(
|
|
118
|
+
decideVerdict(null, { failedCount: failed.length, digest }, OPTS).action,
|
|
119
|
+
"noop",
|
|
120
|
+
"no open issue → nothing to do",
|
|
121
|
+
);
|
|
122
|
+
assert.equal(
|
|
123
|
+
decideVerdict(issueWithDigest(3, "d1"), { failedCount: failed.length, digest }, OPTS).action,
|
|
124
|
+
"close",
|
|
125
|
+
"an open issue is closed, because nothing is actually failing",
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("resolveConfig derives an empty failing set from an all-cancelled job payload", () => {
|
|
130
|
+
const cfg = resolveConfig({
|
|
131
|
+
TRACK_MARKER: MARKER,
|
|
132
|
+
TRACK_REPO: "acme/app",
|
|
133
|
+
TRACK_JOB_RESULTS: JSON.stringify({ a: { result: "cancelled" }, b: { result: "skipped" } }),
|
|
134
|
+
});
|
|
135
|
+
assert.deepEqual(cfg.failedItems, []);
|
|
136
|
+
assert.equal(cfg.digest, "green-0");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// AC-6 — unchanged-set behaviour is selectable, defaulting to today's posture
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
test("an unchanged digest is NOOP by default", () => {
|
|
144
|
+
const existing = issueWithDigest(42, "same-digest");
|
|
145
|
+
const v = decideVerdict(existing, { failedCount: 2, digest: "same-digest" }, OPTS);
|
|
146
|
+
assert.equal(v.action, "noop");
|
|
147
|
+
assert.equal(v.changed, false);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("the same unchanged digest is UPDATE under `unchanged-behavior: refresh`", () => {
|
|
151
|
+
const existing = issueWithDigest(42, "same-digest");
|
|
152
|
+
const v = decideVerdict(
|
|
153
|
+
existing,
|
|
154
|
+
{ failedCount: 2, digest: "same-digest" },
|
|
155
|
+
{ ...OPTS, unchangedBehavior: "refresh" },
|
|
156
|
+
);
|
|
157
|
+
assert.equal(v.action, "update");
|
|
158
|
+
assert.equal(v.changed, false, "a refresh is not a change — it must not fire a change comment");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("resolveConfig defaults unchanged-behavior to noop and rejects an unknown value", () => {
|
|
162
|
+
const base = { TRACK_MARKER: MARKER, TRACK_REPO: "acme/app" };
|
|
163
|
+
assert.equal(resolveConfig(base).unchangedBehavior, "noop");
|
|
164
|
+
assert.equal(resolveConfig({ ...base, TRACK_UNCHANGED_BEHAVIOR: "refresh" }).unchangedBehavior, "refresh");
|
|
165
|
+
assert.equal(
|
|
166
|
+
resolveConfig({ ...base, TRACK_UNCHANGED_BEHAVIOR: "shout" }).unchangedBehavior,
|
|
167
|
+
"noop",
|
|
168
|
+
"an unrecognised value falls back to the quiet posture rather than spamming",
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// Marker + digest contract
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
test("the action owns both marker lines and they round-trip through a body", () => {
|
|
177
|
+
const body = buildIssueBody({
|
|
178
|
+
marker: MARKER,
|
|
179
|
+
digestPrefix: PREFIX,
|
|
180
|
+
digest: "deadbeef",
|
|
181
|
+
intro: "watching the nightly",
|
|
182
|
+
detail: "one job is red",
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
assert.ok(body.includes(markerLine(MARKER)));
|
|
186
|
+
assert.equal(extractDigest(body, PREFIX), "deadbeef");
|
|
187
|
+
assert.equal(extractDigest("no markers here", PREFIX), null);
|
|
188
|
+
assert.match(body, /watching the nightly/);
|
|
189
|
+
assert.match(body, /one job is red/);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("a marker supplied as a rendered comment is not double-wrapped", () => {
|
|
193
|
+
// A caller that hands over `<!-- k -->` must still produce `<!-- k -->` —
|
|
194
|
+
// a double-wrapped marker would never match the live issue again.
|
|
195
|
+
assert.equal(markerKey("<!-- acme:x -->"), "acme:x");
|
|
196
|
+
assert.equal(markerLine("<!-- acme:x -->"), "<!-- acme:x -->");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("the legacy `--!>` comment terminator is stripped, not left in the key", () => {
|
|
200
|
+
// `--!>` closes an HTML comment just as `-->` does. A stripper blind to it
|
|
201
|
+
// leaves a stray `!` in the key, so the rendered marker stops matching the
|
|
202
|
+
// live issue and the next run opens a second one.
|
|
203
|
+
assert.equal(markerKey("<!-- acme:x --!>"), "acme:x");
|
|
204
|
+
assert.equal(markerLine("<!-- acme:x --!>"), "<!-- acme:x -->");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("a digest marker closed with `--!>` is still recovered", () => {
|
|
208
|
+
assert.equal(extractDigest("<!-- acme:d: abc123 --!>", "acme:d:"), "abc123");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("extractDigest skips non-matching comments and needs a single token", () => {
|
|
212
|
+
const body = [
|
|
213
|
+
"<!-- acme:nightly-tracker -->",
|
|
214
|
+
"<!-- unrelated: note -->",
|
|
215
|
+
"<!-- acme:d: two tokens -->",
|
|
216
|
+
"<!-- acme:d: realdigest -->",
|
|
217
|
+
].join("\n");
|
|
218
|
+
assert.equal(extractDigest(body, "acme:d:"), "realdigest");
|
|
219
|
+
assert.equal(extractDigest("<!-- acme:d: -->", "acme:d:"), null);
|
|
220
|
+
assert.equal(extractDigest("<!-- acme:d: unterminated", "acme:d:"), null);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("the digest prefix defaults from the marker but is overridable", () => {
|
|
224
|
+
assert.equal(defaultDigestPrefix(MARKER), "acme:nightly-tracker-digest:");
|
|
225
|
+
assert.match(digestMarker("abc", "custom:pfx:"), /<!-- custom:pfx: abc -->/);
|
|
226
|
+
assert.equal(extractDigest(digestMarker("abc", "custom:pfx:"), "custom:pfx:"), "abc");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("a regex-special digest prefix is matched literally, not as a pattern", () => {
|
|
230
|
+
const prefix = "acme(v1).digest:";
|
|
231
|
+
assert.equal(extractDigest(digestMarker("xyz", prefix), prefix), "xyz");
|
|
232
|
+
assert.equal(extractDigest("<!-- acmeXv1Yxdigest: xyz -->", prefix), null);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("body defaults fill in when the caller supplies no prose", () => {
|
|
236
|
+
const body = buildIssueBody({ marker: MARKER, digestPrefix: PREFIX, digest: "d" });
|
|
237
|
+
assert.ok(body.includes(DEFAULT_INTRO));
|
|
238
|
+
assert.match(body, /_\(no detail provided\)_/);
|
|
239
|
+
assert.ok(!body.includes("Latest run:"), "an absent run-url omits the line entirely");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("a run-url is rendered only when supplied", () => {
|
|
243
|
+
const body = buildIssueBody({
|
|
244
|
+
marker: MARKER,
|
|
245
|
+
digestPrefix: PREFIX,
|
|
246
|
+
digest: "d",
|
|
247
|
+
runUrl: "https://example.invalid/run/1",
|
|
248
|
+
});
|
|
249
|
+
assert.match(body, /Latest run: https:\/\/example\.invalid\/run\/1/);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
// Digest derivation
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
test("computeDigest is order-independent and de-duplicating", () => {
|
|
257
|
+
assert.equal(computeDigest(["b", "a"]), computeDigest(["a", "b"]));
|
|
258
|
+
assert.equal(computeDigest(["a", "a", "b"]), computeDigest(["a", "b"]));
|
|
259
|
+
assert.notEqual(computeDigest(["a"]), computeDigest(["a", "b"]));
|
|
260
|
+
assert.match(computeDigest(["a"]), /^[0-9a-f]{12}$/);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("computeDigest marks an empty set legibly rather than hashing nothing", () => {
|
|
264
|
+
assert.equal(computeDigest([]), "green-0");
|
|
265
|
+
assert.equal(computeDigest(undefined), "green-0");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// Environment contract
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
test("failed-items takes precedence over job-results when both are supplied", () => {
|
|
273
|
+
const cfg = resolveConfig({
|
|
274
|
+
TRACK_MARKER: MARKER,
|
|
275
|
+
TRACK_REPO: "acme/app",
|
|
276
|
+
TRACK_FAILED_ITEMS: JSON.stringify(["explicit"]),
|
|
277
|
+
TRACK_JOB_RESULTS: JSON.stringify({ ignored: { result: "failure" } }),
|
|
278
|
+
});
|
|
279
|
+
assert.deepEqual(cfg.failedItems, ["explicit"]);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("an explicit digest wins over the derived one", () => {
|
|
283
|
+
const cfg = resolveConfig({
|
|
284
|
+
TRACK_MARKER: MARKER,
|
|
285
|
+
TRACK_REPO: "acme/app",
|
|
286
|
+
TRACK_FAILED_ITEMS: JSON.stringify(["a"]),
|
|
287
|
+
TRACK_DIGEST: "caller-supplied",
|
|
288
|
+
});
|
|
289
|
+
assert.equal(cfg.digest, "caller-supplied");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("malformed failed-items is an error, not a silently-empty failing set", () => {
|
|
293
|
+
// Swallowing a parse error here would CLOSE a live tracked issue while the
|
|
294
|
+
// failures it names are still happening.
|
|
295
|
+
const cfg = resolveConfig({
|
|
296
|
+
TRACK_MARKER: MARKER,
|
|
297
|
+
TRACK_REPO: "acme/app",
|
|
298
|
+
TRACK_FAILED_ITEMS: "{not json",
|
|
299
|
+
});
|
|
300
|
+
assert.match(cfg.error, /TRACK_FAILED_ITEMS/);
|
|
301
|
+
|
|
302
|
+
const notAnArray = resolveConfig({
|
|
303
|
+
TRACK_MARKER: MARKER,
|
|
304
|
+
TRACK_REPO: "acme/app",
|
|
305
|
+
TRACK_FAILED_ITEMS: '{"a":1}',
|
|
306
|
+
});
|
|
307
|
+
assert.match(notAnArray.error, /JSON string array/);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("malformed job-results is an error rather than an empty failing set", () => {
|
|
311
|
+
const cfg = resolveConfig({
|
|
312
|
+
TRACK_MARKER: MARKER,
|
|
313
|
+
TRACK_REPO: "acme/app",
|
|
314
|
+
TRACK_JOB_RESULTS: "{not json",
|
|
315
|
+
});
|
|
316
|
+
assert.match(cfg.error, /TRACK_JOB_RESULTS/);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("resolveConfig splits labels and defaults the quiet knobs", () => {
|
|
320
|
+
const cfg = resolveConfig({
|
|
321
|
+
TRACK_MARKER: MARKER,
|
|
322
|
+
TRACK_REPO: "acme/app",
|
|
323
|
+
TRACK_LABELS: " ci , ,tracking ",
|
|
324
|
+
});
|
|
325
|
+
assert.deepEqual(cfg.labels, ["ci", "tracking"]);
|
|
326
|
+
assert.equal(cfg.commentOnChange, false);
|
|
327
|
+
assert.equal(cfg.dryRun, false);
|
|
328
|
+
assert.equal(cfg.branch, "main");
|
|
329
|
+
assert.equal(cfg.closeComment, DEFAULT_CLOSE_COMMENT);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test("renderEnvEntry escalates a multi-line value to the heredoc form", () => {
|
|
333
|
+
assert.equal(renderEnvEntry("K", "one"), "K=one\n");
|
|
334
|
+
const multi = renderEnvEntry("K", "one\ntwo");
|
|
335
|
+
assert.match(multi, /^K<<K_EOF_7f3a\none\ntwo\nK_EOF_7f3a\n$/);
|
|
336
|
+
assert.throws(() => renderEnvEntry("K", "a\nK_EOF_7f3a"), /delimiter/);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
// gh lookup
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
test("findTrackingIssue confirms the marker rather than trusting the search hint", () => {
|
|
344
|
+
const calls = [];
|
|
345
|
+
const runner = (args, opts) => {
|
|
346
|
+
calls.push({ args, opts });
|
|
347
|
+
// gh's `in:body` search is fuzzy — return one true match and one false positive.
|
|
348
|
+
return JSON.stringify([
|
|
349
|
+
{ number: 99, body: "unrelated issue mentioning nightly-tracker in prose" },
|
|
350
|
+
{ number: 100, body: `${markerLine(MARKER)}\n${digestMarker("x-1", PREFIX)}\nbody` },
|
|
351
|
+
]);
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const found = findTrackingIssue({ repo: "acme/app", labels: ["ci"], marker: MARKER }, runner);
|
|
355
|
+
|
|
356
|
+
assert.equal(found.number, 100);
|
|
357
|
+
assert.ok(calls[0].args.includes("--label"));
|
|
358
|
+
assert.ok(calls[0].args.includes("ci"));
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test("findTrackingIssue returns null when nothing carries the marker", () => {
|
|
362
|
+
const runner = () => JSON.stringify([{ number: 1, body: "no marker" }]);
|
|
363
|
+
assert.equal(findTrackingIssue({ repo: "acme/app", labels: [], marker: MARKER }, runner), null);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("findTrackingIssue surfaces a gh failure rather than reporting no issue", () => {
|
|
367
|
+
// Reporting "no issue" on a failed lookup would open a duplicate.
|
|
368
|
+
const runner = () => {
|
|
369
|
+
throw new Error("gh: not authenticated");
|
|
370
|
+
};
|
|
371
|
+
assert.throws(
|
|
372
|
+
() => findTrackingIssue({ repo: "acme/app", labels: [], marker: MARKER }, runner),
|
|
373
|
+
/gh issue list failed/,
|
|
374
|
+
);
|
|
375
|
+
});
|