mandrel-platform 0.17.2 → 0.19.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.
Files changed (43) hide show
  1. package/README.md +254 -34
  2. package/config/commitlint.base.mjs +36 -0
  3. package/config/edge-security/rate-limit.mjs +103 -20
  4. package/config/repo-settings.schema.json +78 -0
  5. package/default.json +4 -19
  6. package/package.json +2 -1
  7. package/scripts/apply-uptime-monitors.mjs +378 -0
  8. package/scripts/apply-uptime-monitors.test.mjs +372 -0
  9. package/scripts/audit-check.mjs +321 -180
  10. package/scripts/audit-check.test.mjs +263 -0
  11. package/scripts/check-action-pins.mjs +106 -173
  12. package/scripts/check-coverage-threshold.mjs +44 -6
  13. package/scripts/check-coverage-threshold.test.mjs +43 -0
  14. package/scripts/check-docs-staleness.mjs +130 -81
  15. package/scripts/check-docs-staleness.test.mjs +130 -0
  16. package/scripts/check-pin-drift.mjs +61 -110
  17. package/scripts/check-pin-drift.test.mjs +175 -3
  18. package/scripts/check-repo-settings.mjs +363 -0
  19. package/scripts/check-repo-settings.test.mjs +320 -0
  20. package/scripts/check-required-contexts.mjs +247 -129
  21. package/scripts/check-required-contexts.test.mjs +137 -0
  22. package/scripts/check-ruleset.mjs +435 -0
  23. package/scripts/check-ruleset.test.mjs +439 -0
  24. package/scripts/check-workflow-portability.mjs +163 -118
  25. package/scripts/check-workflow-portability.test.mjs +199 -0
  26. package/scripts/check-wrangler-baseline.mjs +514 -0
  27. package/scripts/check-wrangler-baseline.test.mjs +454 -0
  28. package/scripts/edge-security.test.mjs +81 -1
  29. package/scripts/lib/args.mjs +93 -0
  30. package/scripts/lib/args.test.mjs +152 -0
  31. package/scripts/lib/gh-json.mjs +119 -0
  32. package/scripts/lib/semver-duration.mjs +84 -0
  33. package/scripts/lib/uses-pins.mjs +220 -0
  34. package/scripts/lib/uses-pins.test.mjs +219 -0
  35. package/scripts/lib/walk.mjs +74 -0
  36. package/scripts/platform-repair.mjs +9 -3
  37. package/scripts/platform-sync.mjs +533 -5
  38. package/scripts/platform-sync.test.mjs +477 -0
  39. package/scripts/update-semgrep-rules.mjs +76 -5
  40. package/templates/runbooks/README.md +9 -5
  41. package/templates/runbooks/branch-protection-setup.md +9 -3
  42. package/templates/workflows/deploy-staging.yml +86 -0
  43. package/templates/workflows/uptime-apply.yml +54 -0
@@ -68,41 +68,49 @@
68
68
  * It is dependency-free (no YAML parser) so it copies cleanly into any repo.
69
69
  */
70
70
 
71
- import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
71
+ import { readFileSync, statSync, existsSync } from "node:fs";
72
72
  import { execFileSync } from "node:child_process";
73
73
  import { resolve, join, relative, basename } from "node:path";
74
74
 
75
+ import { parseFlags } from "./lib/args.mjs";
76
+ import { parseUsesLine, classifyUses, isSha40 } from "./lib/uses-pins.mjs";
77
+ import { listWorkflowFiles, listActionFiles } from "./lib/walk.mjs";
78
+
75
79
  // ---------------------------------------------------------------------------
76
80
  // Arg parsing
77
81
  // ---------------------------------------------------------------------------
78
82
 
