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 +87 -0
- package/default.json +7 -0
- package/package.json +3 -2
- package/scripts/check-pin-drift.mjs +546 -0
- package/scripts/pin-drift-consumers.json +18 -0
- package/scripts/platform-sync.mjs +450 -0
- package/scripts/platform-sync.test.mjs +140 -0
- package/templates/runbooks/README.md +10 -0
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.
|
|
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 --
|
|
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 \
|