mandrel-platform 0.2.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel-platform",
3
- "version": "0.2.5",
3
+ "version": "0.3.0",
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": {
@@ -0,0 +1,342 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-workflow-portability.mjs
4
+ *
5
+ * Cross-repo portability lint for reusable workflows and composite actions.
6
+ *
7
+ * GitHub validates a reusable workflow's `workflow_call` interface (and a
8
+ * composite action's interface) only when it is *called from another repo* —
9
+ * never when its own repo runs CI on it. That blind spot has shipped four
10
+ * consecutive consumer-facing breakages from this repo (see the #24 → #29 →
11
+ * #30 → #32 chain on the athportal Story #2006 worktree), each a silent
12
+ * "workflow file issue / 0 jobs started" that no in-repo check could catch.
13
+ *
14
+ * This lint closes the blind spot by statically asserting the two invariants
15
+ * that GitHub only enforces at cross-repo call time:
16
+ *
17
+ * 1. RELATIVE `uses:` PATHS (e.g. `uses: ./.github/actions/foo`) are
18
+ * PROHIBITED inside a reusable workflow. A cross-repo caller checks out
19
+ * ITS OWN repo, so `./` resolves to the wrong tree and validation fails
20
+ * with 0 jobs. Reusable workflows MUST reference first-party actions by
21
+ * absolute `owner/repo/path@ref` form. (Caused #30 / v0.2.3.)
22
+ *
23
+ * 2. `${{ }}` EXPRESSIONS in `workflow_call` input/secret `description:` or
24
+ * input `default:` fields are PROHIBITED. GitHub evaluates these during
25
+ * interface validation, where contexts like `runner.*` do not yet exist,
26
+ * so the call fails silently. The SAME footgun applies to composite
27
+ * `action.yml` input `default:` values — a composite default is never
28
+ * expression-evaluated, so `${{ }}` is passed through as a literal string.
29
+ * (Caused #32 / v0.2.4 and #30's setup-toolchain default regression.)
30
+ *
31
+ * What this lint deliberately does NOT flag: `${{ }}` in `runs.steps[].with`
32
+ * (e.g. `dest: ${{ inputs['pnpm-dest'] || format('{0}/pnpm', runner.temp) }}`)
33
+ * is a VALID runtime expression. The lint only inspects `description` and
34
+ * `default` leaves *inside input/secret-definition blocks*, so legitimate
35
+ * runtime expressions in step bodies are never touched.
36
+ *
37
+ * Usage:
38
+ * node scripts/check-workflow-portability.mjs
39
+ * node scripts/check-workflow-portability.mjs --workflows-dir .github/workflows
40
+ * node scripts/check-workflow-portability.mjs --actions-dir .github/actions
41
+ *
42
+ * Exit codes:
43
+ * 0 — every reusable workflow and composite action is cross-repo portable
44
+ * 1 — one or more portability violations detected (each named in stderr)
45
+ *
46
+ * Consumer adoption:
47
+ * Copy this script into your project's `scripts/` directory, then wire it
48
+ * into your CI alongside check-required-contexts.mjs:
49
+ *
50
+ * - name: Lint workflow portability
51
+ * run: node scripts/check-workflow-portability.mjs
52
+ *
53
+ * It is dependency-free (no YAML parser) so it copies cleanly into any repo.
54
+ */
55
+
56
+ import { readFileSync, readdirSync, statSync } from "node:fs";
57
+ import { resolve, join, relative, basename } from "node:path";
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Arg parsing
61
+ // ---------------------------------------------------------------------------
62
+
63
+ const args = process.argv.slice(2);
64
+ let workflowsDir = null;
65
+ let actionsDir = null;
66
+
67
+ for (let i = 0; i < args.length; i++) {
68
+ if ((args[i] === "--workflows-dir" || args[i] === "-w") && args[i + 1]) {
69
+ workflowsDir = args[++i];
70
+ } else if ((args[i] === "--actions-dir" || args[i] === "-a") && args[i + 1]) {
71
+ actionsDir = args[++i];
72
+ } else if (args[i] === "--help" || args[i] === "-h") {
73
+ process.stdout.write(
74
+ "Usage: node scripts/check-workflow-portability.mjs [--workflows-dir <dir>] [--actions-dir <dir>]\n"
75
+ );
76
+ process.exit(0);
77
+ }
78
+ }
79
+
80
+ const repoRoot = process.cwd();
81
+ const resolvedWorkflowsDir = workflowsDir
82
+ ? resolve(workflowsDir)
83
+ : resolve(repoRoot, ".github/workflows");
84
+ const resolvedActionsDir = actionsDir
85
+ ? resolve(actionsDir)
86
+ : resolve(repoRoot, ".github/actions");
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Minimal indentation-aware YAML walk (dependency-free)
90
+ //
91
+ // We do NOT need a full YAML parser — only the path-qualified `description`
92
+ // and `default` scalar leaves (with their folded multi-line values) and a
93
+ // flat line scan for `uses:`. The walk yields one record per mapping key:
94
+ //
95
+ // { path: [...ancestorKeys, key], value, lineNo }
96
+ //
97
+ // where `value` is the inline scalar, the gathered block-scalar body, or ""
98
+ // for a parent mapping. Quotes are stripped from keys so `"on":` === `on`.
99
+ // ---------------------------------------------------------------------------
100
+
101
+ function walkYaml(content) {
102
+ const lines = content.split("\n");
103
+ const stack = []; // [{ indent, key }]
104
+ const records = [];
105
+
106
+ let i = 0;
107
+ while (i < lines.length) {
108
+ const raw = lines[i];
109
+
110
+ // Blank and comment-only lines carry no structure.
111
+ if (/^\s*$/.test(raw) || /^\s*#/.test(raw)) {
112
+ i++;
113
+ continue;
114
+ }
115
+
116
+ const indent = raw.match(/^(\s*)/)[1].length;
117
+ const trimmed = raw.slice(indent);
118
+
119
+ // Match a mapping key, optionally introduced by a sequence dash.
120
+ const m = trimmed.match(/^(-\s+)?(["']?[A-Za-z0-9_.\-]+["']?):(\s*)(.*)$/);
121
+ if (!m) {
122
+ i++;
123
+ continue;
124
+ }
125
+
126
+ const key = m[2].replace(/^["']|["']$/g, "");
127
+ const after = m[4];
128
+
129
+ // Unwind to the enclosing mapping for this indentation.
130
+ while (stack.length && stack[stack.length - 1].indent >= indent) stack.pop();
131
+ const path = stack.map((s) => s.key).concat(key);
132
+
133
+ // Block scalar (`>`, `|`, with optional chomp/indent indicators): gather
134
+ // the deeper-indented body so `${{` inside a folded description is seen.
135
+ if (/^[|>][+-]?\d*\s*$/.test(after)) {
136
+ let value = "";
137
+ let j = i + 1;
138
+ while (j < lines.length) {
139
+ const cont = lines[j];
140
+ if (/^\s*$/.test(cont)) {
141
+ value += "\n";
142
+ j++;
143
+ continue;
144
+ }
145
+ const contIndent = cont.match(/^(\s*)/)[1].length;
146
+ if (contIndent <= indent) break;
147
+ value += cont.trim() + "\n";
148
+ j++;
149
+ }
150
+ records.push({ path, value, lineNo: i + 1 });
151
+ i = j;
152
+ continue;
153
+ }
154
+
155
+ if (after === "") {
156
+ // Parent mapping (or empty/sequence container): becomes context.
157
+ stack.push({ indent, key });
158
+ records.push({ path, value: "", lineNo: i + 1 });
159
+ i++;
160
+ continue;
161
+ }
162
+
163
+ // Inline scalar leaf.
164
+ records.push({ path, value: after, lineNo: i + 1 });
165
+ i++;
166
+ }
167
+
168
+ return records;
169
+ }
170
+
171
+ const EXPR = /\$\{\{/;
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // File discovery
175
+ // ---------------------------------------------------------------------------
176
+
177
+ function listWorkflowFiles(dir) {
178
+ let entries;
179
+ try {
180
+ entries = readdirSync(dir);
181
+ } catch {
182
+ return [];
183
+ }
184
+ return entries
185
+ .filter((f) => f.endsWith(".yml") || f.endsWith(".yaml"))
186
+ .map((f) => join(dir, f));
187
+ }
188
+
189
+ function listActionFiles(dir) {
190
+ const found = [];
191
+ let entries;
192
+ try {
193
+ entries = readdirSync(dir);
194
+ } catch {
195
+ return found;
196
+ }
197
+ for (const entry of entries) {
198
+ const full = join(dir, entry);
199
+ let st;
200
+ try {
201
+ st = statSync(full);
202
+ } catch {
203
+ continue;
204
+ }
205
+ if (st.isDirectory()) {
206
+ found.push(...listActionFiles(full));
207
+ } else if (entry === "action.yml" || entry === "action.yaml") {
208
+ found.push(full);
209
+ }
210
+ }
211
+ return found;
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // Lint rules
216
+ // ---------------------------------------------------------------------------
217
+
218
+ /** Collect violations for a single file. Returns an array of {line, message}. */
219
+ function lintFile(filePath) {
220
+ const violations = [];
221
+ let content;
222
+ try {
223
+ content = readFileSync(filePath, "utf8");
224
+ } catch (err) {
225
+ return [{ line: 0, message: `cannot read file: ${err.message}` }];
226
+ }
227
+
228
+ const records = walkYaml(content);
229
+ const isWorkflow = filePath.startsWith(resolvedWorkflowsDir);
230
+ const isAction =
231
+ basename(filePath) === "action.yml" || basename(filePath) === "action.yaml";
232
+
233
+ if (isWorkflow) {
234
+ const isReusable = records.some(
235
+ (r) => r.path.join(".") === "on.workflow_call" || r.path.join(".").startsWith("on.workflow_call.")
236
+ );
237
+ if (!isReusable) return violations; // ordinary workflow — nothing to enforce
238
+
239
+ // Rule 1: no relative `uses:` anywhere in a reusable workflow.
240
+ content.split("\n").forEach((raw, idx) => {
241
+ if (/^\s*uses:\s*['"]?\.\//.test(raw)) {
242
+ violations.push({
243
+ line: idx + 1,
244
+ message:
245
+ `relative \`uses: ./\` path in a reusable workflow — a cross-repo ` +
246
+ `caller resolves \`./\` against its own checkout. Use absolute ` +
247
+ `\`owner/repo/path@ref\` form.`,
248
+ });
249
+ }
250
+ });
251
+
252
+ // Rule 2: no `${{ }}` in workflow_call input/secret description or default.
253
+ for (const r of records) {
254
+ const p = r.path.join(".");
255
+ const isInputMeta =
256
+ /^on\.workflow_call\.inputs\.[^.]+\.(description|default)$/.test(p);
257
+ const isSecretMeta =
258
+ /^on\.workflow_call\.secrets\.[^.]+\.description$/.test(p);
259
+ if ((isInputMeta || isSecretMeta) && EXPR.test(r.value)) {
260
+ const field = r.path[r.path.length - 1];
261
+ const name = r.path[r.path.length - 2];
262
+ violations.push({
263
+ line: r.lineNo,
264
+ message:
265
+ `\`\${{ }}\` expression in workflow_call \`${name}.${field}\` — ` +
266
+ `GitHub evaluates this during interface validation (where ` +
267
+ `runner.*/secrets.* do not exist), failing every cross-repo call. ` +
268
+ `Write it as plain text.`,
269
+ });
270
+ }
271
+ }
272
+ }
273
+
274
+ if (isAction) {
275
+ // Rule 3/4: no `${{ }}` in composite-action input default or description.
276
+ // A composite default is never expression-evaluated (the literal string is
277
+ // passed through); a description expression is misleading and needless.
278
+ for (const r of records) {
279
+ const p = r.path.join(".");
280
+ if (/^inputs\.[^.]+\.(default|description)$/.test(p) && EXPR.test(r.value)) {
281
+ const field = r.path[r.path.length - 1];
282
+ const name = r.path[r.path.length - 2];
283
+ violations.push({
284
+ line: r.lineNo,
285
+ message:
286
+ `\`\${{ }}\` expression in action input \`${name}.${field}\` — ` +
287
+ `composite ${field}s are not expression-evaluated; the literal ` +
288
+ `string is used as-is. Move runtime expressions into ` +
289
+ `\`runs.steps[].with\`, or write the ${field} as plain text.`,
290
+ });
291
+ }
292
+ }
293
+ }
294
+
295
+ return violations;
296
+ }
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // Run
300
+ // ---------------------------------------------------------------------------
301
+
302
+ const workflowFiles = listWorkflowFiles(resolvedWorkflowsDir);
303
+ const actionFiles = listActionFiles(resolvedActionsDir);
304
+ const allFiles = [...workflowFiles, ...actionFiles];
305
+
306
+ process.stdout.write(
307
+ `[check-workflow-portability] Workflows: ${relative(repoRoot, resolvedWorkflowsDir)}/ (${workflowFiles.length})\n` +
308
+ `[check-workflow-portability] Actions : ${relative(repoRoot, resolvedActionsDir)}/ (${actionFiles.length})\n`
309
+ );
310
+
311
+ if (allFiles.length === 0) {
312
+ process.stdout.write(
313
+ `[check-workflow-portability] No workflow or action files found — nothing to lint.\n`
314
+ );
315
+ process.exit(0);
316
+ }
317
+
318
+ let total = 0;
319
+ for (const file of allFiles) {
320
+ const violations = lintFile(file);
321
+ if (violations.length === 0) continue;
322
+ total += violations.length;
323
+ const rel = relative(repoRoot, file);
324
+ process.stderr.write(`\n[check-workflow-portability] ❌ ${rel}\n`);
325
+ for (const v of violations) {
326
+ process.stderr.write(` ${rel}:${v.line} — ${v.message}\n`);
327
+ }
328
+ }
329
+
330
+ if (total > 0) {
331
+ process.stderr.write(
332
+ `\n[check-workflow-portability] ${total} portability violation${total === 1 ? "" : "s"} detected.\n` +
333
+ ` These fail only when a CONSUMER repo calls the workflow/action, which is\n` +
334
+ ` exactly why in-repo CI never caught them before. Fix each above.\n\n`
335
+ );
336
+ process.exit(1);
337
+ }
338
+
339
+ process.stdout.write(
340
+ `[check-workflow-portability] ✅ All reusable workflows and composite actions are cross-repo portable.\n`
341
+ );
342
+ process.exit(0);