mandrel-platform 0.13.0 → 0.14.2

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.
@@ -33,6 +33,33 @@
33
33
  * workflow `uses:` pin disagree about being current — the exact
34
34
  * split-pin class the `uses:`-only check missed (npm lagged at 0.11.3
35
35
  * while the workflows tracked v0.11.6).
36
+ * 6. Couples the two surfaces against the supply-chain hold (Story #107).
37
+ * Renovate's shared preset gates every bump behind a `minimumReleaseAge`
38
+ * (3 days). For the first ~3 days after a platform release, EVERY consumer
39
+ * legitimately lags the new tag — Renovate has not raised the bump PR yet.
40
+ * Flagging that transient window as drift would page on every release, so
41
+ * the checker reads the latest release's `published_at`, compares it to the
42
+ * `minimumReleaseAge` window (configurable in pin-drift-consumers.json,
43
+ * default `3 days`), and **suppresses lag/skew that is fully explained by
44
+ * the hold**: a consumer whose only deviation is "not yet on a release
45
+ * younger than the window" is reported as `holding` (informational), not
46
+ * `drift`. Lag against a release OLDER than the window — or a split pin —
47
+ * still drifts. This is the permanent close of the swarm-os three-way
48
+ * split: npm `0.11.3` / workflows `@v0.11.6` / latest `v0.11.7` could not
49
+ * be distinguished from a fresh-release hold before this coupling existed.
50
+ *
51
+ * 7. Lints **stale pin literals beyond `uses:` lines** (Story #110). A
52
+ * platform-ref SHA/tag can live in a **comment** or a **`run:`/echo step
53
+ * string** (e.g. a deploy-summary line that echoes a hand-maintained
54
+ * `deploy-cloudflare.yml@<sha>` literal) and drift independently of the
55
+ * real `uses:` pin — the `uses:`-only scan never saw it. The checker now
56
+ * also extracts every loose platform-ref literal and flags any whose ref
57
+ * no longer matches the consumer's canonical `uses:` pin (`stale`), or
58
+ * that has no canonical pin to track at all (`orphan`). A stale literal is
59
+ * a real configuration error and is never suppressed by the
60
+ * `minimumReleaseAge` hold. The fix is to adopt the resolved-ref step
61
+ * summary `deploy-cloudflare.yml` now emits (its `github.job_workflow_sha`
62
+ * single source of truth) rather than maintaining the literal by hand.
36
63
  *
37
64
  * Data-driven: a new consumer is one object in pin-drift-consumers.json.
38
65
  *
@@ -135,6 +162,93 @@ export function extractPlatformPins(file, text, platformRepo) {
135
162
  return pins;
136
163
  }
137
164
 
165
+ /**
166
+ * Extract every **non-`uses:`** platform-repo ref literal from one workflow
167
+ * file's text (Story #110). The `uses:`-only extractor above misses a stale
168
+ * SHA/tag that lives in a **comment** or a **`run:`/echo step string** — e.g. a
169
+ * deploy-summary line that echoes a hand-maintained
170
+ * `deploy-cloudflare.yml@<sha>` literal. Those literals drift independently of
171
+ * the real `uses:` pin and the `uses:`-only check never sees them.
172
+ *
173
+ * This scans every line, matches any `<platformRepo>/<subpath>@<ref>` token
174
+ * (with `ref` a 40-hex SHA or a non-whitespace tag), and SKIPS lines that are a
175
+ * `uses:` directive (those are owned by `extractPlatformPins`). The result is
176
+ * the set of "loose" platform-ref literals a consumer carries outside its
177
+ * canonical pin surface.
178
+ *
179
+ * @param {string} file Display label for the file (path in the repo).
180
+ * @param {string} text File contents.
181
+ * @param {string} platformRepo e.g. "dsj1984/mandrel-platform".
182
+ * @returns {Array<{ file: string, line: number, target: string, ref: string, kind: 'comment' | 'run' }>}
183
+ */
184
+ export function extractStaleLiterals(file, text, platformRepo) {
185
+ const literals = [];
186
+ const lines = text.split(/\r?\n/);
187
+ // `<platformRepo>/<subpath>@<ref>` where ref is a 40-hex SHA or a tag token.
188
+ // The subpath is required (a bare `<repo>@<ref>` is not a workflow/action
189
+ // literal we care about here) and the ref stops at whitespace/quote/comment.
190
+ const escapedRepo = platformRepo.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
191
+ const litRe = new RegExp(
192
+ `${escapedRepo}/[^\\s'"@]+@([0-9a-fA-F]{40}|[A-Za-z0-9._/-]+)`,
193
+ "g",
194
+ );
195
+ const usesRe = /^\s*(?:-\s*)?uses:\s*/;
196
+ for (let i = 0; i < lines.length; i += 1) {
197
+ const line = lines[i];
198
+ // `uses:` lines are owned by extractPlatformPins — never double-count them.
199
+ if (usesRe.test(line)) continue;
200
+ const commentIndex = line.indexOf("#");
201
+ let match;
202
+ litRe.lastIndex = 0;
203
+ while ((match = litRe.exec(line)) !== null) {
204
+ const ref = match[1];
205
+ const col = match.index;
206
+ // A literal inside a `#` comment is a comment-kind literal; otherwise it
207
+ // lives in a run:/echo/string body.
208
+ const kind =
209
+ commentIndex !== -1 && col > commentIndex ? "comment" : "run";
210
+ literals.push({ file, line: i + 1, target: platformRepo, ref, kind });
211
+ }
212
+ }
213
+ return literals;
214
+ }
215
+
216
+ /**
217
+ * Classify a consumer's loose platform-ref literals against its canonical
218
+ * `uses:` pin (Story #110). A literal is **stale** when it pins a ref the
219
+ * canonical `uses:` surface no longer pins — most commonly a hand-maintained
220
+ * echoed SHA in a deploy-summary string that lags the real pin. The canonical
221
+ * set is the consumer's distinct `uses:` refs (SHAs and tags); a literal whose
222
+ * ref is absent from that set is flagged.
223
+ *
224
+ * When the consumer has no canonical `uses:` pin to compare against (no
225
+ * platform `uses:` at all), every loose literal is reported as `orphan` — a
226
+ * platform-ref literal with no owning pin is itself a maintenance hazard.
227
+ *
228
+ * @param {Array<{ file: string, line: number, target: string, ref: string, kind: string }>} literals
229
+ * @param {string[]} canonicalRefs Distinct refs from the consumer's `uses:` pins.
230
+ * @returns {{
231
+ * staleLiterals: Array<{ file: string, line: number, ref: string, kind: string, reason: 'stale' | 'orphan' }>,
232
+ * hasStaleLiteral: boolean,
233
+ * }}
234
+ */
235
+ export function classifyStaleLiterals(literals, canonicalRefs) {
236
+ const canonical = new Set(canonicalRefs.map((r) => r.toLowerCase()));
237
+ const staleLiterals = [];
238
+ for (const lit of literals) {
239
+ const refLower = lit.ref.toLowerCase();
240
+ if (canonical.has(refLower)) continue; // matches the live pin — fine.
241
+ staleLiterals.push({
242
+ file: lit.file,
243
+ line: lit.line,
244
+ ref: lit.ref,
245
+ kind: lit.kind,
246
+ reason: canonical.size === 0 ? "orphan" : "stale",
247
+ });
248
+ }
249
+ return { staleLiterals, hasStaleLiteral: staleLiterals.length > 0 };
250
+ }
251
+
138
252
  /**
139
253
  * Classify one consumer's pin set into a drift verdict.
140
254
  *
@@ -238,6 +352,72 @@ export function compareSemver(a, b) {
238
352
  return 0;
239
353
  }
240
354
 
355
+ /**
356
+ * Parse a Renovate-style `minimumReleaseAge` duration into milliseconds. The
357
+ * preset uses human strings like `"3 days"`, `"36 hours"`, `"1 week"`; this
358
+ * accepts an integer (or float) count followed by a unit (the same units
359
+ * Renovate's `ms`-backed parser accepts). Returns null for an unparseable or
360
+ * non-positive value so the caller can fall back to "no hold window".
361
+ *
362
+ * @param {unknown} value
363
+ * @returns {number | null} Window length in ms, or null.
364
+ */
365
+ export function parseDurationMs(value) {
366
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
367
+ // Bare number is interpreted as days (the preset's unit of record).
368
+ return value * 24 * 60 * 60 * 1000;
369
+ }
370
+ if (typeof value !== "string") return null;
371
+ const m = /^\s*(\d+(?:\.\d+)?)\s*([a-z]+)\s*$/i.exec(value.trim());
372
+ if (!m) return null;
373
+ const count = Number.parseFloat(m[1]);
374
+ if (!Number.isFinite(count) || count <= 0) return null;
375
+ const unit = m[2].toLowerCase();
376
+ const units = {
377
+ minute: 60 * 1000,
378
+ minutes: 60 * 1000,
379
+ min: 60 * 1000,
380
+ mins: 60 * 1000,
381
+ hour: 60 * 60 * 1000,
382
+ hours: 60 * 60 * 1000,
383
+ hr: 60 * 60 * 1000,
384
+ hrs: 60 * 60 * 1000,
385
+ day: 24 * 60 * 60 * 1000,
386
+ days: 24 * 60 * 60 * 1000,
387
+ week: 7 * 24 * 60 * 60 * 1000,
388
+ weeks: 7 * 24 * 60 * 60 * 1000,
389
+ };
390
+ const factor = units[unit];
391
+ return factor ? count * factor : null;
392
+ }
393
+
394
+ /**
395
+ * Is the latest platform release still inside the `minimumReleaseAge` hold
396
+ * window? During this window Renovate has not yet raised the bump PR, so EVERY
397
+ * consumer legitimately lags the new tag — that transient lag must NOT be
398
+ * scored as drift (Story #107). Returns false (the safe default — "treat lag as
399
+ * real drift") whenever the window or the publish timestamp can't be resolved.
400
+ *
401
+ * @param {string | null} publishedAt Latest release `published_at` (ISO 8601), or null.
402
+ * @param {number | null} windowMs `minimumReleaseAge` in ms (see parseDurationMs), or null.
403
+ * @param {number} [nowMs] Current epoch ms (injectable for tests).
404
+ * @returns {boolean}
405
+ */
406
+ export function isWithinReleaseAgeWindow(
407
+ publishedAt,
408
+ windowMs,
409
+ nowMs = Date.now(),
410
+ ) {
411
+ if (!publishedAt || typeof windowMs !== "number" || windowMs <= 0) {
412
+ return false;
413
+ }
414
+ const publishedMs = Date.parse(publishedAt);
415
+ if (Number.isNaN(publishedMs)) return false;
416
+ const ageMs = nowMs - publishedMs;
417
+ // A negative age (clock skew / future-dated release) counts as "fresh".
418
+ return ageMs < windowMs;
419
+ }
420
+
241
421
  /**
242
422
  * Extract the consumer's `mandrel-platform` npm dependency spec from a
243
423
  * package.json text blob. Scans `dependencies`, `devDependencies`,
@@ -324,13 +504,64 @@ export function detectSurfaceSkew(usesLagState, npmState) {
324
504
  * into a single per-consumer drift boolean. `npm ahead` and `npm absent` are
325
505
  * informational, not drift; `npm lagging` and any surface skew are.
326
506
  *
327
- * @param {{ drift: boolean }} verdict
507
+ * When `holding` is true (the latest release is still inside the
508
+ * `minimumReleaseAge` hold window — Story #107), lag/skew that is fully
509
+ * explained by the hold is suppressed: Renovate has not raised the bump PR yet,
510
+ * so a one-release-behind consumer is **expected**, not drift. A **split pin**
511
+ * is a real configuration error regardless of the window, so it is never
512
+ * suppressed by the hold.
513
+ *
514
+ * A **stale pin literal** (Story #110) — a platform-ref SHA/tag echoed in a
515
+ * comment or `run:`/echo string that no longer matches the canonical `uses:`
516
+ * pin — is a real configuration error like a split pin: it is **never**
517
+ * suppressed by the `minimumReleaseAge` hold, because the literal lags the
518
+ * consumer's OWN pin, not the platform release.
519
+ *
520
+ * @param {{ drift: boolean, splitPinned?: boolean }} verdict
521
+ * @param {{ npmState: string }} npm
522
+ * @param {boolean} surfaceSkew
523
+ * @param {boolean} [holding] Latest release is inside the minimumReleaseAge window.
524
+ * @param {boolean} [hasStaleLiteral] A platform-ref literal lags the canonical pin.
525
+ * @returns {boolean}
526
+ */
527
+ export function combineDrift(
528
+ verdict,
529
+ npm,
530
+ surfaceSkew,
531
+ holding = false,
532
+ hasStaleLiteral = false,
533
+ ) {
534
+ // A stale pin literal is its own configuration error — never hold-suppressed.
535
+ if (hasStaleLiteral) return true;
536
+ const rawDrift =
537
+ verdict.drift || npm.npmState === "lagging" || surfaceSkew;
538
+ if (!rawDrift) return false;
539
+ if (holding && !verdict.splitPinned) {
540
+ // The only deviation is lag/skew against a release younger than the hold
541
+ // window — transient and expected. Not drift.
542
+ return false;
543
+ }
544
+ return true;
545
+ }
546
+
547
+ /**
548
+ * Decide whether a consumer's lag/skew is being SUPPRESSED by the
549
+ * `minimumReleaseAge` hold (i.e. it would otherwise drift, but the latest
550
+ * release is too young for Renovate to have bumped it yet). Drives the
551
+ * `holding` status in the dashboard so the suppression is visible rather than
552
+ * silent (Story #107). A split pin is never "holding" — it is a real error.
553
+ *
554
+ * @param {{ drift: boolean, splitPinned?: boolean }} verdict
328
555
  * @param {{ npmState: string }} npm
329
556
  * @param {boolean} surfaceSkew
557
+ * @param {boolean} withinWindow Latest release is inside the minimumReleaseAge window.
330
558
  * @returns {boolean}
331
559
  */
332
- export function combineDrift(verdict, npm, surfaceSkew) {
333
- return verdict.drift || npm.npmState === "lagging" || surfaceSkew;
560
+ export function isHolding(verdict, npm, surfaceSkew, withinWindow) {
561
+ if (!withinWindow || verdict.splitPinned) return false;
562
+ const wouldDrift =
563
+ verdict.drift || npm.npmState === "lagging" || surfaceSkew;
564
+ return wouldDrift;
334
565
  }
335
566
 
336
567
  /**
@@ -338,7 +569,8 @@ export function combineDrift(verdict, npm, surfaceSkew) {
338
569
  *
339
570
  * @param {{
340
571
  * platformRepo: string,
341
- * latestRelease: { tag: string | null, sha: string | null },
572
+ * latestRelease: { tag: string | null, sha: string | null, publishedAt?: string | null },
573
+ * releaseAge?: { windowMs: number | null, withinWindow: boolean },
342
574
  * results: Array<{
343
575
  * name: string,
344
576
  * repo: string,
@@ -348,6 +580,7 @@ export function combineDrift(verdict, npm, surfaceSkew) {
348
580
  * verdict: ReturnType<typeof classifyConsumer>,
349
581
  * npm?: ReturnType<typeof classifyNpmPin>,
350
582
  * surfaceSkew?: boolean,
583
+ * holding?: boolean,
351
584
  * drift?: boolean,
352
585
  * }>,
353
586
  * }} report
@@ -355,6 +588,7 @@ export function combineDrift(verdict, npm, surfaceSkew) {
355
588
  */
356
589
  export function renderReport(report) {
357
590
  const { platformRepo, latestRelease, results } = report;
591
+ const releaseAge = report.releaseAge ?? { windowMs: null, withinWindow: false };
358
592
  const latestVersion = parseSemver(latestRelease.tag);
359
593
  const out = [];
360
594
  out.push("## Cross-consumer pin-drift dashboard");
@@ -365,11 +599,21 @@ export function renderReport(report) {
365
599
  ? `\`${latestRelease.tag}\` (\`${latestRelease.sha.slice(0, 7)}\`)`
366
600
  : "unknown";
367
601
  out.push(`Latest release: ${relLabel}`);
602
+ if (releaseAge.withinWindow) {
603
+ out.push("");
604
+ out.push(
605
+ "> ⏳ **Renovate `minimumReleaseAge` hold active.** The latest release is " +
606
+ "younger than the supply-chain hold window, so consumers that lag it by " +
607
+ "one release are **expected** — Renovate has not raised the bump PR yet. " +
608
+ "These are reported as `holding`, not drift.",
609
+ );
610
+ }
368
611
  out.push("");
369
612
  out.push("| Consumer | Pins | uses SHA | uses lag | npm pin | npm lag | Status |");
370
613
  out.push("| -------- | ---- | -------- | -------- | ------- | ------- | ------ |");
371
614
 
372
615
  const driftLines = [];
616
+ const holdingLines = [];
373
617
  for (const r of results) {
374
618
  if (r.error) {
375
619
  out.push(`| \`${r.name}\` | — | — | — | — | — | ⚠️ error |`);
@@ -379,6 +623,9 @@ export function renderReport(report) {
379
623
  const v = r.verdict;
380
624
  const npm = r.npm ?? { rawSpec: null, version: null, npmState: "absent" };
381
625
  const surfaceSkew = r.surfaceSkew === true;
626
+ const holding = r.holding === true;
627
+ const staleLiterals = Array.isArray(r.staleLiterals) ? r.staleLiterals : [];
628
+ const hasStaleLiteral = r.hasStaleLiteral === true;
382
629
  const shaLabel = v.pinnedSha
383
630
  ? `\`${v.pinnedSha.slice(0, 7)}\``
384
631
  : v.splitPinned
@@ -401,9 +648,15 @@ export function renderReport(report) {
401
648
  : "—";
402
649
  const npmLagLabel = npm.npmState === "absent" ? "—" : npm.npmState;
403
650
  let status;
404
- if (v.lagState === "no-pins" && npm.npmState === "absent")
651
+ if (
652
+ v.lagState === "no-pins" &&
653
+ npm.npmState === "absent" &&
654
+ !hasStaleLiteral
655
+ )
405
656
  status = "➖ no platform refs";
406
657
  else if (v.splitPinned) status = "❌ split pin";
658
+ else if (hasStaleLiteral) status = "❌ stale pin literal";
659
+ else if (holding) status = "⏳ holding";
407
660
  else if (surfaceSkew) status = "❌ npm/uses skew";
408
661
  else if (v.lagState === "lagging" || npm.npmState === "lagging")
409
662
  status = "⚠️ lagging";
@@ -417,6 +670,15 @@ export function renderReport(report) {
417
670
  `| \`${r.name}\` | ${v.pinCount} | ${shaLabel} | ${lagLabel} | ${npmLabel} | ${npmLagLabel} | ${status} |`,
418
671
  );
419
672
 
673
+ // A held consumer's lag/skew is suppressed by the minimumReleaseAge window
674
+ // (Story #107): record it under "holding" (informational), never "drift".
675
+ if (holding) {
676
+ holdingLines.push(
677
+ `- \`${r.name}\` (${r.repo}): HOLDING — lags the latest release, but it is younger than the \`minimumReleaseAge\` hold window. Renovate has not raised the bump PR yet; this is expected, not drift.`,
678
+ );
679
+ continue;
680
+ }
681
+
420
682
  if (v.splitPinned) {
421
683
  const refList = v.distinctRefs
422
684
  .map((ref) => {
@@ -437,6 +699,22 @@ export function renderReport(report) {
437
699
  );
438
700
  }
439
701
 
702
+ if (hasStaleLiteral) {
703
+ const litList = staleLiterals
704
+ .map((lit) => {
705
+ const short = isFullSha(lit.ref) ? lit.ref.slice(0, 7) : lit.ref;
706
+ const why =
707
+ lit.reason === "orphan"
708
+ ? "no canonical `uses:` pin to track"
709
+ : "does not match the canonical `uses:` pin";
710
+ return ` - \`${short}\` ← ${lit.file}:${lit.line} (${lit.kind}; ${why})`;
711
+ })
712
+ .join("\n");
713
+ driftLines.push(
714
+ `- \`${r.name}\` (${r.repo}): STALE PIN LITERAL — ${staleLiterals.length} platform-ref literal(s) outside \`uses:\` (comment / \`run:\` / echo string) drift from the canonical pin:\n${litList}`,
715
+ );
716
+ }
717
+
440
718
  if (surfaceSkew) {
441
719
  driftLines.push(
442
720
  `- \`${r.name}\` (${r.repo}): SURFACE SKEW — workflow \`uses:\` pins are ${lagLabel} but the npm \`mandrel-platform\` dependency (\`${npm.version ?? npm.rawSpec}\`) is ${npm.npmState}. The npm config package and the workflow pins are on different releases.`,
@@ -460,6 +738,19 @@ export function renderReport(report) {
460
738
  "Every consumer pins a single platform SHA on the latest release, and its npm `mandrel-platform` dependency is on the matching version.",
461
739
  );
462
740
  }
741
+
742
+ if (holdingLines.length > 0) {
743
+ out.push("");
744
+ out.push("### ⏳ Holding (minimumReleaseAge)");
745
+ out.push("");
746
+ out.push(
747
+ "These consumers lag the latest release but it is younger than the " +
748
+ "`minimumReleaseAge` hold window — Renovate has not bumped them yet. " +
749
+ "Expected, not drift; they should converge once the hold expires.",
750
+ );
751
+ out.push("");
752
+ out.push(...holdingLines);
753
+ }
463
754
  out.push("");
464
755
  return out.join("\n");
465
756
  }
@@ -493,23 +784,29 @@ export function defaultGhRunner(args) {
493
784
  }
494
785
 
495
786
  /**
496
- * Resolve the latest platform release tag + the commit SHA that tag points at.
497
- * Falls back gracefully to { tag: null, sha: null } when the platform has no
787
+ * Resolve the latest platform release tag, the commit SHA that tag points at,
788
+ * and the release `published_at` timestamp (used to evaluate the
789
+ * `minimumReleaseAge` hold window — Story #107). Falls back gracefully to
790
+ * { tag: null, sha: null, publishedAt: null } when the platform has no
498
791
  * published release.
499
792
  *
500
793
  * @param {string} platformRepo
501
794
  * @param {(args: string[]) => string} runGh
502
- * @returns {{ tag: string | null, sha: string | null }}
795
+ * @returns {{ tag: string | null, sha: string | null, publishedAt: string | null }}
503
796
  */
504
797
  export function resolveLatestRelease(platformRepo, runGh) {
505
798
  let release;
506
799
  try {
507
800
  release = ghApiJson(`repos/${platformRepo}/releases/latest`, runGh);
508
801
  } catch {
509
- return { tag: null, sha: null };
802
+ return { tag: null, sha: null, publishedAt: null };
510
803
  }
511
804
  const tag = release && typeof release.tag_name === "string" ? release.tag_name : null;
512
- if (!tag) return { tag: null, sha: null };
805
+ const publishedAt =
806
+ release && typeof release.published_at === "string"
807
+ ? release.published_at
808
+ : null;
809
+ if (!tag) return { tag: null, sha: null, publishedAt };
513
810
  // Resolve the tag to its commit SHA. Tags may be lightweight (object is the
514
811
  // commit) or annotated (object is the tag, deref to .object.sha).
515
812
  try {
@@ -522,9 +819,9 @@ export function resolveLatestRelease(platformRepo, runGh) {
522
819
  const tagObj = ghApiJson(`repos/${platformRepo}/git/tags/${sha}`, runGh);
523
820
  sha = tagObj?.object?.sha ?? sha;
524
821
  }
525
- return { tag, sha: sha ? sha.toLowerCase() : null };
822
+ return { tag, sha: sha ? sha.toLowerCase() : null, publishedAt };
526
823
  } catch {
527
- return { tag, sha: null };
824
+ return { tag, sha: null, publishedAt };
528
825
  }
529
826
  }
530
827
 
@@ -625,30 +922,57 @@ export function resolveBranch(consumer, runGh) {
625
922
  /**
626
923
  * Build the full drift report for the configured consumers.
627
924
  *
628
- * @param {{ platformRepo: string, consumers: Array<{ name: string, repo: string, branch?: string }> }} config
925
+ * @param {{
926
+ * platformRepo: string,
927
+ * consumers: Array<{ name: string, repo: string, branch?: string }>,
928
+ * minimumReleaseAge?: string | number,
929
+ * }} config
629
930
  * @param {(args: string[]) => string} runGh
931
+ * @param {number} [nowMs] Injectable current epoch ms (for tests).
630
932
  * @returns {ReturnType<typeof renderReport> extends string ? object : never}
631
933
  */
632
- export function buildReport(config, runGh) {
934
+ export function buildReport(config, runGh, nowMs = Date.now()) {
633
935
  const platformRepo = config.platformRepo;
634
936
  const platformPkg = config.platformPackage || "mandrel-platform";
635
937
  const latestRelease = resolveLatestRelease(platformRepo, runGh);
636
938
  const latestVersion = parseSemver(latestRelease.tag);
939
+ // The hold window defaults to the shared Renovate preset's `3 days`
940
+ // (default.json) so the dashboard's notion of "transient" matches the gate
941
+ // that actually defers the bump. Overridable per-config.
942
+ const windowMs = parseDurationMs(config.minimumReleaseAge ?? "3 days");
943
+ const withinWindow = isWithinReleaseAgeWindow(
944
+ latestRelease.publishedAt,
945
+ windowMs,
946
+ nowMs,
947
+ );
637
948
  const results = [];
638
949
  for (const consumer of config.consumers) {
639
950
  try {
640
951
  const branch = resolveBranch(consumer, runGh);
641
952
  const files = fetchConsumerWorkflows(consumer.repo, branch, runGh);
642
953
  const pins = [];
954
+ const looseLiterals = [];
643
955
  for (const f of files) {
644
956
  pins.push(...extractPlatformPins(f.path, f.text, platformRepo));
957
+ looseLiterals.push(
958
+ ...extractStaleLiterals(f.path, f.text, platformRepo),
959
+ );
645
960
  }
646
961
  const verdict = classifyConsumer(pins, latestRelease.sha);
962
+ const literalVerdict = classifyStaleLiterals(
963
+ looseLiterals,
964
+ verdict.distinctRefs,
965
+ );
647
966
  const pkgText = fetchConsumerPackageJson(consumer.repo, branch, runGh);
648
967
  const npmSpec =
649
968
  pkgText === null ? null : extractNpmPlatformVersion(pkgText, platformPkg);
650
969
  const npm = classifyNpmPin(npmSpec, latestVersion);
651
970
  const surfaceSkew = detectSurfaceSkew(verdict.lagState, npm.npmState);
971
+ // A stale literal is a real error, so a consumer carrying one is never
972
+ // "holding" — surface it as drift even inside the release-age window.
973
+ const holding =
974
+ !literalVerdict.hasStaleLiteral &&
975
+ isHolding(verdict, npm, surfaceSkew, withinWindow);
652
976
  results.push({
653
977
  name: consumer.name,
654
978
  repo: consumer.repo,
@@ -657,7 +981,16 @@ export function buildReport(config, runGh) {
657
981
  verdict,
658
982
  npm,
659
983
  surfaceSkew,
660
- drift: combineDrift(verdict, npm, surfaceSkew),
984
+ staleLiterals: literalVerdict.staleLiterals,
985
+ hasStaleLiteral: literalVerdict.hasStaleLiteral,
986
+ holding,
987
+ drift: combineDrift(
988
+ verdict,
989
+ npm,
990
+ surfaceSkew,
991
+ withinWindow,
992
+ literalVerdict.hasStaleLiteral,
993
+ ),
661
994
  });
662
995
  } catch (err) {
663
996
  const verdict = classifyConsumer([], latestRelease.sha);
@@ -671,11 +1004,20 @@ export function buildReport(config, runGh) {
671
1004
  verdict,
672
1005
  npm,
673
1006
  surfaceSkew: false,
1007
+ staleLiterals: [],
1008
+ hasStaleLiteral: false,
1009
+ holding: false,
674
1010
  drift: false,
675
1011
  });
676
1012
  }
677
1013
  }
678
- return { platformRepo, latestRelease, latestVersion, results };
1014
+ return {
1015
+ platformRepo,
1016
+ latestRelease,
1017
+ latestVersion,
1018
+ releaseAge: { windowMs, withinWindow },
1019
+ results,
1020
+ };
679
1021
  }
680
1022
 
681
1023
  /**
@@ -698,6 +1040,7 @@ export function hasDrift(report) {
698
1040
  * stderr?: { write: (s: string) => void },
699
1041
  * runGh?: (args: string[]) => string,
700
1042
  * summaryPath?: string | undefined,
1043
+ * nowMs?: number,
701
1044
  * }} [opts]
702
1045
  * @returns {number} exit code
703
1046
  */
@@ -708,6 +1051,7 @@ export function runCli({
708
1051
  stderr = process.stderr,
709
1052
  runGh = defaultGhRunner,
710
1053
  summaryPath = process.env.GITHUB_STEP_SUMMARY,
1054
+ nowMs = Date.now(),
711
1055
  } = {}) {
712
1056
  const { config: configRel, json, strict } = parseArgv(argv);
713
1057
  const configPath = resolve(cwd, configRel);
@@ -728,7 +1072,7 @@ export function runCli({
728
1072
  return 1;
729
1073
  }
730
1074
 
731
- const report = buildReport(config, runGh);
1075
+ const report = buildReport(config, runGh, nowMs);
732
1076
  const drift = hasDrift(report);
733
1077
 
734
1078
  if (json) {