79
- const args = process.argv.slice(2);
80
- let workflowsDir = null;
81
- let actionsDir = null;
82
- let pinCheck = true;
83
-
84
- for (let i = 0; i < args.length; i++) {
85
- if ((args[i] === "--workflows-dir" || args[i] === "-w") && args[i + 1]) {
86
- workflowsDir = args[++i];
87
- } else if ((args[i] === "--actions-dir" || args[i] === "-a") && args[i + 1]) {
88
- actionsDir = args[++i];
89
- } else if (args[i] === "--no-pin-check") {
90
- pinCheck = false;
91
- } else if (args[i] === "--help" || args[i] === "-h") {
92
- process.stdout.write(
93
- "Usage: node scripts/check-workflow-portability.mjs [--workflows-dir <dir>] [--actions-dir <dir>] [--no-pin-check]\n"
94
- );
95
- process.exit(0);
96
- }
83
+ /**
84
+ * Parse the CLI argv slice into an options object. Pure — no I/O, no exit — so
85
+ * the sibling node:test suite can exercise it. `--help` is surfaced as a flag
86
+ * for the CLI wrapper to act on rather than exiting here. Unknown flags are
87
+ * ignored (lenient CLI), preserving this script's historical behavior.
88
+ */
89
+ export function parseArgs(argv) {
90
+ return parseFlags(argv, {
91
+ flags: {
92
+ "--workflows-dir": { type: "string", dest: "workflowsDir", default: null },
93
+ "--actions-dir": { type: "string", dest: "actionsDir", default: null },
94
+ "--no-pin-check": { type: "boolean", dest: "pinCheck", value: false, default: true },
95
+ "--help": { type: "boolean", dest: "help", value: true, default: false },
96
+ },
97
+ aliases: {
98
+ "-w": "--workflows-dir",
99
+ "-a": "--actions-dir",
100
+ "-h": "--help",
101
+ },
102
+ onUnknown: "ignore",
103
+ });
97
104
  }
98
105
 
106
+ // Runtime bindings shared by the discovery/lint helpers below. Initialized at
107
+ // module load with safe defaults (cwd-relative dirs, pin-check on) and
108
+ // re-derived per invocation inside runCli() so importing this module never
109
+ // triggers a scan.
99
110
  const repoRoot = process.cwd();
100
- const resolvedWorkflowsDir = workflowsDir
101
- ? resolve(workflowsDir)
102
- : resolve(repoRoot, ".github/workflows");
103
- const resolvedActionsDir = actionsDir
104
- ? resolve(actionsDir)
105
- : resolve(repoRoot, ".github/actions");
111
+ let resolvedWorkflowsDir = resolve(repoRoot, ".github/workflows");
112
+ let resolvedActionsDir = resolve(repoRoot, ".github/actions");
113
+ let pinCheck = true;
106
114
 
107
115
  // ---------------------------------------------------------------------------
108
116
  // Minimal indentation-aware YAML walk (dependency-free)
@@ -117,7 +125,7 @@ const resolvedActionsDir = actionsDir
117
125
  // for a parent mapping. Quotes are stripped from keys so `"on":` === `on`.
118
126
  // ---------------------------------------------------------------------------
119
127
 
120
- function walkYaml(content) {
128
+ export function walkYaml(content) {
121
129
  const lines = content.split("\n");
122
130
  const stack = []; // [{ indent, key }]
123
131
  const records = [];
@@ -193,19 +201,42 @@ const EXPR = /\$\{\{/;
193
201
  // Content checks (reused for both working-tree files and pinned blobs)
194
202
  // ---------------------------------------------------------------------------
195
203
 
204
+ /**
205
+ * Detect whether a workflow declares `workflow_call` — i.e. is reusable and
206
+ * therefore subject to Rules 1 & 2. Two shapes count:
207
+ *
208
+ * 1. Mapping form — `on:` is a mapping with a `workflow_call:` child, so the
209
+ * walk yields a record whose path is `on.workflow_call` (or a descendant).
210
+ * 2. Inline form — `workflow_call` sits directly on the `on:` value, as a
211
+ * bare scalar (`on: workflow_call`) or inside a flow sequence
212
+ * (`on: [workflow_call]`, `on: [push, workflow_call]`). The walk yields a
213
+ * single record with path `on` whose value carries the trigger token(s).
214
+ *
215
+ * The former mapping-only check silently skipped inline-declared reusable
216
+ * workflows, so Rules 1 & 2 never ran on them.
217
+ */
218
+ export function isReusableWorkflow(records) {
219
+ return records.some((r) => {
220
+ const p = r.path.join(".");
221
+ if (p === "on.workflow_call" || p.startsWith("on.workflow_call.")) return true;
222
+ // Inline scalar / flow-sequence on the top-level `on:` key.
223
+ if (p === "on" && /\bworkflow_call\b/.test(r.value)) return true;
224
+ return false;
225
+ });
226
+ }
227
+
196
228
  /** Reusable-workflow checks (Rules 1 & 2). Returns [{line, message}]. */
197
- function checkWorkflowContent(content) {
229
+ export function checkWorkflowContent(content) {
198
230
  const violations = [];
199
231
  const records = walkYaml(content);
200
232
 
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;
233
+ if (!isReusableWorkflow(records)) return violations;
205
234
 
206
- // Rule 1: no relative `uses:` anywhere in a reusable workflow.
235
+ // Rule 1: no relative `uses:` anywhere in a reusable workflow. Allow an
236
+ // optional leading `- ` so the sequence/list form (`- uses: ./…`) is caught
237
+ // as well as the mapping form — mirrors check-action-pins.mjs's `usesRe`.
207
238
  content.split("\n").forEach((raw, idx) => {
208
- if (/^\s*uses:\s*['"]?\.\//.test(raw)) {
239
+ if (/^\s*(?:-\s+)?uses:\s*['"]?\.\//.test(raw)) {
209
240
  violations.push({
210
241
  line: idx + 1,
211
242
  message:
@@ -239,7 +270,7 @@ function checkWorkflowContent(content) {
239
270
  }
240
271
 
241
272
  /** Composite-action checks (Rules 3/4 of the original; manifest cleanliness). */
242
- function checkActionContent(content) {
273
+ export function checkActionContent(content) {
243
274
  const violations = [];
244
275
  const records = walkYaml(content);
245
276
 
@@ -307,15 +338,23 @@ function gitShow(sha, path) {
307
338
  function collectInternalPins(content) {
308
339
  const pins = [];
309
340
  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];
341
+ const bareRef = parseUsesLine(raw);
342
+ if (bareRef === null) return;
343
+ // `owner/repo/<subpath>@<sha>` — classify against *any* owner (this repo's
344
+ // own slug or a fork's), then keep only same-repo refs whose subpath
345
+ // resolves to a real path in the working tree. Passing the parsed owner as
346
+ // the "first-party" owner makes classifyUses treat every owner/repo the
347
+ // same; the existsSync gate below is what actually decides "internal".
348
+ const atIdx = bareRef.lastIndexOf("@");
349
+ if (atIdx === -1) return;
350
+ const owner = bareRef.slice(0, atIdx).split("/").slice(0, 2).join("/");
351
+ const cls = classifyUses(bareRef, owner);
352
+ if (cls.kind !== "first-party" || !cls.subpath) return;
353
+ if (!isSha40(cls.ref)) return; // only full-SHA pins are validated by Rule 3
354
+ const subpath = cls.subpath;
316
355
  const local = join(repoRoot, subpath);
317
356
  if (!existsSync(local)) return; // external ref → skip
318
- pins.push({ subpath, sha, line: idx + 1 });
357
+ pins.push({ subpath, sha: cls.ref, line: idx + 1 });
319
358
  });
320
359
  return pins;
321
360
  }
@@ -343,46 +382,10 @@ function resolvePinnedManifest(subpath) {
343
382
  }
344
383
 
345
384
  // ---------------------------------------------------------------------------
346
- // File discovery
385
+ // File discovery — `listWorkflowFiles` / `listActionFiles` are imported from
386
+ // `./lib/walk.mjs` (Story #203).
347
387
  // ---------------------------------------------------------------------------
348
388
 
349
- function listWorkflowFiles(dir) {
350
- let entries;
351
- try {
352
- entries = readdirSync(dir);
353
- } catch {
354
- return [];
355
- }
356
- return entries
357
- .filter((f) => f.endsWith(".yml") || f.endsWith(".yaml"))
358
- .map((f) => join(dir, f));
359
- }
360
-
361
- function listActionFiles(dir) {
362
- const found = [];
363
- let entries;
364
- try {
365
- entries = readdirSync(dir);
366
- } catch {
367
- return found;
368
- }
369
- for (const entry of entries) {
370
- const full = join(dir, entry);
371
- let st;
372
- try {
373
- st = statSync(full);
374
- } catch {
375
- continue;
376
- }
377
- if (st.isDirectory()) {
378
- found.push(...listActionFiles(full));
379
- } else if (entry === "action.yml" || entry === "action.yaml") {
380
- found.push(full);
381
- }
382
- }
383
- return found;
384
- }
385
-
386
389
  // ---------------------------------------------------------------------------
387
390
  // Per-file lint
388
391
  // ---------------------------------------------------------------------------
@@ -442,52 +445,94 @@ function lintFile(filePath) {
442
445
  // Run
443
446
  // ---------------------------------------------------------------------------
444
447
 
445
- const workflowFiles = listWorkflowFiles(resolvedWorkflowsDir);
446
- const actionFiles = listActionFiles(resolvedActionsDir);
447
- const allFiles = [...workflowFiles, ...actionFiles];
448
-
449
- process.stdout.write(
450
- `[check-workflow-portability] Workflows: ${relative(repoRoot, resolvedWorkflowsDir)}/ (${workflowFiles.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`
453
- );
448
+ /**
449
+ * Run the full lint end-to-end and return a POSIX exit code (0 = clean,
450
+ * 1 = violations). Applies the parsed options to the shared runtime bindings,
451
+ * discovers the workflow/action files, lints each, and reports. `log` / `err`
452
+ * are injectable so the sibling node:test suite can capture output without
453
+ * touching the real streams; the direct-invocation guard below wires them to
454
+ * process.stdout / process.stderr.
455
+ */
456
+ export function runCli(argv, { log = writeStdout, err = writeStderr } = {}) {
457
+ const opts = parseArgs(argv);
458
+ if (opts.help) {
459
+ log(
460
+ "Usage: node scripts/check-workflow-portability.mjs [--workflows-dir <dir>] [--actions-dir <dir>] [--no-pin-check]\n"
461
+ );
462
+ return 0;
463
+ }
454
464
 
455
- if (allFiles.length === 0) {
456
- process.stdout.write(
457
- `[check-workflow-portability] No workflow or action files found — nothing to lint.\n`
465
+ resolvedWorkflowsDir = opts.workflowsDir
466
+ ? resolve(opts.workflowsDir)
467
+ : resolve(repoRoot, ".github/workflows");
468
+ resolvedActionsDir = opts.actionsDir
469
+ ? resolve(opts.actionsDir)
470
+ : resolve(repoRoot, ".github/actions");
471
+ pinCheck = opts.pinCheck;
472
+ pinSkips.length = 0; // idempotent across repeated runCli() calls
473
+
474
+ const workflowFiles = listWorkflowFiles(resolvedWorkflowsDir);
475
+ const actionFiles = listActionFiles(resolvedActionsDir);
476
+ const allFiles = [...workflowFiles, ...actionFiles];
477
+
478
+ log(
479
+ `[check-workflow-portability] Workflows: ${relative(repoRoot, resolvedWorkflowsDir)}/ (${workflowFiles.length})\n` +
480
+ `[check-workflow-portability] Actions : ${relative(repoRoot, resolvedActionsDir)}/ (${actionFiles.length})\n` +
481
+ `[check-workflow-portability] Pin check: ${pinCheck ? (isGitRepo() ? "on" : "on (git unavailable — skipped)") : "off"}\n`
458
482
  );
459
- process.exit(0);
460
- }
461
483
 
462
- let total = 0;
463
- for (const file of allFiles) {
464
- const violations = lintFile(file);
465
- if (violations.length === 0) continue;
466
- total += violations.length;
467
- const rel = relative(repoRoot, file);
468
- process.stderr.write(`\n[check-workflow-portability] ❌ ${rel}\n`);
469
- for (const v of violations) {
470
- process.stderr.write(` ${rel}:${v.line} ${v.message}\n`);
484
+ if (allFiles.length === 0) {
485
+ log(
486
+ `[check-workflow-portability] No workflow or action files found — nothing to lint.\n`
487
+ );
488
+ return 0;
489
+ }
490
+
491
+ let total = 0;
492
+ for (const file of allFiles) {
493
+ const violations = lintFile(file);
494
+ if (violations.length === 0) continue;
495
+ total += violations.length;
496
+ const rel = relative(repoRoot, file);
497
+ err(`\n[check-workflow-portability] ❌ ${rel}\n`);
498
+ for (const v of violations) {
499
+ err(` ${rel}:${v.line} — ${v.message}\n`);
500
+ }
501
+ }
502
+
503
+ if (pinSkips.length > 0) {
504
+ log(
505
+ `\n[check-workflow-portability] ⚠️ ${pinSkips.length} internal pin(s) could not be verified (Rule 3 skipped):\n`
506
+ );
507
+ for (const s of pinSkips) log(` ${s}\n`);
471
508
  }
472
- }
473
509
 
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`
510
+ if (total > 0) {
511
+ err(
512
+ `\n[check-workflow-portability] ${total} portability violation${total === 1 ? "" : "s"} detected.\n` +
513
+ ` These fail only when a CONSUMER repo calls the workflow/action, which is\n` +
514
+ ` exactly why in-repo CI never caught them before. Fix each above.\n\n`
515
+ );
516
+ return 1;
517
+ }
518
+
519
+ log(
520
+ `[check-workflow-portability] ✅ All reusable workflows and composite actions are cross-repo portable.\n`
477
521
  );
478
- for (const s of pinSkips) process.stdout.write(` ${s}\n`);
522
+ return 0;
479
523
  }
480
524
 
481
- if (total > 0) {
482
- process.stderr.write(
483
- `\n[check-workflow-portability] ${total} portability violation${total === 1 ? "" : "s"} detected.\n` +
484
- ` These fail only when a CONSUMER repo calls the workflow/action, which is\n` +
485
- ` exactly why in-repo CI never caught them before. Fix each above.\n\n`
486
- );
487
- process.exit(1);
525
+ function writeStdout(s) {
526
+ process.stdout.write(s);
527
+ }
528
+ function writeStderr(s) {
529
+ process.stderr.write(s);
488
530
  }
489
531
 
490
- process.stdout.write(
491
- `[check-workflow-portability] All reusable workflows and composite actions are cross-repo portable.\n`
492
- );
493
- process.exit(0);
532
+ // Only run when executed directly, not when imported by the test suite.
533
+ const invokedDirectly =
534
+ process.argv[1] &&
535
+ resolve(process.argv[1]).endsWith("check-workflow-portability.mjs");
536
+ if (invokedDirectly) {
537
+ process.exit(runCli(process.argv.slice(2)));
538
+ }
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-workflow-portability.test.mjs — node:test suite for the cross-repo
4
+ * portability lint (Story #199).
5
+ *
6
+ * This is the "equivalent self-test" the Story's acceptance criteria call for.
7
+ * It exercises the two blind spots the Story closes, plus the surrounding
8
+ * behavior so the fixes are pinned against regression:
9
+ *
10
+ * 1. Rule 1 now catches the sequence/list form `- uses: ./…` (previously the
11
+ * leading `- ` slipped past the mapping-only regex), so a local action
12
+ * referenced inside a workflow_call workflow IS flagged.
13
+ * 2. `on: workflow_call` declared INLINE (bare scalar or flow sequence) is
14
+ * detected as reusable, so Rules 1 & 2 actually run on it — the former
15
+ * mapping-only `on.workflow_call` check silently skipped these.
16
+ *
17
+ * Pure exported helpers keep the whole suite offline — no temp dirs, no git.
18
+ *
19
+ * Run: node --test scripts/check-workflow-portability.test.mjs
20
+ */
21
+
22
+ import assert from "node:assert/strict";
23
+ import { test } from "node:test";
24
+
25
+ import {
26
+ walkYaml,
27
+ isReusableWorkflow,
28
+ checkWorkflowContent,
29
+ checkActionContent,
30
+ parseArgs,
31
+ } from "./check-workflow-portability.mjs";
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // isReusableWorkflow — inline vs mapping workflow_call detection
35
+ // ---------------------------------------------------------------------------
36
+
37
+ test("isReusableWorkflow: mapping form (on: > workflow_call:) is reusable", () => {
38
+ const yaml = ["on:", " workflow_call:", " inputs:", " foo:", " type: string", ""].join("\n");
39
+ assert.equal(isReusableWorkflow(walkYaml(yaml)), true);
40
+ });
41
+
42
+ test("isReusableWorkflow: inline scalar form (on: workflow_call) is reusable", () => {
43
+ const yaml = ["name: reusable", "on: workflow_call", "jobs: {}", ""].join("\n");
44
+ assert.equal(isReusableWorkflow(walkYaml(yaml)), true);
45
+ });
46
+
47
+ test("isReusableWorkflow: inline flow-sequence form (on: [workflow_call]) is reusable", () => {
48
+ const yaml = ["on: [workflow_call]", "jobs: {}", ""].join("\n");
49
+ assert.equal(isReusableWorkflow(walkYaml(yaml)), true);
50
+ });
51
+
52
+ test("isReusableWorkflow: mixed inline flow-sequence (on: [push, workflow_call]) is reusable", () => {
53
+ const yaml = ["on: [push, workflow_call]", "jobs: {}", ""].join("\n");
54
+ assert.equal(isReusableWorkflow(walkYaml(yaml)), true);
55
+ });
56
+
57
+ test("isReusableWorkflow: a plain push-triggered workflow is NOT reusable", () => {
58
+ const yaml = ["on:", " push:", " branches: [main]", "jobs: {}", ""].join("\n");
59
+ assert.equal(isReusableWorkflow(walkYaml(yaml)), false);
60
+ });
61
+
62
+ test("isReusableWorkflow: inline scalar `on: push` is NOT reusable (no false positive)", () => {
63
+ const yaml = ["on: push", "jobs: {}", ""].join("\n");
64
+ assert.equal(isReusableWorkflow(walkYaml(yaml)), false);
65
+ });
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Rule 1 — relative `uses: ./…`, both mapping and sequence forms
69
+ // ---------------------------------------------------------------------------
70
+
71
+ test("Rule 1: sequence form `- uses: ./…` inside a workflow_call workflow IS flagged", () => {
72
+ const yaml = [
73
+ "on:",
74
+ " workflow_call:",
75
+ "jobs:",
76
+ " build:",
77
+ " runs-on: ubuntu-latest",
78
+ " steps:",
79
+ " - uses: ./.github/actions/foo",
80
+ "",
81
+ ].join("\n");
82
+ const violations = checkWorkflowContent(yaml);
83
+ assert.equal(violations.length, 1, "expected exactly one Rule 1 violation");
84
+ assert.match(violations[0].message, /relative `uses: \.\/` path/);
85
+ });
86
+
87
+ test("Rule 1: mapping form `uses: ./…` is still flagged (no regression)", () => {
88
+ const yaml = [
89
+ "on:",
90
+ " workflow_call:",
91
+ "jobs:",
92
+ " build:",
93
+ " uses: ./.github/workflows/reused.yml",
94
+ "",
95
+ ].join("\n");
96
+ const violations = checkWorkflowContent(yaml);
97
+ assert.equal(violations.length, 1);
98
+ assert.match(violations[0].message, /relative `uses: \.\/` path/);
99
+ });
100
+
101
+ test("Rule 1: sequence-form relative `uses` combined with INLINE workflow_call IS flagged", () => {
102
+ // Combines both fixes: inline reusable detection + sequence-form catch.
103
+ const yaml = [
104
+ "name: reusable-inline",
105
+ "on: workflow_call",
106
+ "jobs:",
107
+ " build:",
108
+ " runs-on: ubuntu-latest",
109
+ " steps:",
110
+ " - uses: ./.github/actions/foo",
111
+ "",
112
+ ].join("\n");
113
+ const violations = checkWorkflowContent(yaml);
114
+ assert.equal(violations.length, 1);
115
+ assert.match(violations[0].message, /relative `uses: \.\/` path/);
116
+ });
117
+
118
+ test("Rule 1: absolute owner/repo `uses` in a reusable workflow is NOT flagged", () => {
119
+ const yaml = [
120
+ "on: workflow_call",
121
+ "jobs:",
122
+ " build:",
123
+ " runs-on: ubuntu-latest",
124
+ " steps:",
125
+ " - uses: actions/checkout@v4",
126
+ "",
127
+ ].join("\n");
128
+ assert.deepEqual(checkWorkflowContent(yaml), []);
129
+ });
130
+
131
+ test("Rule 1: relative `uses` in a NON-reusable workflow is NOT flagged", () => {
132
+ // `./` is portable when the workflow only runs in its own repo.
133
+ const yaml = [
134
+ "on:",
135
+ " push:",
136
+ "jobs:",
137
+ " build:",
138
+ " steps:",
139
+ " - uses: ./.github/actions/foo",
140
+ "",
141
+ ].join("\n");
142
+ assert.deepEqual(checkWorkflowContent(yaml), []);
143
+ });
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Rule 2 — ${{ }} in workflow_call input/secret meta runs on inline form too
147
+ // ---------------------------------------------------------------------------
148
+
149
+ test("Rule 2: still fires on the mapping workflow_call form", () => {
150
+ const yaml = [
151
+ "on:",
152
+ " workflow_call:",
153
+ " inputs:",
154
+ " name:",
155
+ " description: ${{ runner.os }}",
156
+ "",
157
+ ].join("\n");
158
+ const violations = checkWorkflowContent(yaml);
159
+ assert.equal(violations.length, 1);
160
+ assert.match(violations[0].message, /expression in workflow_call/);
161
+ });
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // checkActionContent — untouched by this Story, guarded against regression
165
+ // ---------------------------------------------------------------------------
166
+
167
+ test("checkActionContent: flags ${{ }} in a composite input default", () => {
168
+ const yaml = [
169
+ "inputs:",
170
+ " dest:",
171
+ " default: ${{ runner.temp }}",
172
+ "",
173
+ ].join("\n");
174
+ const violations = checkActionContent(yaml);
175
+ assert.equal(violations.length, 1);
176
+ assert.match(violations[0].message, /expression in action input/);
177
+ });
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // parseArgs — pure option parsing
181
+ // ---------------------------------------------------------------------------
182
+
183
+ test("parseArgs: defaults are pin-check on, no dir overrides, no help", () => {
184
+ assert.deepEqual(parseArgs([]), {
185
+ workflowsDir: null,
186
+ actionsDir: null,
187
+ pinCheck: true,
188
+ help: false,
189
+ });
190
+ });
191
+
192
+ test("parseArgs: --no-pin-check, dir overrides, and --help are parsed", () => {
193
+ assert.deepEqual(parseArgs(["-w", "wf", "--actions-dir", "act", "--no-pin-check", "--help"]), {
194
+ workflowsDir: "wf",
195
+ actionsDir: "act",
196
+ pinCheck: false,
197
+ help: true,
198
+ });
199
+ });