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.
- package/README.md +203 -7
- package/config/dependency-cruiser.base.json +121 -0
- package/config/edge-security/allowlist.mjs +138 -0
- package/config/edge-security/cors-astro.mjs +131 -0
- package/config/edge-security/cors-hono.mjs +111 -0
- package/config/edge-security/index.mjs +34 -0
- package/config/edge-security/rate-limit.mjs +219 -0
- package/config/edge-security/security-headers.mjs +148 -0
- package/config/knip.base.json +7 -0
- package/config/lighthouse.base.json +27 -0
- package/config/size-limit.base.json +6 -0
- package/config/stryker.base.json +15 -0
- package/package.json +23 -2
- package/scripts/check-action-pins.mjs +344 -0
- package/scripts/check-action-pins.test.mjs +240 -0
- package/scripts/check-coverage-threshold.mjs +300 -0
- package/scripts/check-coverage-threshold.test.mjs +350 -0
- package/scripts/check-destructive-migration.mjs +313 -0
- package/scripts/check-destructive-migration.test.mjs +183 -0
- package/scripts/check-pin-drift.mjs +361 -17
- package/scripts/check-pin-drift.test.mjs +344 -1
- package/scripts/edge-security.test.mjs +300 -0
- package/scripts/pin-drift-consumers.json +2 -0
- package/scripts/platform-repair.mjs +748 -0
- package/scripts/platform-repair.test.mjs +458 -0
- package/scripts/platform-sync.mjs +0 -0
- package/scripts/platform-sync.test.mjs +0 -0
|
@@ -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
|
-
*
|
|
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
|
|
333
|
-
|
|
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 (
|
|
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
|
|
497
|
-
*
|
|
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
|
-
|
|
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 {{
|
|
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
|
-
|
|
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 {
|
|
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) {
|