mandrel-platform 0.11.7 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/package.json +1 -1
- package/scripts/check-pin-drift.mjs +229 -13
- package/scripts/check-pin-drift.test.mjs +370 -0
- package/scripts/pin-drift-consumers.json +1 -1
package/README.md
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
Shared CI/deploy workflows, composite toolchain action, npm config package,
|
|
4
4
|
Renovate preset, and operator runbook templates for the Mandrel platform.
|
|
5
5
|
|
|
6
|
+
**Docs:** [reusable-workflows.md](docs/reusable-workflows.md) (the `workflow_call`
|
|
7
|
+
contract) · [decisions.md](docs/decisions.md) (decision log). Status, the
|
|
8
|
+
consumer convergence matrix, and the forward roadmap are tracked privately.
|
|
9
|
+
|
|
6
10
|
---
|
|
7
11
|
|
|
8
12
|
## Reusable workflows
|
package/package.json
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* athportal, swarm-os) went undetected and undocumented: every consumer
|
|
9
9
|
* pinned `pr-quality.yml@<shaA>` and `deploy-cloudflare.yml@<shaB>` — two
|
|
10
10
|
* different release SHAs per repo, neither on the current platform release,
|
|
11
|
-
* with no automated drift detection
|
|
11
|
+
* with no automated drift detection. This script is
|
|
12
12
|
* the standing check that surfaces it automatically.
|
|
13
13
|
*
|
|
14
14
|
* For each consumer in `scripts/pin-drift-consumers.json` it:
|
|
@@ -24,6 +24,15 @@
|
|
|
24
24
|
* commit. A consumer is `current` when its single pin equals the latest
|
|
25
25
|
* release SHA, `lagging` when it pins an older release/SHA, and
|
|
26
26
|
* `unknown` when the pinned SHA can't be matched to a release.
|
|
27
|
+
* 5. Reads the consumer's `package.json` and extracts its
|
|
28
|
+
* `mandrel-platform` npm dependency version (the platform also ships as
|
|
29
|
+
* an npm config package: tsconfig.base.json, biome.base.json, the
|
|
30
|
+
* Renovate preset). It compares that version to the latest release's
|
|
31
|
+
* version and flags an `npm` verdict (`current` / `lagging` / `ahead` /
|
|
32
|
+
* `absent` / `unknown`), plus a **surface skew** when the npm pin and the
|
|
33
|
+
* workflow `uses:` pin disagree about being current — the exact
|
|
34
|
+
* split-pin class the `uses:`-only check missed (npm lagged at 0.11.3
|
|
35
|
+
* while the workflows tracked v0.11.6).
|
|
27
36
|
*
|
|
28
37
|
* Data-driven: a new consumer is one object in pin-drift-consumers.json.
|
|
29
38
|
*
|
|
@@ -195,6 +204,135 @@ export function classifyConsumer(pins, latestReleaseSha) {
|
|
|
195
204
|
};
|
|
196
205
|
}
|
|
197
206
|
|
|
207
|
+
/**
|
|
208
|
+
* Extract a comparable `x.y.z` semver core from a release tag or version spec.
|
|
209
|
+
* The platform tags releases as `mandrel-platform-v<semver>`; consumer specs
|
|
210
|
+
* may carry a range prefix (`^0.11.3`, `~0.11.3`). Returns the dotted triple
|
|
211
|
+
* or null when no numeric semver core is present (`workspace:*`, `latest`, a
|
|
212
|
+
* git URL).
|
|
213
|
+
*
|
|
214
|
+
* @param {unknown} value
|
|
215
|
+
* @returns {string | null}
|
|
216
|
+
*/
|
|
217
|
+
export function parseSemver(value) {
|
|
218
|
+
if (typeof value !== "string") return null;
|
|
219
|
+
const m = /(\d+)\.(\d+)\.(\d+)/.exec(value);
|
|
220
|
+
return m ? `${m[1]}.${m[2]}.${m[3]}` : null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Compare two `x.y.z` semver cores. Returns -1 when a < b, 0 when equal, 1
|
|
225
|
+
* when a > b. Inputs MUST already be normalized dotted triples (see
|
|
226
|
+
* `parseSemver`).
|
|
227
|
+
*
|
|
228
|
+
* @param {string} a
|
|
229
|
+
* @param {string} b
|
|
230
|
+
* @returns {-1 | 0 | 1}
|
|
231
|
+
*/
|
|
232
|
+
export function compareSemver(a, b) {
|
|
233
|
+
const pa = a.split(".").map(Number);
|
|
234
|
+
const pb = b.split(".").map(Number);
|
|
235
|
+
for (let i = 0; i < 3; i += 1) {
|
|
236
|
+
if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1;
|
|
237
|
+
}
|
|
238
|
+
return 0;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Extract the consumer's `mandrel-platform` npm dependency spec from a
|
|
243
|
+
* package.json text blob. Scans `dependencies`, `devDependencies`,
|
|
244
|
+
* `optionalDependencies`, and `peerDependencies` in that order. Returns the
|
|
245
|
+
* raw spec string (e.g. `"0.11.3"`, `"^0.11.7"`, `"workspace:*"`) or null when
|
|
246
|
+
* the package isn't depended on (or the JSON is unreadable).
|
|
247
|
+
*
|
|
248
|
+
* @param {string} text package.json contents.
|
|
249
|
+
* @param {string} [pkgName] Dependency name to look for.
|
|
250
|
+
* @returns {string | null}
|
|
251
|
+
*/
|
|
252
|
+
export function extractNpmPlatformVersion(text, pkgName = "mandrel-platform") {
|
|
253
|
+
let pkg;
|
|
254
|
+
try {
|
|
255
|
+
pkg = JSON.parse(text);
|
|
256
|
+
} catch {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
if (!pkg || typeof pkg !== "object") return null;
|
|
260
|
+
const fields = [
|
|
261
|
+
"dependencies",
|
|
262
|
+
"devDependencies",
|
|
263
|
+
"optionalDependencies",
|
|
264
|
+
"peerDependencies",
|
|
265
|
+
];
|
|
266
|
+
for (const field of fields) {
|
|
267
|
+
const deps = pkg[field];
|
|
268
|
+
if (deps && typeof deps === "object" && typeof deps[pkgName] === "string") {
|
|
269
|
+
return deps[pkgName];
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Classify the consumer's npm pin against the latest platform release version.
|
|
277
|
+
*
|
|
278
|
+
* @param {string | null} spec Raw `mandrel-platform` dependency spec, or null if absent.
|
|
279
|
+
* @param {string | null} latestVersion Latest release version (`x.y.z`), or null if unknown.
|
|
280
|
+
* @returns {{
|
|
281
|
+
* rawSpec: string | null,
|
|
282
|
+
* version: string | null,
|
|
283
|
+
* npmState: 'absent' | 'current' | 'lagging' | 'ahead' | 'unknown',
|
|
284
|
+
* }}
|
|
285
|
+
*/
|
|
286
|
+
export function classifyNpmPin(spec, latestVersion) {
|
|
287
|
+
if (spec === null || spec === undefined) {
|
|
288
|
+
return { rawSpec: null, version: null, npmState: "absent" };
|
|
289
|
+
}
|
|
290
|
+
const version = parseSemver(spec);
|
|
291
|
+
if (version === null) {
|
|
292
|
+
// Non-numeric spec (workspace:*, dist-tag, git URL) — can't compare by SHA.
|
|
293
|
+
return { rawSpec: spec, version: null, npmState: "unknown" };
|
|
294
|
+
}
|
|
295
|
+
const latest = parseSemver(latestVersion);
|
|
296
|
+
if (latest === null) {
|
|
297
|
+
return { rawSpec: spec, version, npmState: "unknown" };
|
|
298
|
+
}
|
|
299
|
+
const cmp = compareSemver(version, latest);
|
|
300
|
+
const npmState = cmp === 0 ? "current" : cmp < 0 ? "lagging" : "ahead";
|
|
301
|
+
return { rawSpec: spec, version, npmState };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Detect a surface skew: the npm pin and the workflow `uses:` pin disagree
|
|
306
|
+
* about being current. This is the split-pin class the `uses:`-only check
|
|
307
|
+
* missed — e.g. workflows on the latest release while the npm config package
|
|
308
|
+
* lags an older one (or vice versa). Only meaningful when BOTH surfaces
|
|
309
|
+
* resolve to a comparable currency state.
|
|
310
|
+
*
|
|
311
|
+
* @param {'current' | 'lagging' | 'unknown' | 'no-pins'} usesLagState
|
|
312
|
+
* @param {'absent' | 'current' | 'lagging' | 'ahead' | 'unknown'} npmState
|
|
313
|
+
* @returns {boolean}
|
|
314
|
+
*/
|
|
315
|
+
export function detectSurfaceSkew(usesLagState, npmState) {
|
|
316
|
+
const usesKnown = usesLagState === "current" || usesLagState === "lagging";
|
|
317
|
+
const npmKnown = npmState === "current" || npmState === "lagging";
|
|
318
|
+
if (!usesKnown || !npmKnown) return false;
|
|
319
|
+
return (usesLagState === "current") !== (npmState === "current");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Combine the workflow-pin verdict, the npm verdict, and the surface-skew flag
|
|
324
|
+
* into a single per-consumer drift boolean. `npm ahead` and `npm absent` are
|
|
325
|
+
* informational, not drift; `npm lagging` and any surface skew are.
|
|
326
|
+
*
|
|
327
|
+
* @param {{ drift: boolean }} verdict
|
|
328
|
+
* @param {{ npmState: string }} npm
|
|
329
|
+
* @param {boolean} surfaceSkew
|
|
330
|
+
* @returns {boolean}
|
|
331
|
+
*/
|
|
332
|
+
export function combineDrift(verdict, npm, surfaceSkew) {
|
|
333
|
+
return verdict.drift || npm.npmState === "lagging" || surfaceSkew;
|
|
334
|
+
}
|
|
335
|
+
|
|
198
336
|
/**
|
|
199
337
|
* Render the human-readable dashboard report.
|
|
200
338
|
*
|
|
@@ -208,12 +346,16 @@ export function classifyConsumer(pins, latestReleaseSha) {
|
|
|
208
346
|
* error?: string,
|
|
209
347
|
* pins: Array<{ file: string, line: number, target: string, ref: string | null }>,
|
|
210
348
|
* verdict: ReturnType<typeof classifyConsumer>,
|
|
349
|
+
* npm?: ReturnType<typeof classifyNpmPin>,
|
|
350
|
+
* surfaceSkew?: boolean,
|
|
351
|
+
* drift?: boolean,
|
|
211
352
|
* }>,
|
|
212
353
|
* }} report
|
|
213
354
|
* @returns {string}
|
|
214
355
|
*/
|
|
215
356
|
export function renderReport(report) {
|
|
216
357
|
const { platformRepo, latestRelease, results } = report;
|
|
358
|
+
const latestVersion = parseSemver(latestRelease.tag);
|
|
217
359
|
const out = [];
|
|
218
360
|
out.push("## Cross-consumer pin-drift dashboard");
|
|
219
361
|
out.push("");
|
|
@@ -224,17 +366,19 @@ export function renderReport(report) {
|
|
|
224
366
|
: "unknown";
|
|
225
367
|
out.push(`Latest release: ${relLabel}`);
|
|
226
368
|
out.push("");
|
|
227
|
-
out.push("| Consumer | Pins |
|
|
228
|
-
out.push("| -------- | ---- |
|
|
369
|
+
out.push("| Consumer | Pins | uses SHA | uses lag | npm pin | npm lag | Status |");
|
|
370
|
+
out.push("| -------- | ---- | -------- | -------- | ------- | ------- | ------ |");
|
|
229
371
|
|
|
230
372
|
const driftLines = [];
|
|
231
373
|
for (const r of results) {
|
|
232
374
|
if (r.error) {
|
|
233
|
-
out.push(`| \`${r.name}\` | — | — | — | ⚠️ error |`);
|
|
375
|
+
out.push(`| \`${r.name}\` | — | — | — | — | — | ⚠️ error |`);
|
|
234
376
|
driftLines.push(`- \`${r.name}\` (${r.repo}): error — ${r.error}`);
|
|
235
377
|
continue;
|
|
236
378
|
}
|
|
237
379
|
const v = r.verdict;
|
|
380
|
+
const npm = r.npm ?? { rawSpec: null, version: null, npmState: "absent" };
|
|
381
|
+
const surfaceSkew = r.surfaceSkew === true;
|
|
238
382
|
const shaLabel = v.pinnedSha
|
|
239
383
|
? `\`${v.pinnedSha.slice(0, 7)}\``
|
|
240
384
|
: v.splitPinned
|
|
@@ -250,14 +394,27 @@ export function renderReport(report) {
|
|
|
250
394
|
: v.lagState === "no-pins"
|
|
251
395
|
? "no pins"
|
|
252
396
|
: "unknown";
|
|
397
|
+
const npmLabel = npm.version
|
|
398
|
+
? `\`${npm.version}\``
|
|
399
|
+
: npm.rawSpec
|
|
400
|
+
? `\`${npm.rawSpec}\``
|
|
401
|
+
: "—";
|
|
402
|
+
const npmLagLabel = npm.npmState === "absent" ? "—" : npm.npmState;
|
|
253
403
|
let status;
|
|
254
|
-
if (v.lagState === "no-pins"
|
|
404
|
+
if (v.lagState === "no-pins" && npm.npmState === "absent")
|
|
405
|
+
status = "➖ no platform refs";
|
|
255
406
|
else if (v.splitPinned) status = "❌ split pin";
|
|
256
|
-
else if (
|
|
257
|
-
else if (v.lagState === "
|
|
407
|
+
else if (surfaceSkew) status = "❌ npm/uses skew";
|
|
408
|
+
else if (v.lagState === "lagging" || npm.npmState === "lagging")
|
|
409
|
+
status = "⚠️ lagging";
|
|
410
|
+
else if (
|
|
411
|
+
(v.lagState === "current" || v.lagState === "no-pins") &&
|
|
412
|
+
(npm.npmState === "current" || npm.npmState === "absent")
|
|
413
|
+
)
|
|
414
|
+
status = "✅ current";
|
|
258
415
|
else status = "❔ unknown";
|
|
259
416
|
out.push(
|
|
260
|
-
`| \`${r.name}\` | ${v.pinCount} | ${shaLabel} | ${lagLabel} | ${status} |`,
|
|
417
|
+
`| \`${r.name}\` | ${v.pinCount} | ${shaLabel} | ${lagLabel} | ${npmLabel} | ${npmLagLabel} | ${status} |`,
|
|
261
418
|
);
|
|
262
419
|
|
|
263
420
|
if (v.splitPinned) {
|
|
@@ -279,6 +436,16 @@ export function renderReport(report) {
|
|
|
279
436
|
`- \`${r.name}\` (${r.repo}): LAGGING — pins \`${v.pinnedSha.slice(0, 7)}\`, latest release is \`${(latestRelease.sha || "?").slice(0, 7)}\` (${latestRelease.tag || "?"}).`,
|
|
280
437
|
);
|
|
281
438
|
}
|
|
439
|
+
|
|
440
|
+
if (surfaceSkew) {
|
|
441
|
+
driftLines.push(
|
|
442
|
+
`- \`${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.`,
|
|
443
|
+
);
|
|
444
|
+
} else if (npm.npmState === "lagging") {
|
|
445
|
+
driftLines.push(
|
|
446
|
+
`- \`${r.name}\` (${r.repo}): NPM LAGGING — depends on \`mandrel-platform@${npm.version}\`, latest release is \`${latestVersion ?? "?"}\` (${latestRelease.tag || "?"}).`,
|
|
447
|
+
);
|
|
448
|
+
}
|
|
282
449
|
}
|
|
283
450
|
|
|
284
451
|
out.push("");
|
|
@@ -290,7 +457,7 @@ export function renderReport(report) {
|
|
|
290
457
|
out.push("### ✅ No drift");
|
|
291
458
|
out.push("");
|
|
292
459
|
out.push(
|
|
293
|
-
"Every consumer pins a single platform SHA on the latest release.",
|
|
460
|
+
"Every consumer pins a single platform SHA on the latest release, and its npm `mandrel-platform` dependency is on the matching version.",
|
|
294
461
|
);
|
|
295
462
|
}
|
|
296
463
|
out.push("");
|
|
@@ -405,6 +572,34 @@ export function fetchConsumerWorkflows(repo, branch, runGh) {
|
|
|
405
572
|
return files;
|
|
406
573
|
}
|
|
407
574
|
|
|
575
|
+
/**
|
|
576
|
+
* Fetch a consumer's root `package.json` text over the GitHub contents API.
|
|
577
|
+
* Returns null when the file is absent or unreadable (a consumer that adopts
|
|
578
|
+
* the platform workflows but not the npm config package legitimately has no
|
|
579
|
+
* `mandrel-platform` dependency). The existing `Contents: read` token scope
|
|
580
|
+
* already covers this — no additional permission is required.
|
|
581
|
+
*
|
|
582
|
+
* @param {string} repo "owner/name".
|
|
583
|
+
* @param {string} branch Branch / ref to read.
|
|
584
|
+
* @param {(args: string[]) => string} runGh
|
|
585
|
+
* @returns {string | null}
|
|
586
|
+
*/
|
|
587
|
+
export function fetchConsumerPackageJson(repo, branch, runGh) {
|
|
588
|
+
let obj;
|
|
589
|
+
try {
|
|
590
|
+
obj = ghApiJson(
|
|
591
|
+
`repos/${repo}/contents/package.json?ref=${encodeURIComponent(branch)}`,
|
|
592
|
+
runGh,
|
|
593
|
+
);
|
|
594
|
+
} catch {
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
if (obj && obj.encoding === "base64" && typeof obj.content === "string") {
|
|
598
|
+
return Buffer.from(obj.content, "base64").toString("utf-8");
|
|
599
|
+
}
|
|
600
|
+
return null;
|
|
601
|
+
}
|
|
602
|
+
|
|
408
603
|
/**
|
|
409
604
|
* Resolve a consumer's effective branch: the entry's `branch` if set, else the
|
|
410
605
|
* repo's default branch.
|
|
@@ -436,7 +631,9 @@ export function resolveBranch(consumer, runGh) {
|
|
|
436
631
|
*/
|
|
437
632
|
export function buildReport(config, runGh) {
|
|
438
633
|
const platformRepo = config.platformRepo;
|
|
634
|
+
const platformPkg = config.platformPackage || "mandrel-platform";
|
|
439
635
|
const latestRelease = resolveLatestRelease(platformRepo, runGh);
|
|
636
|
+
const latestVersion = parseSemver(latestRelease.tag);
|
|
440
637
|
const results = [];
|
|
441
638
|
for (const consumer of config.consumers) {
|
|
442
639
|
try {
|
|
@@ -447,19 +644,38 @@ export function buildReport(config, runGh) {
|
|
|
447
644
|
pins.push(...extractPlatformPins(f.path, f.text, platformRepo));
|
|
448
645
|
}
|
|
449
646
|
const verdict = classifyConsumer(pins, latestRelease.sha);
|
|
450
|
-
|
|
647
|
+
const pkgText = fetchConsumerPackageJson(consumer.repo, branch, runGh);
|
|
648
|
+
const npmSpec =
|
|
649
|
+
pkgText === null ? null : extractNpmPlatformVersion(pkgText, platformPkg);
|
|
650
|
+
const npm = classifyNpmPin(npmSpec, latestVersion);
|
|
651
|
+
const surfaceSkew = detectSurfaceSkew(verdict.lagState, npm.npmState);
|
|
652
|
+
results.push({
|
|
653
|
+
name: consumer.name,
|
|
654
|
+
repo: consumer.repo,
|
|
655
|
+
branch,
|
|
656
|
+
pins,
|
|
657
|
+
verdict,
|
|
658
|
+
npm,
|
|
659
|
+
surfaceSkew,
|
|
660
|
+
drift: combineDrift(verdict, npm, surfaceSkew),
|
|
661
|
+
});
|
|
451
662
|
} catch (err) {
|
|
663
|
+
const verdict = classifyConsumer([], latestRelease.sha);
|
|
664
|
+
const npm = classifyNpmPin(null, latestVersion);
|
|
452
665
|
results.push({
|
|
453
666
|
name: consumer.name,
|
|
454
667
|
repo: consumer.repo,
|
|
455
668
|
branch: consumer.branch || "?",
|
|
456
669
|
error: err instanceof Error ? err.message : String(err),
|
|
457
670
|
pins: [],
|
|
458
|
-
verdict
|
|
671
|
+
verdict,
|
|
672
|
+
npm,
|
|
673
|
+
surfaceSkew: false,
|
|
674
|
+
drift: false,
|
|
459
675
|
});
|
|
460
676
|
}
|
|
461
677
|
}
|
|
462
|
-
return { platformRepo, latestRelease, results };
|
|
678
|
+
return { platformRepo, latestRelease, latestVersion, results };
|
|
463
679
|
}
|
|
464
680
|
|
|
465
681
|
/**
|
|
@@ -467,7 +683,7 @@ export function buildReport(config, runGh) {
|
|
|
467
683
|
* @returns {boolean} true when any consumer has drift or an error.
|
|
468
684
|
*/
|
|
469
685
|
export function hasDrift(report) {
|
|
470
|
-
return report.results.some((r) => r.error || r.
|
|
686
|
+
return report.results.some((r) => r.error || r.drift);
|
|
471
687
|
}
|
|
472
688
|
|
|
473
689
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-pin-drift.test.mjs — node:test suite for the cross-consumer pin-drift
|
|
4
|
+
* dashboard (Story #67, MP-12) and the npm-dimension extension that closes the
|
|
5
|
+
* gap where a consumer's `mandrel-platform` npm dependency could lag the
|
|
6
|
+
* workflow `uses:` pins undetected (npm at 0.11.3 while the workflows tracked
|
|
7
|
+
* v0.11.6).
|
|
8
|
+
*
|
|
9
|
+
* The checker exposes pure helpers plus an injectable `runGh` seam, so the
|
|
10
|
+
* whole pipeline is exercised offline with canned GitHub responses — no
|
|
11
|
+
* network, no `gh` auth.
|
|
12
|
+
*
|
|
13
|
+
* Run: node scripts/check-pin-drift.test.mjs (or `node --test scripts/`)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { test } from "node:test";
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
buildReport,
|
|
24
|
+
classifyNpmPin,
|
|
25
|
+
combineDrift,
|
|
26
|
+
compareSemver,
|
|
27
|
+
detectSurfaceSkew,
|
|
28
|
+
extractNpmPlatformVersion,
|
|
29
|
+
fetchConsumerPackageJson,
|
|
30
|
+
hasDrift,
|
|
31
|
+
parseSemver,
|
|
32
|
+
renderReport,
|
|
33
|
+
runCli,
|
|
34
|
+
} from "./check-pin-drift.mjs";
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// parseSemver
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
test("parseSemver extracts the dotted triple from a release tag", () => {
|
|
41
|
+
assert.equal(parseSemver("mandrel-platform-v0.11.7"), "0.11.7");
|
|
42
|
+
assert.equal(parseSemver("v1.4.0"), "1.4.0");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("parseSemver strips range prefixes from a dependency spec", () => {
|
|
46
|
+
assert.equal(parseSemver("^0.11.7"), "0.11.7");
|
|
47
|
+
assert.equal(parseSemver("~1.2.3"), "1.2.3");
|
|
48
|
+
assert.equal(parseSemver(">=2.0.0"), "2.0.0");
|
|
49
|
+
assert.equal(parseSemver("0.11.3"), "0.11.3");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("parseSemver returns null for non-numeric specs and non-strings", () => {
|
|
53
|
+
assert.equal(parseSemver("workspace:*"), null);
|
|
54
|
+
assert.equal(parseSemver("latest"), null);
|
|
55
|
+
assert.equal(parseSemver("github:owner/repo"), null);
|
|
56
|
+
assert.equal(parseSemver(null), null);
|
|
57
|
+
assert.equal(parseSemver(undefined), null);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// compareSemver — numeric, not lexical
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
test("compareSemver orders versions numerically", () => {
|
|
65
|
+
assert.equal(compareSemver("0.11.3", "0.11.7"), -1);
|
|
66
|
+
assert.equal(compareSemver("1.4.0", "1.4.0"), 0);
|
|
67
|
+
assert.equal(compareSemver("2.0.0", "1.9.9"), 1);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("compareSemver compares each segment as a number, not a string", () => {
|
|
71
|
+
// Lexical comparison would put "0.2.0" after "0.10.0"; numeric must not.
|
|
72
|
+
assert.equal(compareSemver("0.2.0", "0.10.0"), -1);
|
|
73
|
+
assert.equal(compareSemver("0.11.10", "0.11.9"), 1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// extractNpmPlatformVersion
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
test("extractNpmPlatformVersion reads the dep from devDependencies", () => {
|
|
81
|
+
const text = JSON.stringify({ devDependencies: { "mandrel-platform": "0.11.3" } });
|
|
82
|
+
assert.equal(extractNpmPlatformVersion(text), "0.11.3");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("extractNpmPlatformVersion prefers dependencies over devDependencies", () => {
|
|
86
|
+
const text = JSON.stringify({
|
|
87
|
+
dependencies: { "mandrel-platform": "1.0.0" },
|
|
88
|
+
devDependencies: { "mandrel-platform": "2.0.0" },
|
|
89
|
+
});
|
|
90
|
+
assert.equal(extractNpmPlatformVersion(text), "1.0.0");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("extractNpmPlatformVersion falls back to optional/peer deps", () => {
|
|
94
|
+
const peer = JSON.stringify({ peerDependencies: { "mandrel-platform": "3.1.4" } });
|
|
95
|
+
assert.equal(extractNpmPlatformVersion(peer), "3.1.4");
|
|
96
|
+
const opt = JSON.stringify({ optionalDependencies: { "mandrel-platform": "5.0.0" } });
|
|
97
|
+
assert.equal(extractNpmPlatformVersion(opt), "5.0.0");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("extractNpmPlatformVersion honors a custom package name", () => {
|
|
101
|
+
const text = JSON.stringify({ devDependencies: { "@scope/other": "9.9.9" } });
|
|
102
|
+
assert.equal(extractNpmPlatformVersion(text, "@scope/other"), "9.9.9");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("extractNpmPlatformVersion returns null when absent or malformed", () => {
|
|
106
|
+
assert.equal(extractNpmPlatformVersion(JSON.stringify({ devDependencies: {} })), null);
|
|
107
|
+
assert.equal(extractNpmPlatformVersion("{ not json"), null);
|
|
108
|
+
assert.equal(extractNpmPlatformVersion("null"), null);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// classifyNpmPin
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
test("classifyNpmPin classifies current / lagging / ahead", () => {
|
|
116
|
+
assert.deepEqual(classifyNpmPin("0.11.7", "0.11.7"), {
|
|
117
|
+
rawSpec: "0.11.7",
|
|
118
|
+
version: "0.11.7",
|
|
119
|
+
npmState: "current",
|
|
120
|
+
});
|
|
121
|
+
assert.equal(classifyNpmPin("0.11.3", "0.11.7").npmState, "lagging");
|
|
122
|
+
assert.equal(classifyNpmPin("1.0.0", "0.11.7").npmState, "ahead");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("classifyNpmPin marks an absent dependency", () => {
|
|
126
|
+
assert.deepEqual(classifyNpmPin(null, "0.11.7"), {
|
|
127
|
+
rawSpec: null,
|
|
128
|
+
version: null,
|
|
129
|
+
npmState: "absent",
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("classifyNpmPin is unknown for non-numeric specs or unknown latest", () => {
|
|
134
|
+
assert.equal(classifyNpmPin("workspace:*", "0.11.7").npmState, "unknown");
|
|
135
|
+
assert.equal(classifyNpmPin("0.11.7", null).npmState, "unknown");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// detectSurfaceSkew — the incident this guard exists for
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
test("detectSurfaceSkew flags uses-current but npm-lagging (and the reverse)", () => {
|
|
143
|
+
assert.equal(detectSurfaceSkew("current", "lagging"), true);
|
|
144
|
+
assert.equal(detectSurfaceSkew("lagging", "current"), true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("detectSurfaceSkew is false when both surfaces agree", () => {
|
|
148
|
+
assert.equal(detectSurfaceSkew("current", "current"), false);
|
|
149
|
+
assert.equal(detectSurfaceSkew("lagging", "lagging"), false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("detectSurfaceSkew is false when either surface is not comparable", () => {
|
|
153
|
+
assert.equal(detectSurfaceSkew("current", "absent"), false);
|
|
154
|
+
assert.equal(detectSurfaceSkew("current", "unknown"), false);
|
|
155
|
+
assert.equal(detectSurfaceSkew("unknown", "lagging"), false);
|
|
156
|
+
assert.equal(detectSurfaceSkew("no-pins", "lagging"), false);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// combineDrift
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
test("combineDrift folds uses-drift, npm-lag, and surface-skew", () => {
|
|
164
|
+
const clean = { drift: false };
|
|
165
|
+
assert.equal(combineDrift(clean, { npmState: "current" }, false), false);
|
|
166
|
+
assert.equal(combineDrift(clean, { npmState: "absent" }, false), false);
|
|
167
|
+
assert.equal(combineDrift(clean, { npmState: "ahead" }, false), false);
|
|
168
|
+
assert.equal(combineDrift(clean, { npmState: "lagging" }, false), true);
|
|
169
|
+
assert.equal(combineDrift(clean, { npmState: "current" }, true), true);
|
|
170
|
+
assert.equal(combineDrift({ drift: true }, { npmState: "current" }, false), true);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// Integration: buildReport + renderReport with an injected gh runner
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
const PLATFORM = "dsj1984/mandrel-platform";
|
|
178
|
+
const TAG = "mandrel-platform-v1.4.0";
|
|
179
|
+
const LATEST_SHA = "a".repeat(40);
|
|
180
|
+
const OLD_SHA = "b".repeat(40);
|
|
181
|
+
|
|
182
|
+
function b64(value) {
|
|
183
|
+
const s = typeof value === "string" ? value : JSON.stringify(value);
|
|
184
|
+
return Buffer.from(s, "utf-8").toString("base64");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function usesYaml(sha) {
|
|
188
|
+
return ["jobs:", " q:", ` uses: ${PLATFORM}/.github/workflows/pr-quality.yml@${sha}`].join(
|
|
189
|
+
"\n",
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function pkgJson(version) {
|
|
194
|
+
const devDependencies = version ? { "mandrel-platform": version } : {};
|
|
195
|
+
return { name: "consumer", devDependencies };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Build an injectable gh runner from a per-repo fixture map:
|
|
200
|
+
* { "owner/repo": { workflowSha, npm: string | null | "throw" } }
|
|
201
|
+
*/
|
|
202
|
+
function makeRunGh(fixtures) {
|
|
203
|
+
return (args) => {
|
|
204
|
+
const path = args[1];
|
|
205
|
+
if (path === `repos/${PLATFORM}/releases/latest`) {
|
|
206
|
+
return JSON.stringify({ tag_name: TAG });
|
|
207
|
+
}
|
|
208
|
+
if (path === `repos/${PLATFORM}/git/ref/tags/${TAG}`) {
|
|
209
|
+
return JSON.stringify({ object: { sha: LATEST_SHA, type: "commit" } });
|
|
210
|
+
}
|
|
211
|
+
for (const [repo, cfg] of Object.entries(fixtures)) {
|
|
212
|
+
if (path === `repos/${repo}/contents/.github/workflows?ref=main`) {
|
|
213
|
+
return JSON.stringify([
|
|
214
|
+
{ type: "file", name: "ci.yml", encoding: "base64", content: b64(usesYaml(cfg.workflowSha)) },
|
|
215
|
+
]);
|
|
216
|
+
}
|
|
217
|
+
if (path === `repos/${repo}/contents/package.json?ref=main`) {
|
|
218
|
+
if (cfg.npm === "throw") throw new Error("404 Not Found");
|
|
219
|
+
return JSON.stringify({ encoding: "base64", content: b64(pkgJson(cfg.npm)) });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
throw new Error(`unexpected gh api path: ${path}`);
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const CONFIG = {
|
|
227
|
+
platformRepo: PLATFORM,
|
|
228
|
+
consumers: [
|
|
229
|
+
{ name: "aligned", repo: "o/aligned", branch: "main" },
|
|
230
|
+
{ name: "skew", repo: "o/skew", branch: "main" },
|
|
231
|
+
{ name: "both-lag", repo: "o/both-lag", branch: "main" },
|
|
232
|
+
{ name: "no-npm", repo: "o/no-npm", branch: "main" },
|
|
233
|
+
],
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const FIXTURES = {
|
|
237
|
+
"o/aligned": { workflowSha: LATEST_SHA, npm: "1.4.0" },
|
|
238
|
+
"o/skew": { workflowSha: LATEST_SHA, npm: "1.3.0" },
|
|
239
|
+
"o/both-lag": { workflowSha: OLD_SHA, npm: "1.3.0" },
|
|
240
|
+
"o/no-npm": { workflowSha: LATEST_SHA, npm: null },
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
function byName(report, name) {
|
|
244
|
+
return report.results.find((r) => r.name === name);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
test("buildReport: aligned consumer is current with no drift", () => {
|
|
248
|
+
const report = buildReport(CONFIG, makeRunGh(FIXTURES));
|
|
249
|
+
assert.equal(report.latestVersion, "1.4.0");
|
|
250
|
+
const r = byName(report, "aligned");
|
|
251
|
+
assert.equal(r.verdict.lagState, "current");
|
|
252
|
+
assert.equal(r.npm.npmState, "current");
|
|
253
|
+
assert.equal(r.surfaceSkew, false);
|
|
254
|
+
assert.equal(r.drift, false);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("buildReport: npm lagging while workflows current is a surface skew", () => {
|
|
258
|
+
const report = buildReport(CONFIG, makeRunGh(FIXTURES));
|
|
259
|
+
const r = byName(report, "skew");
|
|
260
|
+
assert.equal(r.verdict.lagState, "current");
|
|
261
|
+
assert.equal(r.npm.npmState, "lagging");
|
|
262
|
+
assert.equal(r.surfaceSkew, true);
|
|
263
|
+
assert.equal(r.drift, true);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("buildReport: both surfaces lagging is drift but not a skew", () => {
|
|
267
|
+
const report = buildReport(CONFIG, makeRunGh(FIXTURES));
|
|
268
|
+
const r = byName(report, "both-lag");
|
|
269
|
+
assert.equal(r.verdict.lagState, "lagging");
|
|
270
|
+
assert.equal(r.npm.npmState, "lagging");
|
|
271
|
+
assert.equal(r.surfaceSkew, false);
|
|
272
|
+
assert.equal(r.drift, true);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("buildReport: a consumer without the npm dep is current, not drift", () => {
|
|
276
|
+
const report = buildReport(CONFIG, makeRunGh(FIXTURES));
|
|
277
|
+
const r = byName(report, "no-npm");
|
|
278
|
+
assert.equal(r.npm.npmState, "absent");
|
|
279
|
+
assert.equal(r.surfaceSkew, false);
|
|
280
|
+
assert.equal(r.drift, false);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("buildReport: an unreadable package.json is treated as absent, not an error", () => {
|
|
284
|
+
const report = buildReport(
|
|
285
|
+
{ platformRepo: PLATFORM, consumers: [{ name: "c", repo: "o/c", branch: "main" }] },
|
|
286
|
+
makeRunGh({ "o/c": { workflowSha: LATEST_SHA, npm: "throw" } }),
|
|
287
|
+
);
|
|
288
|
+
const r = byName(report, "c");
|
|
289
|
+
assert.equal(r.error, undefined);
|
|
290
|
+
assert.equal(r.npm.npmState, "absent");
|
|
291
|
+
assert.equal(r.drift, false);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("hasDrift is true when any consumer drifts", () => {
|
|
295
|
+
const report = buildReport(CONFIG, makeRunGh(FIXTURES));
|
|
296
|
+
assert.equal(hasDrift(report), true);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("renderReport surfaces the npm columns and drift lines", () => {
|
|
300
|
+
const report = buildReport(CONFIG, makeRunGh(FIXTURES));
|
|
301
|
+
const text = renderReport(report);
|
|
302
|
+
assert.match(text, /npm pin/);
|
|
303
|
+
assert.match(text, /npm lag/);
|
|
304
|
+
assert.match(text, /SURFACE SKEW/);
|
|
305
|
+
assert.match(text, /npm\/uses skew/);
|
|
306
|
+
// The aligned consumer renders its npm version in the table.
|
|
307
|
+
assert.match(text, /`1\.4\.0`/);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("fetchConsumerPackageJson returns null when the file is missing", () => {
|
|
311
|
+
const runGh = () => {
|
|
312
|
+
throw new Error("404");
|
|
313
|
+
};
|
|
314
|
+
assert.equal(fetchConsumerPackageJson("o/x", "main", runGh), null);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
// CLI: --json, --strict, exit codes
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
let cfgDir;
|
|
322
|
+
function writeConfig() {
|
|
323
|
+
cfgDir = mkdtempSync(join(tmpdir(), "pin-drift-test-"));
|
|
324
|
+
const p = join(cfgDir, "consumers.json");
|
|
325
|
+
writeFileSync(p, JSON.stringify(CONFIG));
|
|
326
|
+
return p;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function capture() {
|
|
330
|
+
const chunks = [];
|
|
331
|
+
return { write: (s) => chunks.push(s), text: () => chunks.join("") };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
test("runCli --json emits a machine-readable envelope and exits 0 without --strict", () => {
|
|
335
|
+
const configPath = writeConfig();
|
|
336
|
+
const stdout = capture();
|
|
337
|
+
const stderr = capture();
|
|
338
|
+
try {
|
|
339
|
+
const code = runCli({
|
|
340
|
+
argv: ["--config", configPath, "--json"],
|
|
341
|
+
runGh: makeRunGh(FIXTURES),
|
|
342
|
+
stdout,
|
|
343
|
+
stderr,
|
|
344
|
+
summaryPath: undefined,
|
|
345
|
+
});
|
|
346
|
+
assert.equal(code, 0);
|
|
347
|
+
const envelope = JSON.parse(stdout.text());
|
|
348
|
+
assert.equal(envelope.kind, "pin-drift-report");
|
|
349
|
+
assert.equal(envelope.drift, true);
|
|
350
|
+
assert.equal(envelope.results.length, 4);
|
|
351
|
+
} finally {
|
|
352
|
+
rmSync(cfgDir, { recursive: true, force: true });
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("runCli --strict exits 1 when drift is present", () => {
|
|
357
|
+
const configPath = writeConfig();
|
|
358
|
+
try {
|
|
359
|
+
const code = runCli({
|
|
360
|
+
argv: ["--config", configPath, "--strict"],
|
|
361
|
+
runGh: makeRunGh(FIXTURES),
|
|
362
|
+
stdout: capture(),
|
|
363
|
+
stderr: capture(),
|
|
364
|
+
summaryPath: undefined,
|
|
365
|
+
});
|
|
366
|
+
assert.equal(code, 1);
|
|
367
|
+
} finally {
|
|
368
|
+
rmSync(cfgDir, { recursive: true, force: true });
|
|
369
|
+
}
|
|
370
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$comment": "Data-driven consumer registry for scripts/check-pin-drift.mjs (Story #67, MP-12). Each entry is one downstream repo that pins mandrel-platform reusable workflows / composite actions via `uses: dsj1984/mandrel-platform/...@<sha>`. Adding a new consumer is a single object here — the drift checker enumerates `.github/workflows/*` in each repo over the GitHub API, extracts every mandrel-platform pin, and asserts a single SHA per consumer plus lag vs the latest mandrel-platform release. `branch` is optional (defaults to the repo's default branch).",
|
|
2
|
+
"$comment": "Data-driven consumer registry for scripts/check-pin-drift.mjs (Story #67, MP-12). Each entry is one downstream repo that pins mandrel-platform reusable workflows / composite actions via `uses: dsj1984/mandrel-platform/...@<sha>`. Adding a new consumer is a single object here — the drift checker enumerates `.github/workflows/*` and reads `package.json` in each repo over the GitHub API, extracts every mandrel-platform `uses:` pin plus the `mandrel-platform` npm dependency version, and asserts a single SHA per consumer plus lag (and surface skew between the workflow and npm surfaces) vs the latest mandrel-platform release. `branch` is optional (defaults to the repo's default branch).",
|
|
3
3
|
"platformRepo": "dsj1984/mandrel-platform",
|
|
4
4
|
"consumers": [
|
|
5
5
|
{
|