mandrel-platform 0.3.0 → 0.3.1

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.3.0",
3
+ "version": "0.3.1",
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": {
@@ -4,15 +4,15 @@
4
4
  *
5
5
  * Cross-repo portability lint for reusable workflows and composite actions.
6
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.
7
+ * GitHub validates a reusable workflow's / composite action's interface only
8
+ * when it is *called from another repo* — never when its own repo runs CI on
9
+ * it. That blind spot has shipped five consecutive consumer-facing breakages
10
+ * from this repo (the #24 → #29 → #30 → #32 → #35 chain on the athportal /
11
+ * domio Story worktrees), each a silent "workflow file issue / 0 jobs" or a
12
+ * "Set up job" load failure that no in-repo check could catch.
13
13
  *
14
- * This lint closes the blind spot by statically asserting the two invariants
15
- * that GitHub only enforces at cross-repo call time:
14
+ * This lint closes the blind spot by statically asserting the invariants that
15
+ * GitHub only enforces at cross-repo call / action-load time:
16
16
  *
17
17
  * 1. RELATIVE `uses:` PATHS (e.g. `uses: ./.github/actions/foo`) are
18
18
  * PROHIBITED inside a reusable workflow. A cross-repo caller checks out
@@ -24,9 +24,20 @@
24
24
  * input `default:` fields are PROHIBITED. GitHub evaluates these during
25
25
  * interface validation, where contexts like `runner.*` do not yet exist,
26
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.)
27
+ * `action.yml` input `default:` and `description:` values — a composite
28
+ * default is never expression-evaluated (passed through as a literal),
29
+ * and a `${{ }}` in a composite description throws "Unrecognized
30
+ * named-value" at action-load time ("Set up job"). (Caused #32 / #35.)
31
+ *
32
+ * 3. INTERNAL SHA PINS must point to a CLEAN manifest. A reusable workflow
33
+ * that pins a first-party action by `owner/repo/path@<sha>` is validated
34
+ * here against the manifest AT THAT SHA — not just the working-tree copy.
35
+ * A pin left lagging on a pre-fix commit re-introduces a footgun the
36
+ * working tree already fixed: this is exactly #35, where pr-quality.yml
37
+ * kept pinning setup-toolchain@<pre-fix-sha> after the description was
38
+ * cleaned in the working tree, so every consumer job died at "Set up
39
+ * job". Requires git history (run CI checkout with fetch-depth: 0); the
40
+ * check degrades to a skipped NOTE when the pinned blob is unreachable.
30
41
  *
31
42
  * What this lint deliberately does NOT flag: `${{ }}` in `runs.steps[].with`
32
43
  * (e.g. `dest: ${{ inputs['pnpm-dest'] || format('{0}/pnpm', runner.temp) }}`)
@@ -38,6 +49,7 @@
38
49
  * node scripts/check-workflow-portability.mjs
39
50
  * node scripts/check-workflow-portability.mjs --workflows-dir .github/workflows
40
51
  * node scripts/check-workflow-portability.mjs --actions-dir .github/actions
52
+ * node scripts/check-workflow-portability.mjs --no-pin-check # skip Rule 3
41
53
  *
42
54
  * Exit codes:
43
55
  * 0 — every reusable workflow and composite action is cross-repo portable
@@ -45,15 +57,19 @@
45
57
  *
46
58
  * Consumer adoption:
47
59
  * Copy this script into your project's `scripts/` directory, then wire it
48
- * into your CI alongside check-required-contexts.mjs:
60
+ * into your CI alongside check-required-contexts.mjs. Use fetch-depth: 0 on
61
+ * the checkout so Rule 3 can resolve pinned blobs:
49
62
  *
63
+ * - uses: actions/checkout@<sha>
64
+ * with: { fetch-depth: 0 }
50
65
  * - name: Lint workflow portability
51
66
  * run: node scripts/check-workflow-portability.mjs
52
67
  *
53
68
  * It is dependency-free (no YAML parser) so it copies cleanly into any repo.
54
69
  */
55
70
 
