mandrel-platform 0.10.0 → 0.11.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 CHANGED
@@ -117,6 +117,93 @@ is detected by Renovate).
117
117
 
118
118
  ---
119
119
 
120
+ ## Renovate preset
121
+
122
+ The shared Renovate preset (`default.json`, also exposed at
123
+ `config/renovate.json`) is consumed by extending it from a consumer's
124
+ `renovate.json`:
125
+
126
+ ```jsonc
127
+ {
128
+ "$schema": "https://docs.renovatebot.com/renovate-schema.json",
129
+ "extends": ["github>dsj1984/mandrel-platform"]
130
+ }
131
+ ```
132
+
133
+ It sets a weekly Monday schedule, a 3-day `minimumReleaseAge`, patch/minor
134
+ auto-merge with major updates gated behind the Dependency Dashboard, and
135
+ grouping rules for the common dependency families (Cloudflare, Sentry, Clerk,
136
+ ESLint, Vitest, Playwright, Astro).
137
+
138
+ ### Auto-bumping `mandrel-platform` `uses:` pins
139
+
140
+ The preset ships a `github-actions` manager rule that bumps SHA-pinned
141
+ references to this repo's reusable workflows and composite actions —
142
+ `uses: dsj1984/mandrel-platform/...@<sha>` — so consumers stop drifting on
143
+ stale pins (e.g. `pr-quality.yml@v0.3.1` while `deploy-cloudflare.yml@v0.9.0`).
144
+ The bumps are grouped into a single **"mandrel-platform workflows"** PR and
145
+ ride the preset's weekly window + 3-day `minimumReleaseAge`.
146
+
147
+ **Required:** Renovate only updates a **bare-SHA** pin when the consumer adds a
148
+ version comment after it. Pin with a trailing `# <tag>` comment so Renovate can
149
+ resolve the current release and open the bump PR:
150
+
151
+ ```yaml
152
+ # ✅ Renovate will bump this pin
153
+ - uses: dsj1984/mandrel-platform/.github/actions/setup-toolchain@869bbbf21faa2cdf6045d64a9c3347b928e196fe # v0.10.0
154
+
155
+ # ❌ Bare SHA without a version comment — Renovate leaves it alone
156
+ - uses: dsj1984/mandrel-platform/.github/actions/setup-toolchain@869bbbf21faa2cdf6045d64a9c3347b928e196fe
157
+ ```
158
+
159
+ See the [Dependency Update runbook](docs/runbooks/dependency-update.md) for the
160
+ operator-facing review flow.
161
+
162
+ ---
163
+
164
+ ## Adoption CLI (`platform-sync`)
165
+
166
+ `scripts/platform-sync.mjs` is the operator-facing analogue of `mandrel sync`:
167
+ a single idempotent command a **consumer** repo runs to adopt mandrel-platform
168
+ or to repair the three drift states the founding audit flagged (split pins,
169
+ local-copy runbooks, un-simplified config). Run it from the consumer repo root:
170
+
171
+ ```bash
172
+ # Pin every first-party `uses:` to a release and reconcile config + runbooks
173
+ node node_modules/mandrel-platform/scripts/platform-sync.mjs --ref mandrel-platform-v0.10.0
174
+
175
+ # Preview the plan without touching disk
176
+ node node_modules/mandrel-platform/scripts/platform-sync.mjs --ref v1 --dry-run
177
+ ```
178
+
179
+ What it does (each step is idempotent — a re-run on an already-synced repo
180
+ reports `already in sync`):
181
+
182
+ 1. **Pins workflow SHAs.** Resolves `--ref` (a release tag, branch, or the
183
+ floating `@v1` tag once MP-13 ships it) to its commit SHA via
184
+ `git ls-remote`, then rewrites every
185
+ `uses: dsj1984/mandrel-platform/...@<sha>` reference in the consumer's
186
+ `.github/workflows/` and `.github/actions/` to that single SHA. External
187
+ actions (`actions/checkout`, …) are left untouched. The trailing
188
+ `# <ref>` comment is refreshed so the pin stays human-auditable and the
189
+ Renovate auto-bump rule above can track it.
190
+ 2. **Materializes runbook reference stubs** (§2.2 *link, don't copy*). Copies
191
+ the thin stubs from `templates/runbooks/` into the consumer's
192
+ `docs/runbooks/` **only when absent** — an already-adopted stub is skipped,
193
+ and a full local copy (no stub marker) is surfaced as a warning to
194
+ reconcile by hand, never silently overwritten.
195
+ 3. **Reconciles `extends`.** Prepends `github>dsj1984/mandrel-platform` to the
196
+ consumer's Renovate `extends` and `mandrel-platform/tsconfig.base.json` to
197
+ its `tsconfig.json` `extends`. The SSOT goes first so the consumer's own
198
+ later entries continue to override it.
199
+
200
+ **Flags:** `--ref <ref>` (required), `--dry-run`, `--sha <40-hex>` (skip
201
+ network ref resolution — offline/test mode), `--consumer <dir>` (default:
202
+ cwd), `--templates <dir>`, `--repo <owner/repo>`, `--json` (machine-readable
203
+ result envelope on stdout).
204
+
205
+ ---
206
+
120
207
  ## Development
121
208
 
122
209
  ```bash
package/default.json CHANGED
@@ -24,6 +24,13 @@
24
24
  "dependencyDashboardApproval": true,
25
25
  "automerge": false
26
26
  },
27
+ {
28
+ "description": "Bump SHA-pinned mandrel-platform reusable-workflow and composite-action uses: pins (github-actions manager). Renovate only updates a bare-SHA pin when the consumer adds a version comment after it, e.g. uses: dsj1984/mandrel-platform/...@<sha> # v0.10.0 — see the dependency-update runbook. Grouped into one PR and scheduled via the preset's weekly window + 3-day minimumReleaseAge.",
29
+ "matchManagers": ["github-actions"],
30
+ "matchDepNames": ["dsj1984/mandrel-platform"],
31
+ "groupName": "mandrel-platform workflows",
32
+ "groupSlug": "mandrel-platform"
33
+ },
27
34
  {
28
35
  "description": "Group Astro packages",
29
36
  "matchPackagePatterns": ["^astro$", "^@astrojs/"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel-platform",
3
- "version": "0.10.0",
3
+ "version": "0.11.2",
4
4
  "description": "Shared CI/deploy workflows, composite toolchain action, npm config package, Renovate preset, and operator runbook templates.",
5
5
  "license": "MIT",
6
6
  "engines": {
@@ -24,7 +24,8 @@
24
24
  "scripts": {
25
25
  "typecheck": "node --input-type=module --eval 'process.exit(0)'",
26
26
  "lint": "node --input-type=module --eval 'process.exit(0)'",
27
- "test": "node --input-type=module --eval 'process.exit(0)'",
27
+ "test": "node --test \"scripts/**/*.test.mjs\"",
28
+ "platform:sync": "node scripts/platform-sync.mjs",
28
29
  "sync:commands": "node .agents/scripts/sync-claude-commands.js",
29
30
  "bootstrap": "node .agents/scripts/bootstrap.js",
30
31
  "quality:preview": "node .agents/scripts/quality-preview.js --changed-since HEAD",
@@ -0,0 +1,546 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-pin-drift.mjs
4
+ *
5
+ * Cross-consumer pin-drift dashboard for mandrel-platform (Story #67, MP-12).
6
+ *
7
+ * The split-pin / release-lag state across the three consumers (domio,
8
+ * athportal, swarm-os) went undetected and undocumented: every consumer
9
+ * pinned `pr-quality.yml@<shaA>` and `deploy-cloudflare.yml@<shaB>` — two
10
+ * different release SHAs per repo, neither on the current platform release,
11
+ * with no automated drift detection (roadmap.md §4.2 / §4.3). This script is
12
+ * the standing check that surfaces it automatically.
13
+ *
14
+ * For each consumer in `scripts/pin-drift-consumers.json` it:
15
+ * 1. Enumerates every workflow file under `.github/workflows/` (over the
16
+ * GitHub contents API, against the consumer's default branch unless the
17
+ * entry pins a `branch`).
18
+ * 2. Extracts every `uses:` ref that points at the platform repo
19
+ * (`<platformRepo>/...@<ref>`) — reusable workflows AND composite
20
+ * actions across ALL chains.
21
+ * 3. Asserts the consumer pins a SINGLE platform SHA across all of those
22
+ * refs (a "split pin" is more than one distinct SHA in one consumer).
23
+ * 4. Flags lag: compares the pinned SHA against the latest platform release
24
+ * commit. A consumer is `current` when its single pin equals the latest
25
+ * release SHA, `lagging` when it pins an older release/SHA, and
26
+ * `unknown` when the pinned SHA can't be matched to a release.
27
+ *
28
+ * Data-driven: a new consumer is one object in pin-drift-consumers.json.
29
+ *
30
+ * GitHub access is via the `gh` CLI (`gh api`), so the script inherits the
31
+ * caller's auth (a `GH_TOKEN`/`GITHUB_TOKEN` in CI, or `gh auth` locally).
32
+ * No secrets are read or printed by this script.
33
+ *
34
+ * Usage:
35
+ * node scripts/check-pin-drift.mjs
36
+ * node scripts/check-pin-drift.mjs --config scripts/pin-drift-consumers.json
37
+ * node scripts/check-pin-drift.mjs --json # machine-readable envelope
38
+ * node scripts/check-pin-drift.mjs --strict # exit 1 on any drift
39
+ *
40
+ * Exit codes:
41
+ * 0 — report emitted. Without --strict this is the default even when drift
42
+ * is present (the dashboard reports; it does not block by default).
43
+ * 1 — with --strict: at least one consumer is split-pinned or lagging.
44
+ * Without --strict: only on a fatal error (bad config, gh failure).
45
+ *
46
+ * GitHub Actions: when GITHUB_STEP_SUMMARY is set, the human-readable report
47
+ * is also appended there so it renders on the job summary page.
48
+ */
49
+
50
+ import { readFileSync, appendFileSync } from "node:fs";
51
+ import { resolve } from "node:path";
52
+ import { execFileSync } from "node:child_process";
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Arg parsing
56
+ // ---------------------------------------------------------------------------
57
+
58
+ /**
59
+ * @param {string[]} argv
60
+ * @returns {{ config: string, json: boolean, strict: boolean }}
61
+ */
62
+ export function parseArgv(argv = []) {
63
+ let config = "scripts/pin-drift-consumers.json";
64
+ let json = false;
65
+ let strict = false;
66
+ for (let i = 0; i < argv.length; i += 1) {
67
+ const a = argv[i];
68
+ if (a === "--config") {
69
+ const next = argv[i + 1];
70
+ if (next && !next.startsWith("--")) {
71
+ config = next;
72
+ i += 1;
73
+ }
74
+ } else if (a === "--json") {
75
+ json = true;
76
+ } else if (a === "--strict") {
77
+ strict = true;
78
+ }
79
+ }
80
+ return { config, json, strict };
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Pure helpers (exported for unit-style probing without GitHub access)
85
+ // ---------------------------------------------------------------------------
86
+
87
+ const SHA_RE = /^[0-9a-f]{40}$/i;
88
+
89
+ /**
90
+ * Is `ref` a full 40-char hex commit SHA?
91
+ * @param {string} ref
92
+ * @returns {boolean}
93
+ */
94
+ export function isFullSha(ref) {
95
+ return SHA_RE.test(ref);
96
+ }
97
+
98
+ /**
99
+ * Extract every `uses:` ref that targets the platform repo from one workflow
100
+ * file's text. Matches both reusable-workflow refs
101
+ * (`<platformRepo>/.github/workflows/x.yml@<ref>`) and composite-action refs
102
+ * (`<platformRepo>/.github/actions/y@<ref>`).
103
+ *
104
+ * @param {string} file Display label for the file (path in the repo).
105
+ * @param {string} text File contents.
106
+ * @param {string} platformRepo e.g. "dsj1984/mandrel-platform".
107
+ * @returns {Array<{ file: string, line: number, target: string, ref: string | null }>}
108
+ */
109
+ export function extractPlatformPins(file, text, platformRepo) {
110
+ const pins = [];
111
+ const lines = text.split(/\r?\n/);
112
+ const usesRe = /^\s*(?:-\s*)?uses:\s*['"]?([^'"#\s]+)['"]?/;
113
+ for (let i = 0; i < lines.length; i += 1) {
114
+ const m = usesRe.exec(lines[i]);
115
+ if (!m) continue;
116
+ const value = m[1];
117
+ // Only platform-repo refs: `<platformRepo>` or `<platformRepo>/<subpath>`.
118
+ if (value !== platformRepo && !value.startsWith(`${platformRepo}/`)) {
119
+ continue;
120
+ }
121
+ const atIndex = value.indexOf("@");
122
+ const target = atIndex === -1 ? value : value.slice(0, atIndex);
123
+ const ref = atIndex === -1 ? null : value.slice(atIndex + 1);
124
+ pins.push({ file, line: i + 1, target, ref });
125
+ }
126
+ return pins;
127
+ }
128
+
129
+ /**
130
+ * Classify one consumer's pin set into a drift verdict.
131
+ *
132
+ * @param {Array<{ file: string, line: number, target: string, ref: string | null }>} pins
133
+ * @param {string | null} latestReleaseSha 40-char SHA of the latest platform release commit, or null if unknown.
134
+ * @returns {{
135
+ * pinCount: number,
136
+ * distinctRefs: string[],
137
+ * splitPinned: boolean,
138
+ * floatingRefs: string[],
139
+ * pinnedSha: string | null,
140
+ * lagState: 'current' | 'lagging' | 'unknown' | 'no-pins',
141
+ * drift: boolean,
142
+ * }}
143
+ */
144
+ export function classifyConsumer(pins, latestReleaseSha) {
145
+ const refs = pins.map((p) => p.ref).filter((r) => r !== null);
146
+ const distinctRefs = [...new Set(refs)];
147
+ const floatingRefs = distinctRefs.filter((r) => !isFullSha(r));
148
+
149
+ if (pins.length === 0) {
150
+ return {
151
+ pinCount: 0,
152
+ distinctRefs: [],
153
+ splitPinned: false,
154
+ floatingRefs: [],
155
+ pinnedSha: null,
156
+ lagState: "no-pins",
157
+ drift: false,
158
+ };
159
+ }
160
+
161
+ const distinctShas = distinctRefs.filter((r) => isFullSha(r));
162
+ // "Split pin" = more than one distinct platform ref across all chains
163
+ // (multiple SHAs, or a mix of SHA + floating tag/branch).
164
+ const splitPinned = distinctRefs.length > 1;
165
+ const pinnedSha =
166
+ distinctShas.length === 1 && floatingRefs.length === 0
167
+ ? distinctShas[0].toLowerCase()
168
+ : null;
169
+
170
+ let lagState;
171
+ if (floatingRefs.length > 0 && distinctShas.length === 0) {
172
+ // Pinned only to floating refs (tags/branches) — can't verify lag by SHA.
173
+ lagState = "unknown";
174
+ } else if (pinnedSha === null) {
175
+ // Split or mixed — lag is moot until the split is resolved.
176
+ lagState = "unknown";
177
+ } else if (latestReleaseSha === null) {
178
+ lagState = "unknown";
179
+ } else if (pinnedSha === latestReleaseSha.toLowerCase()) {
180
+ lagState = "current";
181
+ } else {
182
+ lagState = "lagging";
183
+ }
184
+
185
+ const drift = splitPinned || lagState === "lagging";
186
+
187
+ return {
188
+ pinCount: pins.length,
189
+ distinctRefs,
190
+ splitPinned,
191
+ floatingRefs,
192
+ pinnedSha,
193
+ lagState,
194
+ drift,
195
+ };
196
+ }
197
+
198
+ /**
199
+ * Render the human-readable dashboard report.
200
+ *
201
+ * @param {{
202
+ * platformRepo: string,
203
+ * latestRelease: { tag: string | null, sha: string | null },
204
+ * results: Array<{
205
+ * name: string,
206
+ * repo: string,
207
+ * branch: string,
208
+ * error?: string,
209
+ * pins: Array<{ file: string, line: number, target: string, ref: string | null }>,
210
+ * verdict: ReturnType<typeof classifyConsumer>,
211
+ * }>,
212
+ * }} report
213
+ * @returns {string}
214
+ */
215
+ export function renderReport(report) {
216
+ const { platformRepo, latestRelease, results } = report;
217
+ const out = [];
218
+ out.push("## Cross-consumer pin-drift dashboard");
219
+ out.push("");
220
+ out.push(`Platform: \`${platformRepo}\``);
221
+ const relLabel =
222
+ latestRelease.tag && latestRelease.sha
223
+ ? `\`${latestRelease.tag}\` (\`${latestRelease.sha.slice(0, 7)}\`)`
224
+ : "unknown";
225
+ out.push(`Latest release: ${relLabel}`);
226
+ out.push("");
227
+ out.push("| Consumer | Pins | Pinned SHA | Lag | Status |");
228
+ out.push("| -------- | ---- | ---------- | --- | ------ |");
229
+
230
+ const driftLines = [];
231
+ for (const r of results) {
232
+ if (r.error) {
233
+ out.push(`| \`${r.name}\` | — | — | — | ⚠️ error |`);
234
+ driftLines.push(`- \`${r.name}\` (${r.repo}): error — ${r.error}`);
235
+ continue;
236
+ }
237
+ const v = r.verdict;
238
+ const shaLabel = v.pinnedSha
239
+ ? `\`${v.pinnedSha.slice(0, 7)}\``
240
+ : v.splitPinned
241
+ ? `split (${v.distinctRefs.length})`
242
+ : v.floatingRefs.length > 0
243
+ ? v.floatingRefs.map((f) => `\`@${f}\``).join(", ")
244
+ : "—";
245
+ const lagLabel =
246
+ v.lagState === "current"
247
+ ? "current"
248
+ : v.lagState === "lagging"
249
+ ? "lagging"
250
+ : v.lagState === "no-pins"
251
+ ? "no pins"
252
+ : "unknown";
253
+ let status;
254
+ if (v.lagState === "no-pins") status = "➖ no platform pins";
255
+ else if (v.splitPinned) status = "❌ split pin";
256
+ else if (v.lagState === "lagging") status = "⚠️ lagging";
257
+ else if (v.lagState === "current") status = "✅ current";
258
+ else status = "❔ unknown";
259
+ out.push(
260
+ `| \`${r.name}\` | ${v.pinCount} | ${shaLabel} | ${lagLabel} | ${status} |`,
261
+ );
262
+
263
+ if (v.splitPinned) {
264
+ const refList = v.distinctRefs
265
+ .map((ref) => {
266
+ const where = r.pins
267
+ .filter((p) => p.ref === ref)
268
+ .map((p) => `${p.file}:${p.line}`)
269
+ .join(", ");
270
+ const short = isFullSha(ref) ? ref.slice(0, 7) : ref;
271
+ return ` - \`${short}\` ← ${where}`;
272
+ })
273
+ .join("\n");
274
+ driftLines.push(
275
+ `- \`${r.name}\` (${r.repo}): SPLIT PIN — ${v.distinctRefs.length} distinct platform refs across chains:\n${refList}`,
276
+ );
277
+ } else if (v.lagState === "lagging") {
278
+ driftLines.push(
279
+ `- \`${r.name}\` (${r.repo}): LAGGING — pins \`${v.pinnedSha.slice(0, 7)}\`, latest release is \`${(latestRelease.sha || "?").slice(0, 7)}\` (${latestRelease.tag || "?"}).`,
280
+ );
281
+ }
282
+ }
283
+
284
+ out.push("");
285
+ if (driftLines.length > 0) {
286
+ out.push("### Drift detected");
287
+ out.push("");
288
+ out.push(...driftLines);
289
+ } else {
290
+ out.push("### ✅ No drift");
291
+ out.push("");
292
+ out.push(
293
+ "Every consumer pins a single platform SHA on the latest release.",
294
+ );
295
+ }
296
+ out.push("");
297
+ return out.join("\n");
298
+ }
299
+
300
+ // ---------------------------------------------------------------------------
301
+ // GitHub access (via gh CLI) — thin, injectable seam for testing
302
+ // ---------------------------------------------------------------------------
303
+
304
+ /**
305
+ * Run `gh api <path>` and parse the JSON response.
306
+ *
307
+ * @param {string} apiPath e.g. "repos/owner/repo/releases/latest".
308
+ * @param {(args: string[]) => string} runGh Injectable runner (default execFileSync gh).
309
+ * @returns {unknown}
310
+ */
311
+ function ghApiJson(apiPath, runGh) {
312
+ const raw = runGh(["api", apiPath, "-H", "Accept: application/vnd.github+json"]);
313
+ return JSON.parse(raw);
314
+ }
315
+
316
+ /**
317
+ * Default gh runner — shells out to the `gh` CLI.
318
+ * @param {string[]} args
319
+ * @returns {string}
320
+ */
321
+ export function defaultGhRunner(args) {
322
+ return execFileSync("gh", args, {
323
+ encoding: "utf-8",
324
+ maxBuffer: 32 * 1024 * 1024,
325
+ });
326
+ }
327
+
328
+ /**
329
+ * Resolve the latest platform release tag + the commit SHA that tag points at.
330
+ * Falls back gracefully to { tag: null, sha: null } when the platform has no
331
+ * published release.
332
+ *
333
+ * @param {string} platformRepo
334
+ * @param {(args: string[]) => string} runGh
335
+ * @returns {{ tag: string | null, sha: string | null }}
336
+ */
337
+ export function resolveLatestRelease(platformRepo, runGh) {
338
+ let release;
339
+ try {
340
+ release = ghApiJson(`repos/${platformRepo}/releases/latest`, runGh);
341
+ } catch {
342
+ return { tag: null, sha: null };
343
+ }
344
+ const tag = release && typeof release.tag_name === "string" ? release.tag_name : null;
345
+ if (!tag) return { tag: null, sha: null };
346
+ // Resolve the tag to its commit SHA. Tags may be lightweight (object is the
347
+ // commit) or annotated (object is the tag, deref to .object.sha).
348
+ try {
349
+ const refObj = ghApiJson(
350
+ `repos/${platformRepo}/git/ref/tags/${encodeURIComponent(tag)}`,
351
+ runGh,
352
+ );
353
+ let sha = refObj?.object?.sha ?? null;
354
+ if (refObj?.object?.type === "tag" && sha) {
355
+ const tagObj = ghApiJson(`repos/${platformRepo}/git/tags/${sha}`, runGh);
356
+ sha = tagObj?.object?.sha ?? sha;
357
+ }
358
+ return { tag, sha: sha ? sha.toLowerCase() : null };
359
+ } catch {
360
+ return { tag, sha: null };
361
+ }
362
+ }
363
+
364
+ /**
365
+ * Recursively list workflow files in a consumer's `.github/workflows/` dir and
366
+ * return [{ path, text }]. Uses the git trees API to enumerate, then the
367
+ * contents API to fetch each file. Returns [] when the dir is absent.
368
+ *
369
+ * @param {string} repo "owner/name".
370
+ * @param {string} branch Branch / ref to read.
371
+ * @param {(args: string[]) => string} runGh
372
+ * @returns {Array<{ path: string, text: string }>}
373
+ */
374
+ export function fetchConsumerWorkflows(repo, branch, runGh) {
375
+ let listing;
376
+ try {
377
+ listing = ghApiJson(
378
+ `repos/${repo}/contents/.github/workflows?ref=${encodeURIComponent(branch)}`,
379
+ runGh,
380
+ );
381
+ } catch {
382
+ return [];
383
+ }
384
+ if (!Array.isArray(listing)) return [];
385
+ const files = [];
386
+ for (const entry of listing) {
387
+ if (entry.type !== "file" || !/\.ya?ml$/i.test(entry.name)) continue;
388
+ // entry.content is base64 for the contents endpoint, but the dir listing
389
+ // omits it — fetch the blob via its git sha for an explicit decode.
390
+ let text = "";
391
+ if (typeof entry.content === "string" && entry.encoding === "base64") {
392
+ text = Buffer.from(entry.content, "base64").toString("utf-8");
393
+ } else {
394
+ try {
395
+ const blob = ghApiJson(`repos/${repo}/git/blobs/${entry.sha}`, runGh);
396
+ if (blob?.encoding === "base64" && typeof blob.content === "string") {
397
+ text = Buffer.from(blob.content, "base64").toString("utf-8");
398
+ }
399
+ } catch {
400
+ text = "";
401
+ }
402
+ }
403
+ files.push({ path: `.github/workflows/${entry.name}`, text });
404
+ }
405
+ return files;
406
+ }
407
+
408
+ /**
409
+ * Resolve a consumer's effective branch: the entry's `branch` if set, else the
410
+ * repo's default branch.
411
+ *
412
+ * @param {{ repo: string, branch?: string }} consumer
413
+ * @param {(args: string[]) => string} runGh
414
+ * @returns {string}
415
+ */
416
+ export function resolveBranch(consumer, runGh) {
417
+ if (consumer.branch) return consumer.branch;
418
+ try {
419
+ const repoMeta = ghApiJson(`repos/${consumer.repo}`, runGh);
420
+ return repoMeta?.default_branch || "main";
421
+ } catch {
422
+ return "main";
423
+ }
424
+ }
425
+
426
+ // ---------------------------------------------------------------------------
427
+ // Orchestration
428
+ // ---------------------------------------------------------------------------
429
+
430
+ /**
431
+ * Build the full drift report for the configured consumers.
432
+ *
433
+ * @param {{ platformRepo: string, consumers: Array<{ name: string, repo: string, branch?: string }> }} config
434
+ * @param {(args: string[]) => string} runGh
435
+ * @returns {ReturnType<typeof renderReport> extends string ? object : never}
436
+ */
437
+ export function buildReport(config, runGh) {
438
+ const platformRepo = config.platformRepo;
439
+ const latestRelease = resolveLatestRelease(platformRepo, runGh);
440
+ const results = [];
441
+ for (const consumer of config.consumers) {
442
+ try {
443
+ const branch = resolveBranch(consumer, runGh);
444
+ const files = fetchConsumerWorkflows(consumer.repo, branch, runGh);
445
+ const pins = [];
446
+ for (const f of files) {
447
+ pins.push(...extractPlatformPins(f.path, f.text, platformRepo));
448
+ }
449
+ const verdict = classifyConsumer(pins, latestRelease.sha);
450
+ results.push({ name: consumer.name, repo: consumer.repo, branch, pins, verdict });
451
+ } catch (err) {
452
+ results.push({
453
+ name: consumer.name,
454
+ repo: consumer.repo,
455
+ branch: consumer.branch || "?",
456
+ error: err instanceof Error ? err.message : String(err),
457
+ pins: [],
458
+ verdict: classifyConsumer([], latestRelease.sha),
459
+ });
460
+ }
461
+ }
462
+ return { platformRepo, latestRelease, results };
463
+ }
464
+
465
+ /**
466
+ * @param {object} report
467
+ * @returns {boolean} true when any consumer has drift or an error.
468
+ */
469
+ export function hasDrift(report) {
470
+ return report.results.some((r) => r.error || r.verdict.drift);
471
+ }
472
+
473
+ // ---------------------------------------------------------------------------
474
+ // CLI entry
475
+ // ---------------------------------------------------------------------------
476
+
477
+ /**
478
+ * @param {{
479
+ * argv?: string[],
480
+ * cwd?: string,
481
+ * stdout?: { write: (s: string) => void },
482
+ * stderr?: { write: (s: string) => void },
483
+ * runGh?: (args: string[]) => string,
484
+ * summaryPath?: string | undefined,
485
+ * }} [opts]
486
+ * @returns {number} exit code
487
+ */
488
+ export function runCli({
489
+ argv = process.argv.slice(2),
490
+ cwd = process.cwd(),
491
+ stdout = process.stdout,
492
+ stderr = process.stderr,
493
+ runGh = defaultGhRunner,
494
+ summaryPath = process.env.GITHUB_STEP_SUMMARY,
495
+ } = {}) {
496
+ const { config: configRel, json, strict } = parseArgv(argv);
497
+ const configPath = resolve(cwd, configRel);
498
+
499
+ let config;
500
+ try {
501
+ config = JSON.parse(readFileSync(configPath, "utf-8"));
502
+ } catch (err) {
503
+ stderr.write(
504
+ `[pin-drift] ❌ failed to read config ${configPath}: ${err instanceof Error ? err.message : String(err)}\n`,
505
+ );
506
+ return 1;
507
+ }
508
+ if (!config.platformRepo || !Array.isArray(config.consumers)) {
509
+ stderr.write(
510
+ `[pin-drift] ❌ config must define { platformRepo: string, consumers: [] }\n`,
511
+ );
512
+ return 1;
513
+ }
514
+
515
+ const report = buildReport(config, runGh);
516
+ const drift = hasDrift(report);
517
+
518
+ if (json) {
519
+ stdout.write(`${JSON.stringify({ kind: "pin-drift-report", drift, ...report }, null, 2)}\n`);
520
+ } else {
521
+ const text = renderReport(report);
522
+ stdout.write(`${text}\n`);
523
+ if (summaryPath) {
524
+ try {
525
+ appendFileSync(summaryPath, `${text}\n`);
526
+ } catch (err) {
527
+ stderr.write(
528
+ `[pin-drift] ⚠ could not write job summary: ${err instanceof Error ? err.message : String(err)}\n`,
529
+ );
530
+ }
531
+ }
532
+ }
533
+
534
+ if (strict && drift) {
535
+ stderr.write(`[pin-drift] ❌ drift detected (--strict)\n`);
536
+ return 1;
537
+ }
538
+ return 0;
539
+ }
540
+
541
+ // Direct-invocation guard (matches the repo's other scripts/*.mjs entry style).
542
+ const invokedDirectly =
543
+ process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
544
+ if (invokedDirectly) {
545
+ process.exit(runCli());
546
+ }
@@ -0,0 +1,18 @@
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).",
3
+ "platformRepo": "dsj1984/mandrel-platform",
4
+ "consumers": [
5
+ {
6
+ "name": "domio",
7
+ "repo": "dsj1984/domio"
8
+ },
9
+ {
10
+ "name": "athportal",
11
+ "repo": "dsj1984/athportal"
12
+ },
13
+ {
14
+ "name": "swarm-os",
15
+ "repo": "Beestera/swarm-os"
16
+ }
17
+ ]
18
+ }
@@ -0,0 +1,450 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * platform-sync.mjs — mandrel-platform adoption / drift-repair CLI (MP-14).
4
+ *
5
+ * The operator-facing analogue of `mandrel sync`. Run it from the root of a
6
+ * **consumer** repo to adopt mandrel-platform — or to repair the three drift
7
+ * states the founding audit (§4.1) called out:
8
+ *
9
+ * 1. SPLIT PINS — consumer workflows reference
10
+ * `dsj1984/mandrel-platform/...@<sha>` at mixed/stale SHAs. This command
11
+ * resolves a chosen release ref (e.g. `mandrel-platform-v0.10.0`, or the
12
+ * `@v1` floating tag once MP-13 ships it) to its commit SHA and rewrites
13
+ * every first-party `uses:` pin to that single SHA — leaving the
14
+ * `# <ref>` trailing comment so the pin stays human-auditable and
15
+ * Renovate's `helpers:pinGitHubActionDigests`-style bump rule can track
16
+ * it (MP-11).
17
+ *
18
+ * 2. LOCAL-COPY RUNBOOKS — consumer holds full local copies of the shared
19
+ * process runbooks instead of thin reference stubs (§2.2 "reference,
20
+ * don't copy"). This command materializes the canonical reference stubs
21
+ * from `templates/runbooks/` into the consumer's `docs/runbooks/`,
22
+ * **link-only** — it never overwrites a stub the operator has already
23
+ * filled in (idempotent by content-marker detection).
24
+ *
25
+ * 3. UN-SIMPLIFIED CONFIG — consumer's `renovate.json` / `tsconfig.json`
26
+ * hand-reimplement what the shared preset / base config already provide.
27
+ * This command reconciles the `extends` chains so the consumer extends
28
+ * the SSOT (`github>dsj1984/mandrel-platform` for Renovate,
29
+ * `mandrel-platform/tsconfig.base.json` for TypeScript).
30
+ *
31
+ * Idempotent: re-running on an already-synced consumer makes no changes and
32
+ * reports `unchanged`. `--dry-run` prints the planned diff without touching
33
+ * disk or the network mutation.
34
+ *
35
+ * Usage (from the consumer repo root):
36
+ * node node_modules/mandrel-platform/scripts/platform-sync.mjs --ref mandrel-platform-v0.10.0
37
+ * node .../platform-sync.mjs --ref v1 --dry-run
38
+ * node .../platform-sync.mjs --ref <ref> --consumer /path/to/consumer --templates /path/to/mandrel-platform/templates
39
+ *
40
+ * Flags:
41
+ * --ref <ref> (required) release tag / branch / floating tag to pin to.
42
+ * --dry-run plan only; no disk writes, no SHA resolution network call
43
+ * when --sha is also supplied.
44
+ * --sha <40-hex> skip ref→SHA resolution and pin to this SHA directly
45
+ * (offline / test mode).
46
+ * --consumer <dir> consumer repo root (default: process.cwd()).
47
+ * --templates <dir> mandrel-platform templates/ dir (default: resolved
48
+ * relative to this script — works when run from
49
+ * node_modules/mandrel-platform/scripts/).
50
+ * --repo <owner/repo> first-party slug to pin (default: dsj1984/mandrel-platform).
51
+ * --json emit the result envelope as JSON on stdout.
52
+ *
53
+ * Exit codes:
54
+ * 0 — sync applied or already in sync (or dry-run printed cleanly).
55
+ * 1 — a fatal error (unresolvable ref, missing templates, malformed config).
56
+ */
57
+
58
+ import { execFileSync } from "node:child_process";
59
+ import {
60
+ existsSync,
61
+ mkdirSync,
62
+ readFileSync,
63
+ readdirSync,
64
+ statSync,
65
+ writeFileSync,
66
+ } from "node:fs";
67
+ import { dirname, join, relative, resolve } from "node:path";
68
+ import { fileURLToPath } from "node:url";
69
+
70
+ const __dirname = dirname(fileURLToPath(import.meta.url));
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Arg parsing
74
+ // ---------------------------------------------------------------------------
75
+
76
+ const args = process.argv.slice(2);
77
+ const opts = {
78
+ ref: null,
79
+ sha: null,
80
+ dryRun: false,
81
+ consumer: process.cwd(),
82
+ templates: null,
83
+ repo: "dsj1984/mandrel-platform",
84
+ json: false,
85
+ };
86
+
87
+ for (let i = 0; i < args.length; i++) {
88
+ const a = args[i];
89
+ if (a === "--ref" && args[i + 1]) opts.ref = args[++i];
90
+ else if (a === "--sha" && args[i + 1]) opts.sha = args[++i];
91
+ else if (a === "--dry-run") opts.dryRun = true;
92
+ else if (a === "--consumer" && args[i + 1]) opts.consumer = resolve(args[++i]);
93
+ else if (a === "--templates" && args[i + 1]) opts.templates = resolve(args[++i]);
94
+ else if (a === "--repo" && args[i + 1]) opts.repo = args[++i];
95
+ else if (a === "--json") opts.json = true;
96
+ else if (a === "--help" || a === "-h") {
97
+ printHelp();
98
+ process.exit(0);
99
+ } else {
100
+ fail(`Unknown argument: ${a}`);
101
+ }
102
+ }
103
+
104
+ function printHelp() {
105
+ // Echo the usage block from the file header so `--help` stays in sync.
106
+ process.stdout.write(
107
+ [
108
+ "platform-sync — mandrel-platform adoption / drift-repair CLI (MP-14)",
109
+ "",
110
+ "Usage (from the consumer repo root):",
111
+ " node node_modules/mandrel-platform/scripts/platform-sync.mjs --ref <ref> [--dry-run]",
112
+ "",
113
+ "Flags:",
114
+ " --ref <ref> (required) release tag / branch / floating tag to pin to.",
115
+ " --dry-run plan only; no disk writes.",
116
+ " --sha <40-hex> skip ref->SHA resolution; pin to this SHA (offline mode).",
117
+ " --consumer <dir> consumer repo root (default: cwd).",
118
+ " --templates <dir> mandrel-platform templates/ dir (default: resolved from script).",
119
+ " --repo <owner/repo> first-party slug to pin (default: dsj1984/mandrel-platform).",
120
+ " --json emit the result envelope as JSON.",
121
+ "",
122
+ ].join("\n")
123
+ );
124
+ }
125
+
126
+ function fail(msg) {
127
+ process.stderr.write(`❌ platform-sync: ${msg}\n`);
128
+ process.exit(1);
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Logging — quiet under --json (the envelope is the only stdout artifact).
133
+ // ---------------------------------------------------------------------------
134
+
135
+ function log(msg) {
136
+ if (!opts.json) process.stdout.write(`${msg}\n`);
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Defaults requiring resolution
141
+ // ---------------------------------------------------------------------------
142
+
143
+ if (!opts.ref) fail("--ref <release-tag|branch|floating-tag> is required.");
144
+ if (opts.sha && !/^[0-9a-fA-F]{40}$/.test(opts.sha)) {
145
+ fail(`--sha must be a 40-character hex commit SHA (got: ${opts.sha}).`);
146
+ }
147
+
148
+ // templates/ defaults to the dir adjacent to this script's package root.
149
+ // When run from node_modules/mandrel-platform/scripts/, that is
150
+ // node_modules/mandrel-platform/templates/.
151
+ if (!opts.templates) {
152
+ opts.templates = resolve(__dirname, "..", "templates");
153
+ }
154
+ const runbookTemplatesDir = join(opts.templates, "runbooks");
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // 1. Resolve the chosen ref → commit SHA
158
+ // ---------------------------------------------------------------------------
159
+
160
+ /**
161
+ * Resolve `ref` (tag/branch/floating tag) on the remote `repo` to its full
162
+ * 40-char commit SHA. Uses `git ls-remote`, which needs no checkout and works
163
+ * for tags, annotated tags (peeled `^{}`), and branches. In --dry-run with an
164
+ * explicit --sha we skip the network entirely.
165
+ */
166
+ function resolveSha() {
167
+ if (opts.sha) return opts.sha;
168
+ const remote = `https://github.com/${opts.repo}.git`;
169
+ let out;
170
+ try {
171
+ out = execFileSync("git", ["ls-remote", remote, opts.ref, `${opts.ref}^{}`], {
172
+ encoding: "utf8",
173
+ stdio: ["ignore", "pipe", "pipe"],
174
+ });
175
+ } catch (err) {
176
+ fail(
177
+ `could not resolve ref '${opts.ref}' on ${opts.repo}: ${
178
+ (err && err.stderr) || err.message
179
+ }`
180
+ );
181
+ }
182
+ const lines = out.trim().split("\n").filter(Boolean);
183
+ if (lines.length === 0) {
184
+ fail(`ref '${opts.ref}' not found on ${opts.repo}.`);
185
+ }
186
+ // Prefer the peeled (`^{}`) line for annotated tags — that is the commit the
187
+ // tag ultimately points at, which is what a `uses: ...@<sha>` pin must use.
188
+ const peeled = lines.find((l) => l.endsWith(`^{}`));
189
+ const chosen = peeled || lines[0];
190
+ const sha = chosen.split(/\s+/)[0];
191
+ if (!/^[0-9a-fA-F]{40}$/.test(sha)) {
192
+ fail(`resolved ref '${opts.ref}' to a non-SHA value: '${sha}'.`);
193
+ }
194
+ return sha;
195
+ }
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // 2. Pin first-party `uses:` SHAs in consumer workflows
199
+ // ---------------------------------------------------------------------------
200
+
201
+ /** Recursively collect `.yml`/`.yaml` files under a directory. */
202
+ function collectYaml(dir) {
203
+ const found = [];
204
+ if (!existsSync(dir)) return found;
205
+ for (const entry of readdirSync(dir)) {
206
+ const full = join(dir, entry);
207
+ const st = statSync(full);
208
+ if (st.isDirectory()) found.push(...collectYaml(full));
209
+ else if (/\.ya?ml$/.test(entry)) found.push(full);
210
+ }
211
+ return found;
212
+ }
213
+
214
+ /**
215
+ * Rewrite every first-party `uses: <repo>/<path>@<oldSha>` to the target SHA.
216
+ * The match mirrors check-workflow-portability.mjs's internal-pin regex but is
217
+ * scoped to the configured `repo` slug (the consumer's workflows reference
218
+ * mandrel-platform explicitly, so the slug is known). Preserves any trailing
219
+ * `# <comment>` but rewrites it to `# <ref>` so the human-readable annotation
220
+ * tracks the chosen release.
221
+ */
222
+ function pinWorkflows(targetSha) {
223
+ const workflowsDir = join(opts.consumer, ".github", "workflows");
224
+ const actionsDir = join(opts.consumer, ".github", "actions");
225
+ const files = [...collectYaml(workflowsDir), ...collectYaml(actionsDir)];
226
+ const slug = opts.repo.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
227
+ // uses: dsj1984/mandrel-platform/<subpath>@<40hex> [optional trailing comment]
228
+ const pinRe = new RegExp(
229
+ `(uses:\\s*['"]?${slug}/[^@\\s'"]+@)([0-9a-fA-F]{40})(['"]?)([^\\n]*)`,
230
+ "g"
231
+ );
232
+ const changes = [];
233
+ for (const file of files) {
234
+ const before = readFileSync(file, "utf8");
235
+ let touched = false;
236
+ const after = before.replace(pinRe, (_m, head, oldSha, quote, trailing) => {
237
+ // Drop any existing trailing comment; re-attach a fresh `# <ref>`.
238
+ const newTrailing = ` # ${opts.ref}`;
239
+ if (oldSha.toLowerCase() === targetSha.toLowerCase()) {
240
+ // SHA already correct — but normalize the comment if it drifted.
241
+ const normalized = `${head}${oldSha}${quote}${newTrailing}`;
242
+ const current = `${head}${oldSha}${quote}${trailing}`;
243
+ if (normalized !== current) touched = true;
244
+ return normalized;
245
+ }
246
+ touched = true;
247
+ changes.push({ file: rel(file), from: oldSha.slice(0, 7), to: targetSha.slice(0, 7) });
248
+ return `${head}${targetSha}${quote}${newTrailing}`;
249
+ });
250
+ if (touched && after !== before) {
251
+ if (!opts.dryRun) writeFileSync(file, after);
252
+ }
253
+ }
254
+ return changes;
255
+ }
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // 3. Materialize runbook reference stubs (link, don't copy)
259
+ // ---------------------------------------------------------------------------
260
+
261
+ // Content marker every materialized stub carries so re-runs are idempotent and
262
+ // operator-edited stubs are never clobbered.
263
+ const STUB_MARKER = "> **Thin local stub.**";
264
+
265
+ /**
266
+ * Copy each `templates/runbooks/*.md` (except the index README) into the
267
+ * consumer's `docs/runbooks/`, but only when the destination is ABSENT. An
268
+ * existing destination is left untouched — whether it is an already-adopted
269
+ * stub or a local copy the operator must reconcile by hand (we surface the
270
+ * latter as a `localCopy` warning rather than silently overwriting their work).
271
+ */
272
+ function materializeRunbooks() {
273
+ const created = [];
274
+ const skipped = [];
275
+ const localCopies = [];
276
+ if (!existsSync(runbookTemplatesDir)) {
277
+ fail(`runbook templates not found at ${runbookTemplatesDir}.`);
278
+ }
279
+ const destDir = join(opts.consumer, "docs", "runbooks");
280
+ for (const entry of readdirSync(runbookTemplatesDir)) {
281
+ if (!entry.endsWith(".md")) continue;
282
+ if (entry.toLowerCase() === "readme.md") continue; // index, not a stub
283
+ const src = join(runbookTemplatesDir, entry);
284
+ const dest = join(destDir, entry);
285
+ if (existsSync(dest)) {
286
+ const body = readFileSync(dest, "utf8");
287
+ if (body.includes(STUB_MARKER)) {
288
+ skipped.push(rel(dest)); // already a reference stub — idempotent no-op
289
+ } else {
290
+ localCopies.push(rel(dest)); // full local copy — operator must reconcile
291
+ }
292
+ continue;
293
+ }
294
+ if (!opts.dryRun) {
295
+ mkdirSync(destDir, { recursive: true });
296
+ writeFileSync(dest, readFileSync(src, "utf8"));
297
+ }
298
+ created.push(rel(dest));
299
+ }
300
+ return { created, skipped, localCopies };
301
+ }
302
+
303
+ // ---------------------------------------------------------------------------
304
+ // 4. Reconcile renovate / tsconfig `extends`
305
+ // ---------------------------------------------------------------------------
306
+
307
+ const RENOVATE_PRESET = `github>${"dsj1984/mandrel-platform"}`;
308
+ const TSCONFIG_BASE = "mandrel-platform/tsconfig.base.json";
309
+
310
+ /** Parse JSON tolerating `//` and block comments (jsonc), preserving nothing
311
+ * but the parsed value — we re-serialize with 2-space indent. */
312
+ function parseJsonc(text) {
313
+ const stripped = text
314
+ .replace(/\/\*[\s\S]*?\*\//g, "")
315
+ .replace(/(^|[^:])\/\/.*$/gm, "$1");
316
+ return JSON.parse(stripped);
317
+ }
318
+
319
+ function reconcileRenovate() {
320
+ // Renovate config can live at a few canonical paths.
321
+ const candidates = [
322
+ "renovate.json",
323
+ "renovate.json5",
324
+ ".github/renovate.json",
325
+ ".renovaterc.json",
326
+ ].map((p) => join(opts.consumer, p));
327
+ const path = candidates.find((p) => existsSync(p));
328
+ if (!path) return { action: "absent", file: null };
329
+ let cfg;
330
+ try {
331
+ cfg = parseJsonc(readFileSync(path, "utf8"));
332
+ } catch (err) {
333
+ fail(`could not parse Renovate config at ${rel(path)}: ${err.message}`);
334
+ }
335
+ const extendsArr = Array.isArray(cfg.extends) ? [...cfg.extends] : [];
336
+ if (extendsArr.includes(RENOVATE_PRESET)) {
337
+ return { action: "unchanged", file: rel(path) };
338
+ }
339
+ // Prepend the SSOT preset so consumer overrides (later entries) still win.
340
+ cfg.extends = [RENOVATE_PRESET, ...extendsArr];
341
+ if (!opts.dryRun) writeFileSync(path, `${JSON.stringify(cfg, null, 2)}\n`);
342
+ return { action: "reconciled", file: rel(path), added: RENOVATE_PRESET };
343
+ }
344
+
345
+ function reconcileTsconfig() {
346
+ const path = join(opts.consumer, "tsconfig.json");
347
+ if (!existsSync(path)) return { action: "absent", file: null };
348
+ let cfg;
349
+ try {
350
+ cfg = parseJsonc(readFileSync(path, "utf8"));
351
+ } catch (err) {
352
+ fail(`could not parse tsconfig at ${rel(path)}: ${err.message}`);
353
+ }
354
+ // `extends` may be a string or (TS 5.0+) an array.
355
+ const current = cfg.extends;
356
+ const hasBase = Array.isArray(current)
357
+ ? current.includes(TSCONFIG_BASE)
358
+ : current === TSCONFIG_BASE;
359
+ if (hasBase) return { action: "unchanged", file: rel(path) };
360
+ if (current === undefined) {
361
+ cfg.extends = TSCONFIG_BASE;
362
+ } else if (Array.isArray(current)) {
363
+ // Base first so the consumer's own extends override it.
364
+ cfg.extends = [TSCONFIG_BASE, ...current];
365
+ } else {
366
+ cfg.extends = [TSCONFIG_BASE, current];
367
+ }
368
+ if (!opts.dryRun) writeFileSync(path, `${JSON.stringify(cfg, null, 2)}\n`);
369
+ return { action: "reconciled", file: rel(path), added: TSCONFIG_BASE };
370
+ }
371
+
372
+ // ---------------------------------------------------------------------------
373
+ // Helpers
374
+ // ---------------------------------------------------------------------------
375
+
376
+ function rel(p) {
377
+ return relative(opts.consumer, p) || p;
378
+ }
379
+
380
+ // ---------------------------------------------------------------------------
381
+ // Main
382
+ // ---------------------------------------------------------------------------
383
+
384
+ function main() {
385
+ if (!existsSync(opts.consumer)) {
386
+ fail(`consumer dir not found: ${opts.consumer}`);
387
+ }
388
+
389
+ const targetSha = resolveSha();
390
+ log(`▶ platform-sync — ref '${opts.ref}' → ${targetSha.slice(0, 7)} (${opts.repo})`);
391
+ if (opts.dryRun) log(" (dry-run: no files will be written)");
392
+
393
+ const pins = pinWorkflows(targetSha);
394
+ const runbooks = materializeRunbooks();
395
+ const renovate = reconcileRenovate();
396
+ const tsconfig = reconcileTsconfig();
397
+
398
+ const changed =
399
+ pins.length > 0 ||
400
+ runbooks.created.length > 0 ||
401
+ renovate.action === "reconciled" ||
402
+ tsconfig.action === "reconciled";
403
+
404
+ // Human-readable summary
405
+ log("");
406
+ log(` pins: ${pins.length} workflow pin(s) ${opts.dryRun ? "would be " : ""}updated`);
407
+ for (const c of pins) log(` - ${c.file}: ${c.from} → ${c.to}`);
408
+ log(
409
+ ` runbooks: ${runbooks.created.length} stub(s) ${
410
+ opts.dryRun ? "would be " : ""
411
+ }materialized, ${runbooks.skipped.length} already present`
412
+ );
413
+ for (const f of runbooks.created) log(` + ${f}`);
414
+ for (const f of runbooks.localCopies) {
415
+ log(` ⚠ ${f}: full local copy detected — reconcile to a reference stub by hand (§2.2)`);
416
+ }
417
+ log(` renovate: ${renovate.action}${renovate.file ? ` (${renovate.file})` : ""}`);
418
+ log(` tsconfig: ${tsconfig.action}${tsconfig.file ? ` (${tsconfig.file})` : ""}`);
419
+ log("");
420
+ log(
421
+ changed
422
+ ? opts.dryRun
423
+ ? "✅ dry-run: changes planned (see above). Re-run without --dry-run to apply."
424
+ : "✅ sync applied."
425
+ : "✅ already in sync — no changes."
426
+ );
427
+
428
+ if (opts.json) {
429
+ process.stdout.write(
430
+ `${JSON.stringify(
431
+ {
432
+ ref: opts.ref,
433
+ sha: targetSha,
434
+ repo: opts.repo,
435
+ consumer: opts.consumer,
436
+ dryRun: opts.dryRun,
437
+ changed,
438
+ pins,
439
+ runbooks,
440
+ renovate,
441
+ tsconfig,
442
+ },
443
+ null,
444
+ 2
445
+ )}\n`
446
+ );
447
+ }
448
+ }
449
+
450
+ main();
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * platform-sync.test.mjs — node:test suite for the MP-14 adoption CLI.
4
+ *
5
+ * Exercises the four acceptance behaviours against a synthetic consumer dir
6
+ * built under a temp root, in offline mode (`--sha` skips the network):
7
+ *
8
+ * 1. workflow SHA pinning (first-party rewritten, external untouched, the
9
+ * `# <ref>` annotation refreshed),
10
+ * 2. runbook reference-stub materialization (link-only, local-copy warning),
11
+ * 3. renovate / tsconfig `extends` reconciliation (SSOT prepended, consumer
12
+ * overrides preserved),
13
+ * 4. idempotency + `--dry-run` non-mutation.
14
+ *
15
+ * Run: node scripts/platform-sync.test.mjs (or `node --test scripts/`)
16
+ */
17
+
18
+ import assert from "node:assert/strict";
19
+ import { execFileSync } from "node:child_process";
20
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
21
+ import { tmpdir } from "node:os";
22
+ import { dirname, join } from "node:path";
23
+ import { fileURLToPath } from "node:url";
24
+ import { afterEach, beforeEach, test } from "node:test";
25
+
26
+ const __dirname = dirname(fileURLToPath(import.meta.url));
27
+ const CLI = join(__dirname, "platform-sync.mjs");
28
+ const SHA = "a".repeat(40);
29
+ const REF = "mandrel-platform-v9.9.9";
30
+
31
+ let consumer;
32
+
33
+ function run(extraArgs) {
34
+ return execFileSync(
35
+ "node",
36
+ [CLI, "--ref", REF, "--sha", SHA, "--consumer", consumer, "--json", ...extraArgs],
37
+ { encoding: "utf8" }
38
+ );
39
+ }
40
+
41
+ function seedConsumer() {
42
+ mkdirSync(join(consumer, ".github", "workflows"), { recursive: true });
43
+ writeFileSync(
44
+ join(consumer, ".github", "workflows", "ci.yml"),
45
+ [
46
+ "name: CI",
47
+ "jobs:",
48
+ " q:",
49
+ " steps:",
50
+ ` - uses: dsj1984/mandrel-platform/.github/actions/setup-toolchain@${"1".repeat(40)} # stale`,
51
+ ` - uses: actions/checkout@${"2".repeat(40)} # external`,
52
+ "",
53
+ ].join("\n")
54
+ );
55
+ writeFileSync(join(consumer, "renovate.json"), JSON.stringify({ extends: ["config:base"] }, null, 2));
56
+ writeFileSync(
57
+ join(consumer, "tsconfig.json"),
58
+ JSON.stringify({ compilerOptions: { outDir: "dist" } }, null, 2)
59
+ );
60
+ }
61
+
62
+ beforeEach(() => {
63
+ consumer = mkdtempSync(join(tmpdir(), "platform-sync-test-"));
64
+ seedConsumer();
65
+ });
66
+
67
+ afterEach(() => {
68
+ rmSync(consumer, { recursive: true, force: true });
69
+ });
70
+
71
+ test("--dry-run does not mutate any file", () => {
72
+ const before = readFileSync(join(consumer, ".github", "workflows", "ci.yml"), "utf8");
73
+ const out = JSON.parse(run(["--dry-run"]));
74
+ assert.equal(out.dryRun, true);
75
+ assert.equal(out.changed, true);
76
+ const after = readFileSync(join(consumer, ".github", "workflows", "ci.yml"), "utf8");
77
+ assert.equal(after, before, "ci.yml must be untouched in dry-run");
78
+ assert.ok(!existsSync(join(consumer, "docs", "runbooks", "observability.md")));
79
+ });
80
+
81
+ test("apply pins first-party SHAs, leaves external actions untouched", () => {
82
+ const out = JSON.parse(run([]));
83
+ assert.equal(out.changed, true);
84
+ assert.equal(out.pins.length, 1);
85
+ const ci = readFileSync(join(consumer, ".github", "workflows", "ci.yml"), "utf8");
86
+ assert.ok(ci.includes(`setup-toolchain@${SHA} # ${REF}`), "first-party pin rewritten + annotated");
87
+ assert.ok(ci.includes(`actions/checkout@${"2".repeat(40)} # external`), "external action untouched");
88
+ });
89
+
90
+ test("apply materializes runbook reference stubs (link, don't copy)", () => {
91
+ run([]);
92
+ const stub = join(consumer, "docs", "runbooks", "deploy-promotion.md");
93
+ assert.ok(existsSync(stub));
94
+ const body = readFileSync(stub, "utf8");
95
+ assert.ok(body.includes("Thin local stub"), "materialized stub is a reference, not a copy");
96
+ assert.ok(
97
+ body.includes("github.com/dsj1984/mandrel-platform"),
98
+ "stub links back to the canonical runbook"
99
+ );
100
+ });
101
+
102
+ test("apply reconciles renovate + tsconfig extends, preserving consumer entries", () => {
103
+ run([]);
104
+ const renovate = JSON.parse(readFileSync(join(consumer, "renovate.json"), "utf8"));
105
+ assert.deepEqual(renovate.extends, ["github>dsj1984/mandrel-platform", "config:base"]);
106
+ const tsconfig = JSON.parse(readFileSync(join(consumer, "tsconfig.json"), "utf8"));
107
+ assert.equal(tsconfig.extends, "mandrel-platform/tsconfig.base.json");
108
+ assert.equal(tsconfig.compilerOptions.outDir, "dist", "consumer overrides preserved");
109
+ });
110
+
111
+ test("re-running is idempotent (changed: false on the second pass)", () => {
112
+ run([]);
113
+ const second = JSON.parse(run([]));
114
+ assert.equal(second.changed, false, "second sync reports no changes");
115
+ assert.equal(second.pins.length, 0);
116
+ });
117
+
118
+ test("a full local-copy runbook is flagged, not overwritten", () => {
119
+ const dest = join(consumer, "docs", "runbooks");
120
+ mkdirSync(dest, { recursive: true });
121
+ const localCopy = "# Local copy, no stub marker\n\nfull process re-authored here\n";
122
+ writeFileSync(join(dest, "observability.md"), localCopy);
123
+ const out = JSON.parse(run([]));
124
+ assert.ok(
125
+ out.runbooks.localCopies.some((f) => f.endsWith("observability.md")),
126
+ "local copy surfaced as a warning"
127
+ );
128
+ assert.equal(
129
+ readFileSync(join(dest, "observability.md"), "utf8"),
130
+ localCopy,
131
+ "operator's local copy is never clobbered"
132
+ );
133
+ });
134
+
135
+ test("an existing reference stub is skipped idempotently", () => {
136
+ run([]); // materialize stubs
137
+ const out = JSON.parse(run([])); // second pass
138
+ assert.ok(out.runbooks.skipped.length >= 8, "already-present stubs are skipped, not re-created");
139
+ assert.equal(out.runbooks.created.length, 0);
140
+ });
@@ -15,6 +15,16 @@ Each stub:
15
15
 
16
16
  ## How to adopt (downstream repo)
17
17
 
18
+ **Automated path (recommended).** The adoption CLI materializes every stub for
19
+ you (link-only, never clobbering a stub you've already filled in) as part of a
20
+ full sync — see [`scripts/platform-sync.mjs`](../../README.md#adoption-cli-platform-sync):
21
+
22
+ ```bash
23
+ node node_modules/mandrel-platform/scripts/platform-sync.mjs --ref mandrel-platform-v0.10.0
24
+ ```
25
+
26
+ **Manual path.**
27
+
18
28
  1. Copy the stub(s) you need into your project's `docs/runbooks/`:
19
29
  ```bash
20
30
  cp node_modules/mandrel-platform/templates/runbooks/deploy-promotion.md \