mandrel-platform 1.3.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel-platform",
3
- "version": "1.3.0",
3
+ "version": "1.4.1",
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.7.0"
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 pinned = new Set(git.lsTree(sha, subpath));
352
- const working = new Set(git.lsFiles(subpath));
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 ${subpath} lag the pinned SHA — the pinned ` +
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
- * @param {{cwd?: string, workflowsDir?: string, actionsDir?: string, firstPartyOwner?: string, ref?: string}} opts
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 drift = driftFor(pin.sha, pin.subpath);
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
  // ---------------------------------------------------------------------------
@@ -0,0 +1,451 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-playwright-browser-install.test.mjs — regression guard for the e2e
4
+ * tier's Playwright browser install (Story #396).
5
+ *
6
+ * The bug this pins: the install step was gated on
7
+ * `steps.playwright-cache.outputs.cache-hit != 'true'`. `actions/cache` sets
8
+ * `cache-hit: true` on an EXACT key match and says nothing about what the
9
+ * restored tree actually contains, so the gate treats "an entry exists under
10
+ * this key" as proof the binaries are present. A cache saved partially — or
11
+ * saved before a Playwright patch added a browser variant under the same
12
+ * version key — therefore skips the only step that would repair it, and every
13
+ * scenario dies in milliseconds at `browserType.launch`.
14
+ *
15
+ * It could not self-heal in either direction: the hit kept skipping the
16
+ * repair, and Actions cache entries are IMMUTABLE under a key (`actions/cache`
17
+ * skips its post-job save on an exact hit), so the bad entry was never
18
+ * overwritten. Hence the two halves of the fix this file guards:
19
+ *
20
+ * 1. The install runs unconditionally, so a bad restore costs a re-download
21
+ * rather than the run. This is not a new cost — the pre-fix hit path
22
+ * already ran `playwright install-deps`, so the same OS-dependency step
23
+ * ran on BOTH branches; collapsing them adds only a browser-manifest
24
+ * verify.
25
+ * 2. A caller-settable salt is folded into the cache key, so an operator can
26
+ * stop paying that repair on every run by moving to a fresh key — without
27
+ * hand-deleting caches through the GitHub API.
28
+ *
29
+ * Asserting the key by string-matching its spelling would pin the text rather
30
+ * than the contract. The property that actually matters is RELATIONAL: the
31
+ * default salt must leave the key byte-for-byte identical to the pre-fix one
32
+ * (or every consumer's warm cache is silently invalidated by the upgrade), and
33
+ * distinct salts must produce distinct keys (or the escape hatch does not
34
+ * escape). So this extracts the real key template and resolves it under
35
+ * several salt values, the same read-then-execute approach as
36
+ * check-toolchain-cache-default.test.mjs.
37
+ *
38
+ * Run: node --test scripts/check-playwright-browser-install.test.mjs
39
+ */
40
+
41
+ import assert from "node:assert/strict";
42
+ import { test } from "node:test";
43
+ import { spawnSync } from "node:child_process";
44
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
45
+ import { tmpdir } from "node:os";
46
+ import { join } from "node:path";
47
+
48
+ const QUALITY = ".github/workflows/pr-quality.yml";
49
+ const SALT_INPUT = "playwright-cache-salt";
50
+
51
+ /**
52
+ * The fixed literal the resolve step falls back to (Story #400).
53
+ *
54
+ * It must stay a CONSTANT. A host- or run-derived fallback (`$GITHUB_SHA`, a
55
+ * date) satisfies "the step no longer fails" while minting a new cache key on
56
+ * every run — permanently defeating the ~460 MiB cache the step exists to
57
+ * label, which is a worse outcome than the abort it replaced.
58
+ */
59
+ const SENTINEL = "unresolved";
60
+
61
+ /** The exact key template the tier carried before Story #396. */
62
+ const PRE_FIX_KEY = "playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}";
63
+ const PRE_FIX_RESTORE_KEY = "playwright-${{ runner.os }}-";
64
+
65
+ const text = readFileSync(QUALITY, "utf8");
66
+
67
+ /**
68
+ * The workflow with whole-line `#` comments removed.
69
+ *
70
+ * The guard is about what the workflow DOES, not what it says: the tier
71
+ * carries a comment naming the very expression this file forbids, so that a
72
+ * future reader is told not to reintroduce it. Scanning raw text would let
73
+ * that warning fail the check it exists to support. Only leading-`#` lines are
74
+ * dropped — never a mid-line `#`, which could sit inside a quoted value.
75
+ */
76
+ const code = text
77
+ .split("\n")
78
+ .filter((l) => !l.trimStart().startsWith("#"))
79
+ .join("\n");
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Extraction
83
+ // ---------------------------------------------------------------------------
84
+
85
+ /**
86
+ * The `steps:` block of the named job, sliced by indentation.
87
+ *
88
+ * Scans lines rather than building a `new RegExp` around the job name: a
89
+ * dynamically-constructed regex is a SAST finding (ReDoS surface) and buys
90
+ * nothing here, since the block boundary is just indentation.
91
+ */
92
+ function jobBlock(name, source = code) {
93
+ const lines = source.split("\n");
94
+ const start = lines.indexOf(` ${name}:`);
95
+ assert.notEqual(start, -1, `${QUALITY}: job \`${name}\` not found`);
96
+ const out = [];
97
+ for (let i = start + 1; i < lines.length; i++) {
98
+ if (lines[i].trim() === "") {
99
+ out.push(lines[i]);
100
+ continue;
101
+ }
102
+ // Dedent to the job-name level or beyond → the block ended.
103
+ if (lines[i].match(/^(\s*)/)[1].length <= 2) break;
104
+ out.push(lines[i]);
105
+ }
106
+ return out.join("\n");
107
+ }
108
+
109
+ /**
110
+ * The `- name: <step>` … block for one step of a job, sliced by indentation.
111
+ *
112
+ * `source` defaults to the comment-stripped text — right for asserting what the
113
+ * workflow DOES. Pass the raw `text` when the block is going to be EXECUTED, so
114
+ * the guard runs the same script the runner does rather than a stripped
115
+ * paraphrase of it.
116
+ */
117
+ function stepBlock(job, stepName, source = code) {
118
+ const lines = jobBlock(job, source).split("\n");
119
+ const start = lines.findIndex((l) => l.trim() === `- name: ${stepName}`);
120
+ assert.notEqual(start, -1, `${QUALITY}: step \`${stepName}\` not found in job \`${job}\``);
121
+ const indent = lines[start].match(/^(\s*)/)[1].length;
122
+ const out = [lines[start]];
123
+ for (let i = start + 1; i < lines.length; i++) {
124
+ if (lines[i].trim() === "") continue;
125
+ const width = lines[i].match(/^(\s*)/)[1].length;
126
+ // A sibling list item (or a dedent) at the same indent ends this step.
127
+ if (width <= indent) break;
128
+ out.push(lines[i]);
129
+ }
130
+ return out.join("\n");
131
+ }
132
+
133
+ /** The literal `default:` of the named workflow_call input. */
134
+ function inputDefault(name) {
135
+ const lines = code.split("\n");
136
+ const start = lines.indexOf(` ${name}:`);
137
+ assert.notEqual(start, -1, `${QUALITY}: workflow_call input \`${name}\` not found`);
138
+ for (let i = start + 1; i < lines.length; i++) {
139
+ if (lines[i].trim() === "") continue;
140
+ if (lines[i].match(/^(\s*)/)[1].length <= 6) break;
141
+ const d = lines[i].match(/^\s*default:\s*(.+)$/);
142
+ if (d) return d[1].trim();
143
+ }
144
+ return assert.fail(`${QUALITY}: input \`${name}\` has no default`);
145
+ }
146
+
147
+ /** The `key:` / `restore-keys:` templates of the cache step. */
148
+ function cacheKeys() {
149
+ const block = stepBlock("e2e", "Cache Playwright browsers");
150
+ const key = block.match(/^\s*key:\s*(.+)$/m);
151
+ assert.ok(key, `${QUALITY}: the cache step has no \`key:\``);
152
+ const restore = block.match(/^\s*restore-keys:\s*\|\s*\n\s*(.+)$/m);
153
+ assert.ok(restore, `${QUALITY}: the cache step has no \`restore-keys:\``);
154
+ return { key: key[1].trim(), restoreKey: restore[1].trim() };
155
+ }
156
+
157
+ /**
158
+ * Resolve a key template for one salt value, leaving every other `${{ … }}`
159
+ * placeholder untouched so the result is directly comparable to the pre-fix
160
+ * literal. Split/join rather than a constructed regex, for the SAST reason
161
+ * above.
162
+ */
163
+ function resolveSalt(template, salt) {
164
+ return template
165
+ .split(`\${{ inputs.${SALT_INPUT} }}`)
166
+ .join(salt)
167
+ .split(`\${{ inputs['${SALT_INPUT}'] }}`)
168
+ .join(salt);
169
+ }
170
+
171
+ /**
172
+ * The dedented body of a step's `run: |` block, taken from the RAW workflow
173
+ * text so the guard executes what the runner executes.
174
+ *
175
+ * This is only sound while the block holds no `${{ }}` expression — the runner
176
+ * substitutes those before bash ever sees them, and there is no substituting
177
+ * them here. A dedicated test below pins that precondition rather than leaving
178
+ * it as a silent assumption.
179
+ */
180
+ function runScript(job, stepName) {
181
+ const lines = stepBlock(job, stepName, text).split("\n");
182
+ const start = lines.findIndex((l) => l.trim() === "run: |");
183
+ assert.notEqual(start, -1, `${QUALITY}: step \`${stepName}\` has no \`run: |\` block`);
184
+ const body = lines.slice(start + 1);
185
+ assert.ok(body.length > 0, `${QUALITY}: step \`${stepName}\` has an empty \`run:\` block`);
186
+ const indent = body[0].match(/^(\s*)/)[1].length;
187
+ return body.map((l) => l.slice(indent)).join("\n");
188
+ }
189
+
190
+ /**
191
+ * Run a script under the runner's own shell invocation, in a throwaway cwd.
192
+ *
193
+ * GitHub executes `shell: bash` as `bash --noprofile --norc -eo pipefail
194
+ * {0}` — the `-e` is the whole reason the pre-fix step could kill the tier, so
195
+ * the guard reproduces the flags exactly. `cwd` is passed to `spawnSync` and
196
+ * `process.chdir` is never called: this file's later tests read
197
+ * `docs/reusable-workflows.md` by a RELATIVE path, and a leaked cwd would fail
198
+ * them for a reason that has nothing to do with the change under test.
199
+ */
200
+ function runInDir(script, cwd) {
201
+ const outPath = join(cwd, "github-output");
202
+ writeFileSync(outPath, "");
203
+ const scriptPath = join(cwd, "step.sh");
204
+ writeFileSync(scriptPath, script);
205
+ const res = spawnSync("bash", ["--noprofile", "--norc", "-eo", "pipefail", scriptPath], {
206
+ cwd,
207
+ env: { ...process.env, GITHUB_OUTPUT: outPath },
208
+ encoding: "utf8",
209
+ });
210
+ const outputs = new Map();
211
+ for (const line of readFileSync(outPath, "utf8").split("\n")) {
212
+ const eq = line.indexOf("=");
213
+ if (eq > 0) outputs.set(line.slice(0, eq), line.slice(eq + 1));
214
+ }
215
+ return { status: res.status, stderr: res.stderr, outputs };
216
+ }
217
+
218
+ /** A temp directory, removed however the callback exits. */
219
+ function withTempDir(fn) {
220
+ const dir = mkdtempSync(join(tmpdir(), "pw-version-"));
221
+ try {
222
+ return fn(dir);
223
+ } finally {
224
+ rmSync(dir, { recursive: true, force: true });
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Plant a resolvable `@playwright/test` under `dir`.
230
+ *
231
+ * The `exports` map matters: the real package restricts subpath access and
232
+ * lists `"./package.json"` explicitly. Without it here, a resolution strategy
233
+ * that is ILLEGAL against the real package would still pass this guard.
234
+ */
235
+ function plantPlaywright(dir, version) {
236
+ const pkgDir = join(dir, "node_modules", "@playwright", "test");
237
+ mkdirSync(pkgDir, { recursive: true });
238
+ writeFileSync(
239
+ join(pkgDir, "package.json"),
240
+ JSON.stringify({
241
+ name: "@playwright/test",
242
+ version,
243
+ exports: { "./package.json": "./package.json" },
244
+ }),
245
+ );
246
+ }
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // The contract
250
+ // ---------------------------------------------------------------------------
251
+
252
+ test("no step gates on the Playwright cache-hit output", () => {
253
+ // AC-1, the defect itself. `cache-hit` is true whenever an entry EXISTS
254
+ // under the key — it is not a statement about the entry's contents, so no
255
+ // step may treat it as one.
256
+ assert.doesNotMatch(
257
+ code,
258
+ /steps\.playwright-cache\.outputs\.cache-hit/,
259
+ "a cache-hit gate is back: a partial cache would again be fatal rather than repaired",
260
+ );
261
+ });
262
+
263
+ test("the browser install runs unconditionally with --with-deps", () => {
264
+ // AC-1. Unconditional is the whole fix — an `if:` of ANY shape here
265
+ // reintroduces a path where a bad restore is never repaired.
266
+ const block = stepBlock("e2e", "Install Playwright browsers");
267
+ assert.match(block, /run:\s*pnpm exec playwright install --with-deps/);
268
+ assert.doesNotMatch(
269
+ block,
270
+ /^\s*if:/m,
271
+ "the install step must carry no condition — see this file's header",
272
+ );
273
+ });
274
+
275
+ test("the cache-hit-only OS-dependency step is gone", () => {
276
+ // AC-1. Its only reason to exist was the hit branch; leaving it behind
277
+ // would run `install-deps` twice on every run.
278
+ assert.doesNotMatch(
279
+ jobBlock("e2e"),
280
+ /- name: Install browser OS dependencies/,
281
+ "the split OS-dependency step is redundant once the install is unconditional",
282
+ );
283
+ });
284
+
285
+ test(`the ${SALT_INPUT} input exists with a literal default`, () => {
286
+ // AC-2. A `workflow_call` default may not hold an expression: GitHub
287
+ // resolves defaults during interface validation, before any context exists,
288
+ // and check-workflow-portability.mjs Rule 2 rejects it outright.
289
+ const value = inputDefault(SALT_INPUT);
290
+ assert.doesNotMatch(value, /\$\{\{/, "a workflow_call default may not hold an expression");
291
+ assert.equal(value, "''");
292
+ });
293
+
294
+ test("the cache key interpolates the salt input", () => {
295
+ // AC-2. Declared-but-unread is the failure mode that makes the escape hatch
296
+ // silently inert.
297
+ const { key } = cacheKeys();
298
+ assert.match(key, /inputs\./, "the key does not read any input");
299
+ assert.notEqual(
300
+ resolveSalt(key, "probe"),
301
+ key,
302
+ `the key does not interpolate \`inputs.${SALT_INPUT}\``,
303
+ );
304
+ });
305
+
306
+ test("the default salt leaves the cache key byte-for-byte unchanged", () => {
307
+ // AC-3 — the compatibility contract. If this drifts, every consumer's warm
308
+ // ~460 MiB cache is silently orphaned the moment they adopt the release,
309
+ // which is a worse outage than the bug being fixed.
310
+ const { key, restoreKey } = cacheKeys();
311
+ assert.equal(resolveSalt(key, ""), PRE_FIX_KEY);
312
+ assert.equal(resolveSalt(restoreKey, ""), PRE_FIX_RESTORE_KEY);
313
+ });
314
+
315
+ test("distinct salts produce distinct cache keys", () => {
316
+ // AC-4 — the escape hatch actually escapes. A salt that collapses into the
317
+ // same key (interpolated into a comment, or into a segment the key does not
318
+ // use) would leave the operator back at deleting caches by hand.
319
+ const { key } = cacheKeys();
320
+ const base = resolveSalt(key, "");
321
+ const bumped = resolveSalt(key, "-v2");
322
+ const bumpedAgain = resolveSalt(key, "-v3");
323
+ assert.notEqual(bumped, base, "a non-empty salt must not resolve to the default key");
324
+ assert.notEqual(bumpedAgain, bumped, "two different salts must not collide");
325
+ });
326
+
327
+ test("the salt does not disturb the restore-keys prefix", () => {
328
+ // A deliberate asymmetry, not an oversight: the prefix fallback must keep
329
+ // matching older entries so a salt bump still gets a WARM start. The
330
+ // unconditional install then fills whatever the old entry was missing, and
331
+ // because a prefix (non-exact) restore leaves `cache-hit` false, the
332
+ // post-job save writes a complete tree under the NEW key. That is what
333
+ // completes the escape — one run, no manual cache deletion.
334
+ const { restoreKey } = cacheKeys();
335
+ assert.equal(
336
+ resolveSalt(restoreKey, "-v2"),
337
+ PRE_FIX_RESTORE_KEY,
338
+ "restore-keys must stay salt-free so a bumped key still warm-starts",
339
+ );
340
+ });
341
+
342
+ test("the documented input row states the default and the escape semantics", () => {
343
+ // AC-6. The row is the consumer-facing contract for a knob whose entire
344
+ // purpose is manual operator use — undocumented, it may as well not exist.
345
+ const docs = readFileSync("docs/reusable-workflows.md", "utf8");
346
+ const rows = docs
347
+ .split("\n")
348
+ .filter((l) => l.startsWith(`| \`${SALT_INPUT}\``) && /\|\s*string\s*\|/.test(l));
349
+ assert.equal(rows.length, 1, "expected exactly one documented input row");
350
+ assert.match(rows[0], /`''`/, "row does not state the empty-string default");
351
+ assert.match(rows[0], /cache/i, "row does not explain what the salt affects");
352
+ });
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // Version resolution (Story #400)
356
+ //
357
+ // The pre-fix step ran `node -e "…require('./node_modules/@playwright/test/…')"`
358
+ // as a bare `VAR=$(…)` assignment. Under `bash -eo pipefail` that propagates
359
+ // the substitution's exit status, so on a consumer whose ROOT node_modules
360
+ // lacks the package — pnpm's isolated layout only symlinks a root DIRECT
361
+ // dependency, so a workspace-owned Playwright has no such path — `set -e`
362
+ // killed the step and took the whole e2e tier with it. A step that exists only
363
+ // to LABEL a cache key must never be able to do that.
364
+ // ---------------------------------------------------------------------------
365
+
366
+ test("version resolution uses a bare specifier, not a hardcoded root path", () => {
367
+ const block = stepBlock("e2e", "Resolve Playwright version");
368
+ assert.doesNotMatch(
369
+ block,
370
+ /\.\/node_modules\/@playwright\/test/,
371
+ "a hardcoded root path is back: a workspace-owned Playwright would not resolve",
372
+ );
373
+ assert.match(
374
+ block,
375
+ /require\((['"])@playwright\/test\/package\.json\1\)/,
376
+ "resolution must go through the bare specifier, which walks node_modules",
377
+ );
378
+ });
379
+
380
+ test("the resolve step's run block holds no workflow expression", () => {
381
+ // The precondition for executing this step in the tests below: the runner
382
+ // substitutes `${{ }}` before bash sees it, and nothing substitutes it here.
383
+ // Threading an input into the block would leave the guard asserting against
384
+ // a string that never runs.
385
+ assert.doesNotMatch(
386
+ runScript("e2e", "Resolve Playwright version"),
387
+ /\$\{\{/,
388
+ "keep the run block expression-free so the guard executes the runner's text",
389
+ );
390
+ });
391
+
392
+ test("the sentinel is assigned as a fixed literal", () => {
393
+ // Invariant 2 (see SENTINEL above). Assert the SHAPE of the assignment, not
394
+ // merely that the step stopped failing — `PW_VERSION=$(date +%F)` would pass
395
+ // a stability check across two runs in the same second and still rekey the
396
+ // cache on every push.
397
+ const script = runScript("e2e", "Resolve Playwright version");
398
+ const assignments = script
399
+ .split("\n")
400
+ .map((l) => l.trim())
401
+ .filter((l) => !l.startsWith("#") && l.includes(`=${SENTINEL}`));
402
+ assert.equal(assignments.length, 1, `expected exactly one \`=${SENTINEL}\` assignment`);
403
+ const [assignment] = assignments;
404
+ assert.match(assignment, /^[A-Za-z_][A-Za-z0-9_]*=unresolved$/, "the sentinel must be a literal");
405
+ assert.ok(!assignment.includes("$("), "the sentinel must not be command-substituted");
406
+ assert.ok(!assignment.includes("${"), "the sentinel must not be parameter-expanded");
407
+ });
408
+
409
+ test("an unresolvable @playwright/test yields the sentinel instead of failing the tier", () => {
410
+ // The defect itself. A temp dir has no `node_modules` anywhere up its tree,
411
+ // which is exactly the consumer shape that lost the tier.
412
+ const script = runScript("e2e", "Resolve Playwright version");
413
+ withTempDir((dir) => {
414
+ const { status, outputs, stderr } = runInDir(script, dir);
415
+ assert.equal(status, 0, `the step must not fail the tier; stderr:\n${stderr}`);
416
+ assert.equal(outputs.get("version"), SENTINEL);
417
+ });
418
+ });
419
+
420
+ test("the sentinel is stable across runs, so the cache key does not churn", () => {
421
+ // Invariant 2 again, from the outside: a value that varies run to run mints a
422
+ // fresh key every time and permanently defeats the cache.
423
+ const script = runScript("e2e", "Resolve Playwright version");
424
+ const read = () => withTempDir((dir) => runInDir(script, dir).outputs.get("version"));
425
+ const first = read();
426
+ // Pin the value, not just its stability: two runs that both emit NOTHING are
427
+ // trivially equal, which would let a step that never writes the output pass.
428
+ assert.equal(first, SENTINEL);
429
+ assert.equal(read(), first);
430
+ });
431
+
432
+ test("a resolvable @playwright/test produces the pre-fix cache key exactly", () => {
433
+ // The compatibility contract, end to end: what the step actually emits, fed
434
+ // through the real key template at the default salt, must equal the key
435
+ // consumers' warm caches already sit under.
436
+ const script = runScript("e2e", "Resolve Playwright version");
437
+ const version = "1.61.1";
438
+ const resolved = withTempDir((dir) => {
439
+ plantPlaywright(dir, version);
440
+ const { status, outputs, stderr } = runInDir(script, dir);
441
+ assert.equal(status, 0, `stderr:\n${stderr}`);
442
+ return outputs.get("version");
443
+ });
444
+ assert.equal(resolved, version, "the step must report the resolved package's version");
445
+
446
+ const { key } = cacheKeys();
447
+ const withVersion = resolveSalt(key, "")
448
+ .split("${{ steps.pw-version.outputs.version }}")
449
+ .join(resolved);
450
+ assert.equal(withVersion, `playwright-\${{ runner.os }}-${version}`);
451
+ });
@@ -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
+ });