56
- import { readFileSync, readdirSync, statSync } from "node:fs";
71
+ import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
72
+ import { execFileSync } from "node:child_process";
57
73
  import { resolve, join, relative, basename } from "node:path";
58
74
 
59
75
  // ---------------------------------------------------------------------------
@@ -63,15 +79,18 @@ import { resolve, join, relative, basename } from "node:path";
63
79
  const args = process.argv.slice(2);
64
80
  let workflowsDir = null;
65
81
  let actionsDir = null;
82
+ let pinCheck = true;
66
83
 
67
84
  for (let i = 0; i < args.length; i++) {
68
85
  if ((args[i] === "--workflows-dir" || args[i] === "-w") && args[i + 1]) {
69
86
  workflowsDir = args[++i];
70
87
  } else if ((args[i] === "--actions-dir" || args[i] === "-a") && args[i + 1]) {
71
88
  actionsDir = args[++i];
89
+ } else if (args[i] === "--no-pin-check") {
90
+ pinCheck = false;
72
91
  } else if (args[i] === "--help" || args[i] === "-h") {
73
92
  process.stdout.write(
74
- "Usage: node scripts/check-workflow-portability.mjs [--workflows-dir <dir>] [--actions-dir <dir>]\n"
93
+ "Usage: node scripts/check-workflow-portability.mjs [--workflows-dir <dir>] [--actions-dir <dir>] [--no-pin-check]\n"
75
94
  );
76
95
  process.exit(0);
77
96
  }
@@ -170,6 +189,159 @@ function walkYaml(content) {
170
189
 
171
190
  const EXPR = /\$\{\{/;
172
191
 
192
+ // ---------------------------------------------------------------------------
193
+ // Content checks (reused for both working-tree files and pinned blobs)
194
+ // ---------------------------------------------------------------------------
195
+
196
+ /** Reusable-workflow checks (Rules 1 & 2). Returns [{line, message}]. */
197
+ function checkWorkflowContent(content) {
198
+ const violations = [];
199
+ const records = walkYaml(content);
200
+
201
+ const isReusable = records.some(
202
+ (r) => r.path.join(".") === "on.workflow_call" || r.path.join(".").startsWith("on.workflow_call.")
203
+ );
204
+ if (!isReusable) return violations;
205
+
206
+ // Rule 1: no relative `uses:` anywhere in a reusable workflow.
207
+ content.split("\n").forEach((raw, idx) => {
208
+ if (/^\s*uses:\s*['"]?\.\//.test(raw)) {
209
+ violations.push({
210
+ line: idx + 1,
211
+ message:
212
+ `relative \`uses: ./\` path in a reusable workflow — a cross-repo ` +
213
+ `caller resolves \`./\` against its own checkout. Use absolute ` +
214
+ `\`owner/repo/path@ref\` form.`,
215
+ });
216
+ }
217
+ });
218
+
219
+ // Rule 2: no `${{ }}` in workflow_call input/secret description or default.
220
+ for (const r of records) {
221
+ const p = r.path.join(".");
222
+ const isInputMeta = /^on\.workflow_call\.inputs\.[^.]+\.(description|default)$/.test(p);
223
+ const isSecretMeta = /^on\.workflow_call\.secrets\.[^.]+\.description$/.test(p);
224
+ if ((isInputMeta || isSecretMeta) && EXPR.test(r.value)) {
225
+ const field = r.path[r.path.length - 1];
226
+ const name = r.path[r.path.length - 2];
227
+ violations.push({
228
+ line: r.lineNo,
229
+ message:
230
+ `\`\${{ }}\` expression in workflow_call \`${name}.${field}\` — ` +
231
+ `GitHub evaluates this during interface validation (where ` +
232
+ `runner.*/secrets.* do not exist), failing every cross-repo call. ` +
233
+ `Write it as plain text.`,
234
+ });
235
+ }
236
+ }
237
+
238
+ return violations;
239
+ }
240
+
241
+ /** Composite-action checks (Rules 3/4 of the original; manifest cleanliness). */
242
+ function checkActionContent(content) {
243
+ const violations = [];
244
+ const records = walkYaml(content);
245
+
246
+ for (const r of records) {
247
+ const p = r.path.join(".");
248
+ if (/^inputs\.[^.]+\.(default|description)$/.test(p) && EXPR.test(r.value)) {
249
+ const field = r.path[r.path.length - 1];
250
+ const name = r.path[r.path.length - 2];
251
+ violations.push({
252
+ line: r.lineNo,
253
+ message:
254
+ `\`\${{ }}\` expression in action input \`${name}.${field}\` — ` +
255
+ `composite ${field}s are not expression-evaluated; \`${field}\` ` +
256
+ `throws "Unrecognized named-value" at action-load time. Move ` +
257
+ `runtime expressions into \`runs.steps[].with\`, or write it as ` +
258
+ `plain text.`,
259
+ });
260
+ }
261
+ }
262
+
263
+ return violations;
264
+ }
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // Internal SHA-pin guard (Rule 3) — validate the PINNED manifest, not just
268
+ // the working tree. Catches a self-reference that lags a fix (e.g. #35).
269
+ // ---------------------------------------------------------------------------
270
+
271
+ let gitAvailable = null;
272
+ function isGitRepo() {
273
+ if (gitAvailable !== null) return gitAvailable;
274
+ try {
275
+ execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
276
+ cwd: repoRoot,
277
+ stdio: ["ignore", "pipe", "ignore"],
278
+ });
279
+ gitAvailable = true;
280
+ } catch {
281
+ gitAvailable = false;
282
+ }
283
+ return gitAvailable;
284
+ }
285
+
286
+ /** `git show <sha>:<path>` → file content, or null if unreachable. */
287
+ function gitShow(sha, path) {
288
+ try {
289
+ return execFileSync("git", ["show", `${sha}:${path}`], {
290
+ cwd: repoRoot,
291
+ encoding: "utf8",
292
+ stdio: ["ignore", "pipe", "ignore"],
293
+ maxBuffer: 8 * 1024 * 1024,
294
+ });
295
+ } catch {
296
+ return null;
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Collect internal (same-repo, full-SHA-pinned) `uses:` references from a
302
+ * workflow/action file. "Internal" is detected structurally: the sub-path
303
+ * after `owner/repo/` exists in the local working tree. External refs
304
+ * (actions/checkout, pnpm/action-setup, …) resolve to no local path and are
305
+ * skipped — so the heuristic needs no knowledge of this repo's own slug.
306
+ */
307
+ function collectInternalPins(content) {
308
+ const pins = [];
309
+ content.split("\n").forEach((raw, idx) => {
310
+ const m = raw.match(
311
+ /uses:\s*['"]?[\w.-]+\/[\w.-]+\/([^@\s'"]+)@([0-9a-fA-F]{40})/
312
+ );
313
+ if (!m) return;
314
+ const subpath = m[1];
315
+ const sha = m[2];
316
+ const local = join(repoRoot, subpath);
317
+ if (!existsSync(local)) return; // external ref → skip
318
+ pins.push({ subpath, sha, line: idx + 1 });
319
+ });
320
+ return pins;
321
+ }
322
+
323
+ /** Resolve a pinned ref's manifest path + kind ('action' | 'workflow'). */
324
+ function resolvePinnedManifest(subpath) {
325
+ const local = join(repoRoot, subpath);
326
+ let st;
327
+ try {
328
+ st = statSync(local);
329
+ } catch {
330
+ return null;
331
+ }
332
+ if (st.isDirectory()) {
333
+ for (const name of ["action.yml", "action.yaml"]) {
334
+ if (existsSync(join(local, name))) return { path: `${subpath}/${name}`, kind: "action" };
335
+ }
336
+ return null;
337
+ }
338
+ if (/\.ya?ml$/.test(subpath)) {
339
+ const kind = basename(subpath).startsWith("action.") ? "action" : "workflow";
340
+ return { path: subpath, kind };
341
+ }
342
+ return null;
343
+ }
344
+
173
345
  // ---------------------------------------------------------------------------
174
346
  // File discovery
175
347
  // ---------------------------------------------------------------------------
@@ -212,10 +384,11 @@ function listActionFiles(dir) {
212
384
  }
213
385
 
214
386
  // ---------------------------------------------------------------------------
215
- // Lint rules
387
+ // Per-file lint
216
388
  // ---------------------------------------------------------------------------
217
389
 
218
- /** Collect violations for a single file. Returns an array of {line, message}. */
390
+ const pinSkips = [];
391
+
219
392
  function lintFile(filePath) {
220
393
  const violations = [];
221
394
  let content;
@@ -225,68 +398,38 @@ function lintFile(filePath) {
225
398
  return [{ line: 0, message: `cannot read file: ${err.message}` }];
226
399
  }
227
400
 
228
- const records = walkYaml(content);
229
401
  const isWorkflow = filePath.startsWith(resolvedWorkflowsDir);
230
402
  const isAction =
231
403
  basename(filePath) === "action.yml" || basename(filePath) === "action.yaml";
232
404
 
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
- });
405
+ // Rules 1 & 2 (workflows) and the manifest checks (actions) on the live file.
406
+ if (isWorkflow) violations.push(...checkWorkflowContent(content));
407
+ if (isAction) violations.push(...checkActionContent(content));
408
+
409
+ // Rule 3: validate internal SHA-pinned references against their pinned blob.
410
+ if (pinCheck && isGitRepo()) {
411
+ for (const pin of collectInternalPins(content)) {
412
+ const manifest = resolvePinnedManifest(pin.subpath);
413
+ if (!manifest) continue;
414
+ const pinned = gitShow(pin.sha, manifest.path);
415
+ if (pinned === null) {
416
+ pinSkips.push(
417
+ `${relative(repoRoot, filePath)}:${pin.line} pinned ${pin.subpath}@${pin.sha.slice(0, 7)} ` +
418
+ `(blob unreachable; run checkout with fetch-depth: 0 to enable Rule 3)`
419
+ );
420
+ continue;
270
421
  }
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];
422
+ const pinnedViolations =
423
+ manifest.kind === "action"
424
+ ? checkActionContent(pinned)
425
+ : checkWorkflowContent(pinned);
426
+ for (const v of pinnedViolations) {
283
427
  violations.push({
284
- line: r.lineNo,
428
+ line: pin.line,
285
429
  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.`,
430
+ `internal pin \`${pin.subpath}@${pin.sha.slice(0, 7)}\` points to a ` +
431
+ `manifest with a portability defect (${manifest.path}:${v.line}) ` +
432
+ `${v.message} Bump the pin to a commit whose manifest is clean.`,
290
433
  });
291
434
  }
292
435
  }
@@ -305,7 +448,8 @@ const allFiles = [...workflowFiles, ...actionFiles];
305
448
 
306
449
  process.stdout.write(
307
450
  `[check-workflow-portability] Workflows: ${relative(repoRoot, resolvedWorkflowsDir)}/ (${workflowFiles.length})\n` +
308
- `[check-workflow-portability] Actions : ${relative(repoRoot, resolvedActionsDir)}/ (${actionFiles.length})\n`
451
+ `[check-workflow-portability] Actions : ${relative(repoRoot, resolvedActionsDir)}/ (${actionFiles.length})\n` +
452
+ `[check-workflow-portability] Pin check: ${pinCheck ? (isGitRepo() ? "on" : "on (git unavailable — skipped)") : "off"}\n`
309
453
  );
310
454
 
311
455
  if (allFiles.length === 0) {
@@ -327,6 +471,13 @@ for (const file of allFiles) {
327
471
  }
328
472
  }
329
473
 
474
+ if (pinSkips.length > 0) {
475
+ process.stdout.write(
476
+ `\n[check-workflow-portability] ⚠️ ${pinSkips.length} internal pin(s) could not be verified (Rule 3 skipped):\n`
477
+ );
478
+ for (const s of pinSkips) process.stdout.write(` ${s}\n`);
479
+ }
480
+
330
481
  if (total > 0) {
331
482
  process.stderr.write(
332
483
  `\n[check-workflow-portability] ${total} portability violation${total === 1 ? "" : "s"} detected.\n` +