mandrel-platform 0.2.5 → 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
|
@@ -0,0 +1,493 @@
|
|
|
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 / 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
|
+
*
|
|
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
|
+
*
|
|
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:` 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.
|
|
41
|
+
*
|
|
42
|
+
* What this lint deliberately does NOT flag: `${{ }}` in `runs.steps[].with`
|
|
43
|
+
* (e.g. `dest: ${{ inputs['pnpm-dest'] || format('{0}/pnpm', runner.temp) }}`)
|
|
44
|
+
* is a VALID runtime expression. The lint only inspects `description` and
|
|
45
|
+
* `default` leaves *inside input/secret-definition blocks*, so legitimate
|
|
46
|
+
* runtime expressions in step bodies are never touched.
|
|
47
|
+
*
|
|
48
|
+
* Usage:
|
|
49
|
+
* node scripts/check-workflow-portability.mjs
|
|
50
|
+
* node scripts/check-workflow-portability.mjs --workflows-dir .github/workflows
|
|
51
|
+
* node scripts/check-workflow-portability.mjs --actions-dir .github/actions
|
|
52
|
+
* node scripts/check-workflow-portability.mjs --no-pin-check # skip Rule 3
|
|
53
|
+
*
|
|
54
|
+
* Exit codes:
|
|
55
|
+
* 0 — every reusable workflow and composite action is cross-repo portable
|
|
56
|
+
* 1 — one or more portability violations detected (each named in stderr)
|
|
57
|
+
*
|
|
58
|
+
* Consumer adoption:
|
|
59
|
+
* Copy this script into your project's `scripts/` directory, then wire it
|
|
60
|
+
* into your CI alongside check-required-contexts.mjs. Use fetch-depth: 0 on
|
|
61
|
+
* the checkout so Rule 3 can resolve pinned blobs:
|
|
62
|
+
*
|
|
63
|
+
* - uses: actions/checkout@<sha>
|
|
64
|
+
* with: { fetch-depth: 0 }
|
|
65
|
+
* - name: Lint workflow portability
|
|
66
|
+
* run: node scripts/check-workflow-portability.mjs
|
|
67
|
+
*
|
|
68
|
+
* It is dependency-free (no YAML parser) so it copies cleanly into any repo.
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
|
|
72
|
+
import { execFileSync } from "node:child_process";
|
|
73
|
+
import { resolve, join, relative, basename } from "node:path";
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Arg parsing
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
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
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
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");
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Minimal indentation-aware YAML walk (dependency-free)
|
|
109
|
+
//
|
|
110
|
+
// We do NOT need a full YAML parser — only the path-qualified `description`
|
|
111
|
+
// and `default` scalar leaves (with their folded multi-line values) and a
|
|
112
|
+
// flat line scan for `uses:`. The walk yields one record per mapping key:
|
|
113
|
+
//
|
|
114
|
+
// { path: [...ancestorKeys, key], value, lineNo }
|
|
115
|
+
//
|
|
116
|
+
// where `value` is the inline scalar, the gathered block-scalar body, or ""
|
|
117
|
+
// for a parent mapping. Quotes are stripped from keys so `"on":` === `on`.
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
function walkYaml(content) {
|
|
121
|
+
const lines = content.split("\n");
|
|
122
|
+
const stack = []; // [{ indent, key }]
|
|
123
|
+
const records = [];
|
|
124
|
+
|
|
125
|
+
let i = 0;
|
|
126
|
+
while (i < lines.length) {
|
|
127
|
+
const raw = lines[i];
|
|
128
|
+
|
|
129
|
+
// Blank and comment-only lines carry no structure.
|
|
130
|
+
if (/^\s*$/.test(raw) || /^\s*#/.test(raw)) {
|
|
131
|
+
i++;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const indent = raw.match(/^(\s*)/)[1].length;
|
|
136
|
+
const trimmed = raw.slice(indent);
|
|
137
|
+
|
|
138
|
+
// Match a mapping key, optionally introduced by a sequence dash.
|
|
139
|
+
const m = trimmed.match(/^(-\s+)?(["']?[A-Za-z0-9_.\-]+["']?):(\s*)(.*)$/);
|
|
140
|
+
if (!m) {
|
|
141
|
+
i++;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const key = m[2].replace(/^["']|["']$/g, "");
|
|
146
|
+
const after = m[4];
|
|
147
|
+
|
|
148
|
+
// Unwind to the enclosing mapping for this indentation.
|
|
149
|
+
while (stack.length && stack[stack.length - 1].indent >= indent) stack.pop();
|
|
150
|
+
const path = stack.map((s) => s.key).concat(key);
|
|
151
|
+
|
|
152
|
+
// Block scalar (`>`, `|`, with optional chomp/indent indicators): gather
|
|
153
|
+
// the deeper-indented body so `${{` inside a folded description is seen.
|
|
154
|
+
if (/^[|>][+-]?\d*\s*$/.test(after)) {
|
|
155
|
+
let value = "";
|
|
156
|
+
let j = i + 1;
|
|
157
|
+
while (j < lines.length) {
|
|
158
|
+
const cont = lines[j];
|
|
159
|
+
if (/^\s*$/.test(cont)) {
|
|
160
|
+
value += "\n";
|
|
161
|
+
j++;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const contIndent = cont.match(/^(\s*)/)[1].length;
|
|
165
|
+
if (contIndent <= indent) break;
|
|
166
|
+
value += cont.trim() + "\n";
|
|
167
|
+
j++;
|
|
168
|
+
}
|
|
169
|
+
records.push({ path, value, lineNo: i + 1 });
|
|
170
|
+
i = j;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (after === "") {
|
|
175
|
+
// Parent mapping (or empty/sequence container): becomes context.
|
|
176
|
+
stack.push({ indent, key });
|
|
177
|
+
records.push({ path, value: "", lineNo: i + 1 });
|
|
178
|
+
i++;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Inline scalar leaf.
|
|
183
|
+
records.push({ path, value: after, lineNo: i + 1 });
|
|
184
|
+
i++;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return records;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const EXPR = /\$\{\{/;
|
|
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
|
+
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
// File discovery
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
|
|
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
|
+
// ---------------------------------------------------------------------------
|
|
387
|
+
// Per-file lint
|
|
388
|
+
// ---------------------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
const pinSkips = [];
|
|
391
|
+
|
|
392
|
+
function lintFile(filePath) {
|
|
393
|
+
const violations = [];
|
|
394
|
+
let content;
|
|
395
|
+
try {
|
|
396
|
+
content = readFileSync(filePath, "utf8");
|
|
397
|
+
} catch (err) {
|
|
398
|
+
return [{ line: 0, message: `cannot read file: ${err.message}` }];
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const isWorkflow = filePath.startsWith(resolvedWorkflowsDir);
|
|
402
|
+
const isAction =
|
|
403
|
+
basename(filePath) === "action.yml" || basename(filePath) === "action.yaml";
|
|
404
|
+
|
|
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;
|
|
421
|
+
}
|
|
422
|
+
const pinnedViolations =
|
|
423
|
+
manifest.kind === "action"
|
|
424
|
+
? checkActionContent(pinned)
|
|
425
|
+
: checkWorkflowContent(pinned);
|
|
426
|
+
for (const v of pinnedViolations) {
|
|
427
|
+
violations.push({
|
|
428
|
+
line: pin.line,
|
|
429
|
+
message:
|
|
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.`,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return violations;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ---------------------------------------------------------------------------
|
|
442
|
+
// Run
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
|
|
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
|
+
);
|
|
454
|
+
|
|
455
|
+
if (allFiles.length === 0) {
|
|
456
|
+
process.stdout.write(
|
|
457
|
+
`[check-workflow-portability] No workflow or action files found — nothing to lint.\n`
|
|
458
|
+
);
|
|
459
|
+
process.exit(0);
|
|
460
|
+
}
|
|
461
|
+
|
|
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`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
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
|
+
|
|
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);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
process.stdout.write(
|
|
491
|
+
`[check-workflow-portability] ✅ All reusable workflows and composite actions are cross-repo portable.\n`
|
|
492
|
+
);
|
|
493
|
+
process.exit(0);
|