@secureci/cli 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +201 -2
- package/dist/cli.js +545 -0
- package/dist/config.js +190 -0
- package/dist/index.js +23 -0
- package/dist/init.js +45 -0
- package/dist/inventory.js +212 -0
- package/dist/parser/pipeline-contract-parser.js +131 -0
- package/dist/parser/pipeline-contract-validator.js +448 -0
- package/dist/parser/validator.js +232 -0
- package/dist/parser/workflow-parser.js +122 -0
- package/dist/report.js +75 -0
- package/dist/runner/action-fetcher.js +431 -0
- package/dist/runner/actions.js +495 -0
- package/dist/runner/builtin-actions.js +484 -0
- package/dist/runner/checkout.js +142 -0
- package/dist/runner/container-runtime.js +374 -0
- package/dist/runner/docker-action.js +179 -0
- package/dist/runner/dry-run.js +142 -0
- package/dist/runner/executor.js +554 -0
- package/dist/runner/expression.js +676 -0
- package/dist/runner/github-context.js +235 -0
- package/dist/runner/hash-files.js +123 -0
- package/dist/runner/job-container.js +322 -0
- package/dist/runner/job-plan.js +138 -0
- package/dist/runner/job-runtime.js +338 -0
- package/dist/runner/node-action.js +278 -0
- package/dist/runner/runner-env.js +303 -0
- package/dist/runner/shell.js +61 -0
- package/dist/runner/steps.js +250 -0
- package/dist/types/index.js +10 -0
- package/dist/utils/key-value.js +33 -0
- package/dist/utils/logger.js +87 -0
- package/dist/utils/repository.js +51 -0
- package/package.json +77 -4
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.validatePipelineContract = validatePipelineContract;
|
|
37
|
+
const fs = __importStar(require("fs"));
|
|
38
|
+
const path = __importStar(require("path"));
|
|
39
|
+
const job_plan_1 = require("../runner/job-plan");
|
|
40
|
+
const workflow_parser_1 = require("./workflow-parser");
|
|
41
|
+
const repository_1 = require("../utils/repository");
|
|
42
|
+
const action_fetcher_1 = require("../runner/action-fetcher");
|
|
43
|
+
/** Maximum nesting of composite actions / reusable workflows. */
|
|
44
|
+
const MAX_DEPTH = 10;
|
|
45
|
+
/**
|
|
46
|
+
* Validate a workflow's job/step sequence against a central pipeline
|
|
47
|
+
* contract: a minimum set of required actions (optionally ordered) and a
|
|
48
|
+
* deny-list of forbidden actions.
|
|
49
|
+
*
|
|
50
|
+
* Composite actions (`uses: ./path`) and reusable workflows
|
|
51
|
+
* (`jobs.<id>.uses: ./path.yml`) are resolved recursively, so wrapping an
|
|
52
|
+
* action doesn't hide it: local ones by default, remote ones too with
|
|
53
|
+
* `recurse: 'all'`. Ordering follows
|
|
54
|
+
* what GitHub guarantees: steps of a job run in order, and a job runs after
|
|
55
|
+
* the jobs it (transitively) `needs:`; jobs with no such relationship may
|
|
56
|
+
* run in parallel, so neither is "before" the other.
|
|
57
|
+
*/
|
|
58
|
+
async function validatePipelineContract(parsed, spec, options = {}) {
|
|
59
|
+
const graph = await buildGraph(parsed, options.recurse ?? 'local');
|
|
60
|
+
const { sequence, nodes } = graph;
|
|
61
|
+
const errors = [...graph.errors];
|
|
62
|
+
const ordering = spec.ordering ?? 'relative';
|
|
63
|
+
for (const entry of spec.forbidden ?? []) {
|
|
64
|
+
for (const usage of sequence) {
|
|
65
|
+
if (usage.name === normalizeActionName(entry.uses) &&
|
|
66
|
+
(entry.version === undefined || usage.version === entry.version)) {
|
|
67
|
+
errors.push({
|
|
68
|
+
path: usage.path,
|
|
69
|
+
message: entry.version
|
|
70
|
+
? `Forbidden action "${entry.uses}@${entry.version}" is used`
|
|
71
|
+
: `Forbidden action "${entry.uses}" is used (any version)`,
|
|
72
|
+
severity: 'error'
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const matchedPositions = [];
|
|
78
|
+
(spec.required ?? []).forEach((entry, idx) => {
|
|
79
|
+
const path = `required[${idx}]`;
|
|
80
|
+
const name = normalizeActionName(entry.uses);
|
|
81
|
+
const candidates = sequence.filter((u) => u.name === name);
|
|
82
|
+
if (candidates.length > 0 && candidates.every((c) => c.conditional)) {
|
|
83
|
+
errors.push({
|
|
84
|
+
path,
|
|
85
|
+
message: `Required action "${entry.uses}" only runs under an if: condition (on its step or job), so it may be skipped`,
|
|
86
|
+
severity: 'warning'
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
if (candidates.length === 0) {
|
|
90
|
+
errors.push({
|
|
91
|
+
path,
|
|
92
|
+
message: `Required action "${entry.uses}" was not found in the pipeline`,
|
|
93
|
+
severity: 'error'
|
|
94
|
+
});
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (!entry['min-version']) {
|
|
98
|
+
matchedPositions.push({ entry, usage: candidates[0], index: idx });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const satisfying = candidates.find((c) => c.version && isVersionAtLeast(c.version, entry['min-version']));
|
|
102
|
+
if (satisfying) {
|
|
103
|
+
matchedPositions.push({ entry, usage: satisfying, index: idx });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// Present but too old (or not comparable): still check its ordering,
|
|
107
|
+
// using its first occurrence.
|
|
108
|
+
matchedPositions.push({ entry, usage: candidates[0], index: idx });
|
|
109
|
+
const comparable = candidates.find((c) => c.version &&
|
|
110
|
+
isVersionAtLeast(c.version, entry['min-version']) === false);
|
|
111
|
+
if (comparable) {
|
|
112
|
+
errors.push({
|
|
113
|
+
path,
|
|
114
|
+
message: `Required action "${entry.uses}" is used at version "${comparable.version}", which is below the required minimum "${entry['min-version']}"`,
|
|
115
|
+
severity: 'error'
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
errors.push({
|
|
120
|
+
path,
|
|
121
|
+
message: `Required action "${entry.uses}" was found, but its version could not be compared against the required minimum "${entry['min-version']}"`,
|
|
122
|
+
severity: 'warning'
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
/** Whether `a` is guaranteed to have finished before `b` starts. */
|
|
127
|
+
const runsBefore = (a, b) => a.node === b.node
|
|
128
|
+
? a.index < b.index
|
|
129
|
+
: needsTransitively(nodes, b.node, a.node);
|
|
130
|
+
for (let i = 1; i < matchedPositions.length; i++) {
|
|
131
|
+
const prev = matchedPositions[i - 1];
|
|
132
|
+
const curr = matchedPositions[i];
|
|
133
|
+
const path = `required[${curr.index}]`;
|
|
134
|
+
if (ordering === 'strict') {
|
|
135
|
+
const a = prev.usage;
|
|
136
|
+
const b = curr.usage;
|
|
137
|
+
const adjacent = a.node === b.node
|
|
138
|
+
? b.index === a.index + 1
|
|
139
|
+
: a.index === (nodes.get(a.node)?.length ?? 0) - 1 &&
|
|
140
|
+
b.index === 0 &&
|
|
141
|
+
(nodes.get(b.node)?.needs.has(a.node) ?? false);
|
|
142
|
+
if (!adjacent) {
|
|
143
|
+
errors.push({
|
|
144
|
+
path,
|
|
145
|
+
message: `Required action "${curr.entry.uses}" must run immediately after "${prev.entry.uses}" (ordering: strict), but other steps appear in between`,
|
|
146
|
+
severity: 'error'
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
else if (!runsBefore(prev.usage, curr.usage)) {
|
|
151
|
+
const parallel = !runsBefore(curr.usage, prev.usage);
|
|
152
|
+
errors.push({
|
|
153
|
+
path,
|
|
154
|
+
message: `Required action "${curr.entry.uses}" must run after "${prev.entry.uses}", but it does not` +
|
|
155
|
+
(parallel
|
|
156
|
+
? ` (jobs "${nodes.get(prev.usage.node)?.label}" and "${nodes.get(curr.usage.node)?.label}" have no needs: relationship, so they may run in parallel)`
|
|
157
|
+
: ''),
|
|
158
|
+
severity: 'error'
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Supply-chain rules over the same flattened sequence (recursion
|
|
163
|
+
// included), so a wrapper can't hide an unpinned or disallowed action.
|
|
164
|
+
const FULL_SHA = /^[0-9a-f]{40}$/i;
|
|
165
|
+
const isRemote = (u) => !u.name.startsWith('.') && !u.name.startsWith('docker://');
|
|
166
|
+
if (spec.pins?.required) {
|
|
167
|
+
const allow = new Set((spec.pins.allow ?? []).map((a) => normalizeActionName(a)));
|
|
168
|
+
for (const usage of sequence) {
|
|
169
|
+
if (!isRemote(usage))
|
|
170
|
+
continue;
|
|
171
|
+
const owner = usage.name.split('/')[0];
|
|
172
|
+
if (allow.has(usage.name) || allow.has(owner))
|
|
173
|
+
continue;
|
|
174
|
+
if (!usage.version || !FULL_SHA.test(usage.version)) {
|
|
175
|
+
errors.push({
|
|
176
|
+
path: usage.path,
|
|
177
|
+
message: `Action "${usage.name}${usage.version ? '@' + usage.version : ''}" must be pinned to a full commit SHA`,
|
|
178
|
+
severity: 'error'
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (spec.allowedOwners && spec.allowedOwners.length > 0) {
|
|
184
|
+
const allowed = new Set(spec.allowedOwners.map((o) => o.split('/')[0].toLowerCase()));
|
|
185
|
+
for (const usage of sequence) {
|
|
186
|
+
if (!isRemote(usage))
|
|
187
|
+
continue;
|
|
188
|
+
const owner = usage.name.split('/')[0];
|
|
189
|
+
if (!allowed.has(owner)) {
|
|
190
|
+
errors.push({
|
|
191
|
+
path: usage.path,
|
|
192
|
+
message: `Action owner "${owner}" is not allowed (allowed owners: ${[...allowed].join(', ')})`,
|
|
193
|
+
severity: 'error'
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
valid: errors.every((e) => e.severity !== 'error'),
|
|
200
|
+
errors
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
/** Whether job `from` (transitively) needs job `to`. */
|
|
204
|
+
function needsTransitively(nodes, from, to) {
|
|
205
|
+
const seen = new Set();
|
|
206
|
+
const queue = [...(nodes.get(from)?.needs ?? [])];
|
|
207
|
+
while (queue.length > 0) {
|
|
208
|
+
const id = queue.shift();
|
|
209
|
+
if (id === to)
|
|
210
|
+
return true;
|
|
211
|
+
if (seen.has(id))
|
|
212
|
+
continue;
|
|
213
|
+
seen.add(id);
|
|
214
|
+
queue.push(...(nodes.get(id)?.needs ?? []));
|
|
215
|
+
}
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
async function buildGraph(parsed, recurse) {
|
|
219
|
+
const graph = { sequence: [], nodes: new Map(), errors: [] };
|
|
220
|
+
const root = (0, repository_1.repositoryRoot)(parsed.path);
|
|
221
|
+
const isLocal = (uses) => uses.startsWith('./') || uses.startsWith('../');
|
|
222
|
+
/**
|
|
223
|
+
* The file behind a composite action / reusable workflow reference, or
|
|
224
|
+
* null (with a warning) if it can't be found. Local actions resolve
|
|
225
|
+
* against the workspace, local workflows against their own repository.
|
|
226
|
+
* Returns undefined for references that aren't followed.
|
|
227
|
+
*/
|
|
228
|
+
const resolveRef = async (uses, kind, workflowRoot, at) => {
|
|
229
|
+
if (isLocal(uses)) {
|
|
230
|
+
if (recurse === 'none')
|
|
231
|
+
return undefined;
|
|
232
|
+
const target = path.resolve(kind === 'action' ? root : workflowRoot, uses);
|
|
233
|
+
const names = kind === 'action' ? ['action.yml', 'action.yaml'] : [];
|
|
234
|
+
for (const candidate of [
|
|
235
|
+
target,
|
|
236
|
+
...names.map((n) => path.join(target, n))
|
|
237
|
+
]) {
|
|
238
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
|
|
239
|
+
return candidate;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
graph.errors.push({
|
|
243
|
+
path: at,
|
|
244
|
+
message: kind === 'action'
|
|
245
|
+
? `Local action "${uses}" could not be resolved, so its steps were not checked`
|
|
246
|
+
: `Local reusable workflow "${uses}" could not be resolved, so its jobs were not checked`,
|
|
247
|
+
severity: 'warning'
|
|
248
|
+
});
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
if (recurse !== 'all' || uses.startsWith('docker://'))
|
|
252
|
+
return undefined;
|
|
253
|
+
const file = kind === 'action' ? await (0, action_fetcher_1.fetchAction)(uses) : await (0, action_fetcher_1.fetchRepoFile)(uses);
|
|
254
|
+
if (!file) {
|
|
255
|
+
graph.errors.push({
|
|
256
|
+
path: at,
|
|
257
|
+
message: `Remote ${kind} "${uses}" could not be fetched, so its ${kind === 'action' ? 'steps' : 'jobs'} were not checked`,
|
|
258
|
+
severity: 'warning'
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
return file;
|
|
262
|
+
};
|
|
263
|
+
/** Parse an action/workflow file, recording an error if it's invalid. */
|
|
264
|
+
const parse = (file, at) => {
|
|
265
|
+
try {
|
|
266
|
+
return (0, workflow_parser_1.parseFile)(file);
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
graph.errors.push({
|
|
270
|
+
path: at,
|
|
271
|
+
message: `Could not parse ${path.relative(root, file)}: ${error instanceof Error ? error.message : String(error)}`,
|
|
272
|
+
severity: 'error'
|
|
273
|
+
});
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
/** Check recursion limits; returns false (with an error) to stop. */
|
|
278
|
+
const canEnter = (file, stack, at) => {
|
|
279
|
+
if (stack.includes(file)) {
|
|
280
|
+
graph.errors.push({
|
|
281
|
+
path: at,
|
|
282
|
+
message: `Action/workflow cycle: ${[...stack, file]
|
|
283
|
+
.map((f) => path.relative(root, f))
|
|
284
|
+
.join(' -> ')}`,
|
|
285
|
+
severity: 'error'
|
|
286
|
+
});
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
if (stack.length >= MAX_DEPTH) {
|
|
290
|
+
graph.errors.push({
|
|
291
|
+
path: at,
|
|
292
|
+
message: `Actions/workflows exceed the maximum nesting depth (${MAX_DEPTH})`,
|
|
293
|
+
severity: 'error'
|
|
294
|
+
});
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
return true;
|
|
298
|
+
};
|
|
299
|
+
const addUsage = (node, index, at, uses, conditional) => {
|
|
300
|
+
graph.sequence.push({
|
|
301
|
+
node,
|
|
302
|
+
index,
|
|
303
|
+
path: at,
|
|
304
|
+
conditional,
|
|
305
|
+
...splitActionRef(uses)
|
|
306
|
+
});
|
|
307
|
+
};
|
|
308
|
+
/** Flatten a job's steps (and composite action steps) into `node`. */
|
|
309
|
+
const addSteps = async (steps, node, at, counter, stack, workflowRoot, conditional) => {
|
|
310
|
+
for (const [i, step] of steps.entries()) {
|
|
311
|
+
const stepPath = `${at}steps[${i}]`;
|
|
312
|
+
if (!step.uses) {
|
|
313
|
+
counter.next++;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
const stepConditional = conditional || hasCondition(step.if);
|
|
317
|
+
addUsage(node, counter.next++, stepPath, step.uses, stepConditional);
|
|
318
|
+
const file = await resolveRef(step.uses, 'action', workflowRoot, stepPath);
|
|
319
|
+
if (!file || !canEnter(file, stack, stepPath))
|
|
320
|
+
continue;
|
|
321
|
+
const action = parse(file, stepPath);
|
|
322
|
+
if (action?.type === 'action' && action.file.runs.using === 'composite') {
|
|
323
|
+
await addSteps(action.file.runs.steps ?? [], node, `${stepPath} > ${step.uses} > `, counter, [...stack, file], workflowRoot, stepConditional);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
/**
|
|
328
|
+
* Add a workflow's jobs. Every job also runs after `after` (the jobs the
|
|
329
|
+
* reusable-workflow call itself needs, plus the call). Returns the ids
|
|
330
|
+
* of the added jobs.
|
|
331
|
+
*/
|
|
332
|
+
const addWorkflow = async (workflow, workflowRoot, prefix, labelPrefix, at, after, stack, conditional) => {
|
|
333
|
+
const added = [];
|
|
334
|
+
// A job id -> the graph nodes standing for it (a reusable-workflow call
|
|
335
|
+
// stands for the call and all of its called jobs).
|
|
336
|
+
const alias = new Map();
|
|
337
|
+
for (const jobId of (0, job_plan_1.topologicalJobOrder)(workflow.jobs)) {
|
|
338
|
+
const job = workflow.jobs[jobId];
|
|
339
|
+
const node = `${prefix}${jobId}`;
|
|
340
|
+
const needs = new Set(after);
|
|
341
|
+
for (const dep of [job.needs ?? []].flat()) {
|
|
342
|
+
for (const id of alias.get(dep) ?? [])
|
|
343
|
+
needs.add(id);
|
|
344
|
+
}
|
|
345
|
+
graph.nodes.set(node, {
|
|
346
|
+
label: `${labelPrefix}${jobId}`,
|
|
347
|
+
needs,
|
|
348
|
+
length: 0
|
|
349
|
+
});
|
|
350
|
+
added.push(node);
|
|
351
|
+
const jobPath = `${at}jobs.${jobId}`;
|
|
352
|
+
const jobConditional = conditional || hasCondition(job.if);
|
|
353
|
+
if (job.uses) {
|
|
354
|
+
// The call itself is a one-step "job" its called jobs run after.
|
|
355
|
+
addUsage(node, 0, jobPath, job.uses, jobConditional);
|
|
356
|
+
graph.nodes.get(node).length = 1;
|
|
357
|
+
const ids = [node];
|
|
358
|
+
const file = await resolveRef(job.uses, 'workflow', workflowRoot, jobPath);
|
|
359
|
+
if (file && canEnter(file, stack, jobPath)) {
|
|
360
|
+
const called = parse(file, jobPath);
|
|
361
|
+
if (called?.type === 'workflow') {
|
|
362
|
+
ids.push(...(await addWorkflow(called.file, (0, repository_1.repositoryRoot)(file), `${node}>`, `${labelPrefix}${jobId} > ${job.uses} > `, `${jobPath} > ${job.uses} > `, [...needs, node], [...stack, file], jobConditional)));
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
alias.set(jobId, ids);
|
|
366
|
+
added.push(...ids.slice(1));
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
alias.set(jobId, [node]);
|
|
370
|
+
const counter = { next: 0 };
|
|
371
|
+
await addSteps(job.steps ?? [], node, `${jobPath}.`, counter, stack, workflowRoot, jobConditional);
|
|
372
|
+
graph.nodes.get(node).length = counter.next;
|
|
373
|
+
}
|
|
374
|
+
return added;
|
|
375
|
+
};
|
|
376
|
+
await addWorkflow(parsed.file, root, '', '', '', [], [path.resolve(parsed.path)], false);
|
|
377
|
+
return graph;
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Whether an `if:` can skip the step/job: anything but a missing value,
|
|
381
|
+
* `true`, `success()` (the default) or `always()`.
|
|
382
|
+
*/
|
|
383
|
+
function hasCondition(condition) {
|
|
384
|
+
if (condition === undefined || condition === null || condition === true) {
|
|
385
|
+
return false;
|
|
386
|
+
}
|
|
387
|
+
const text = String(condition)
|
|
388
|
+
.trim()
|
|
389
|
+
.replace(/^\$\{\{([\s\S]*)\}\}$/, '$1')
|
|
390
|
+
.trim();
|
|
391
|
+
return !['', 'true', 'success()', 'always()'].includes(text);
|
|
392
|
+
}
|
|
393
|
+
function splitActionRef(uses) {
|
|
394
|
+
const atIndex = uses.lastIndexOf('@');
|
|
395
|
+
if (atIndex === -1 || uses.startsWith('docker://')) {
|
|
396
|
+
return { name: normalizeActionName(uses) };
|
|
397
|
+
}
|
|
398
|
+
return {
|
|
399
|
+
name: normalizeActionName(uses.slice(0, atIndex)),
|
|
400
|
+
version: uses.slice(atIndex + 1)
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Lower-case the `owner/repo` part of a marketplace-style reference, which
|
|
405
|
+
* GitHub resolves case-insensitively. A path inside the repo, local paths
|
|
406
|
+
* (`./...`) and `docker://` references (whose tags are case-sensitive) are
|
|
407
|
+
* kept as they are.
|
|
408
|
+
*/
|
|
409
|
+
function normalizeActionName(name) {
|
|
410
|
+
if (name.startsWith('.') || name.startsWith('docker://'))
|
|
411
|
+
return name;
|
|
412
|
+
const parts = name.split('/');
|
|
413
|
+
return [
|
|
414
|
+
...parts.slice(0, 2).map((p) => p.toLowerCase()),
|
|
415
|
+
...parts.slice(2)
|
|
416
|
+
].join('/');
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Best-effort numeric semver-style comparison (ignoring a leading "v").
|
|
420
|
+
* Returns undefined when either version string isn't numeric dot-notation
|
|
421
|
+
* (e.g. a branch name or commit SHA), since it can't be meaningfully compared.
|
|
422
|
+
*/
|
|
423
|
+
function isVersionAtLeast(actual, min) {
|
|
424
|
+
const a = parseVersionParts(actual);
|
|
425
|
+
const m = parseVersionParts(min);
|
|
426
|
+
if (!a || !m)
|
|
427
|
+
return undefined;
|
|
428
|
+
const len = Math.max(a.length, m.length);
|
|
429
|
+
for (let i = 0; i < len; i++) {
|
|
430
|
+
const av = a[i] ?? 0;
|
|
431
|
+
const mv = m[i] ?? 0;
|
|
432
|
+
if (av > mv)
|
|
433
|
+
return true;
|
|
434
|
+
if (av < mv)
|
|
435
|
+
return false;
|
|
436
|
+
}
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
439
|
+
function parseVersionParts(version) {
|
|
440
|
+
const cleaned = version.trim().replace(/^v/i, '');
|
|
441
|
+
if (!cleaned)
|
|
442
|
+
return null;
|
|
443
|
+
const parts = cleaned.split('.').map(Number);
|
|
444
|
+
if (parts.some((n) => Number.isNaN(n)))
|
|
445
|
+
return null;
|
|
446
|
+
return parts;
|
|
447
|
+
}
|
|
448
|
+
//# sourceMappingURL=pipeline-contract-validator.js.map
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.workflowExpressionErrors = workflowExpressionErrors;
|
|
4
|
+
exports.actionExpressionErrors = actionExpressionErrors;
|
|
5
|
+
exports.validateWorkflow = validateWorkflow;
|
|
6
|
+
exports.validateAction = validateAction;
|
|
7
|
+
const workflow_parser_1 = require("./workflow-parser");
|
|
8
|
+
const expression_1 = require("../runner/expression");
|
|
9
|
+
/**
|
|
10
|
+
* Invalid `if:` expressions in a workflow: syntax errors, or contexts and
|
|
11
|
+
* functions not available where they're used. GitHub rejects such a
|
|
12
|
+
* workflow before running any job.
|
|
13
|
+
*/
|
|
14
|
+
function workflowExpressionErrors(workflow) {
|
|
15
|
+
const errors = [];
|
|
16
|
+
const check = (condition, location, at) => {
|
|
17
|
+
const message = (0, expression_1.validateExpression)(condition, location);
|
|
18
|
+
if (message)
|
|
19
|
+
errors.push({ path: at, message, severity: 'error' });
|
|
20
|
+
};
|
|
21
|
+
for (const [jobId, job] of Object.entries(workflow.jobs ?? {})) {
|
|
22
|
+
check(job.if, 'job-if', `jobs.${jobId}.if`);
|
|
23
|
+
(job.steps ?? []).forEach((step, i) => check(step.if, 'step-if', `jobs.${jobId}.steps[${i}].if`));
|
|
24
|
+
}
|
|
25
|
+
return errors;
|
|
26
|
+
}
|
|
27
|
+
/** Invalid `if:` expressions in a composite action's steps. */
|
|
28
|
+
function actionExpressionErrors(action) {
|
|
29
|
+
const errors = [];
|
|
30
|
+
(action.runs?.steps ?? []).forEach((step, i) => {
|
|
31
|
+
const message = (0, expression_1.validateExpression)(step.if, 'composite-step-if');
|
|
32
|
+
if (message) {
|
|
33
|
+
errors.push({ path: `runs.steps[${i}].if`, message, severity: 'error' });
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
return errors;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Validate a workflow file
|
|
40
|
+
*/
|
|
41
|
+
function validateWorkflow(filePath) {
|
|
42
|
+
const errors = [];
|
|
43
|
+
try {
|
|
44
|
+
const parsed = (0, workflow_parser_1.parseWorkflow)(filePath);
|
|
45
|
+
const workflow = parsed.file;
|
|
46
|
+
// Validate name
|
|
47
|
+
if (!workflow.name || workflow.name.trim() === '') {
|
|
48
|
+
errors.push({
|
|
49
|
+
path: 'name',
|
|
50
|
+
message: 'Workflow name is required',
|
|
51
|
+
severity: 'error'
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
// Validate trigger
|
|
55
|
+
if (!workflow.on) {
|
|
56
|
+
errors.push({
|
|
57
|
+
path: 'on',
|
|
58
|
+
message: 'Workflow trigger (on) is required',
|
|
59
|
+
severity: 'error'
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
// Validate jobs
|
|
63
|
+
if (!workflow.jobs || Object.keys(workflow.jobs).length === 0) {
|
|
64
|
+
errors.push({
|
|
65
|
+
path: 'jobs',
|
|
66
|
+
message: 'At least one job is required',
|
|
67
|
+
severity: 'error'
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
// Validate each job
|
|
72
|
+
Object.entries(workflow.jobs).forEach(([jobId, job]) => {
|
|
73
|
+
// Check if it's a reusable workflow call
|
|
74
|
+
if (job.uses) {
|
|
75
|
+
// Reusable workflow - only needs 'uses'
|
|
76
|
+
if (!job.uses.trim()) {
|
|
77
|
+
errors.push({
|
|
78
|
+
path: `jobs.${jobId}.uses`,
|
|
79
|
+
message: 'Reusable workflow path cannot be empty',
|
|
80
|
+
severity: 'error'
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
// Regular job - needs runs-on and steps
|
|
86
|
+
if (!job['runs-on']) {
|
|
87
|
+
errors.push({
|
|
88
|
+
path: `jobs.${jobId}.runs-on`,
|
|
89
|
+
message: 'Job must specify runs-on',
|
|
90
|
+
severity: 'error'
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
if (!job.steps || job.steps.length === 0) {
|
|
94
|
+
errors.push({
|
|
95
|
+
path: `jobs.${jobId}.steps`,
|
|
96
|
+
message: 'Job must have at least one step',
|
|
97
|
+
severity: 'error'
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
// Validate each step
|
|
102
|
+
job.steps.forEach((step, idx) => {
|
|
103
|
+
if (!step.uses && !step.run) {
|
|
104
|
+
errors.push({
|
|
105
|
+
path: `jobs.${jobId}.steps[${idx}]`,
|
|
106
|
+
message: 'Step must have either "uses" or "run"',
|
|
107
|
+
severity: 'error'
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
if (step.uses && step.run) {
|
|
111
|
+
errors.push({
|
|
112
|
+
path: `jobs.${jobId}.steps[${idx}]`,
|
|
113
|
+
message: 'Step cannot have both "uses" and "run"',
|
|
114
|
+
severity: 'error'
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
errors.push(...workflowExpressionErrors(workflow));
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
errors.push({
|
|
126
|
+
path: 'root',
|
|
127
|
+
message: error instanceof Error ? error.message : 'Unknown error',
|
|
128
|
+
severity: 'error'
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
valid: errors.length === 0,
|
|
133
|
+
errors
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Validate an action file
|
|
138
|
+
*/
|
|
139
|
+
function validateAction(filePath) {
|
|
140
|
+
const errors = [];
|
|
141
|
+
try {
|
|
142
|
+
const parsed = (0, workflow_parser_1.parseAction)(filePath);
|
|
143
|
+
const action = parsed.file;
|
|
144
|
+
// Validate name
|
|
145
|
+
if (!action.name || action.name.trim() === '') {
|
|
146
|
+
errors.push({
|
|
147
|
+
path: 'name',
|
|
148
|
+
message: 'Action name is required',
|
|
149
|
+
severity: 'error'
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
// Validate description
|
|
153
|
+
if (!action.description || action.description.trim() === '') {
|
|
154
|
+
errors.push({
|
|
155
|
+
path: 'description',
|
|
156
|
+
message: 'Action description is required',
|
|
157
|
+
severity: 'error'
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
// Validate runs
|
|
161
|
+
if (!action.runs) {
|
|
162
|
+
errors.push({
|
|
163
|
+
path: 'runs',
|
|
164
|
+
message: 'Action must have a runs section',
|
|
165
|
+
severity: 'error'
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
if (!action.runs.using) {
|
|
170
|
+
errors.push({
|
|
171
|
+
path: 'runs.using',
|
|
172
|
+
message: 'Action must specify runs.using',
|
|
173
|
+
severity: 'error'
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
// Validate composite actions
|
|
177
|
+
if (action.runs.using === 'composite') {
|
|
178
|
+
if (!action.runs.steps || action.runs.steps.length === 0) {
|
|
179
|
+
errors.push({
|
|
180
|
+
path: 'runs.steps',
|
|
181
|
+
message: 'Composite action must have at least one step',
|
|
182
|
+
severity: 'error'
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
// Validate each step
|
|
187
|
+
action.runs.steps.forEach((step, idx) => {
|
|
188
|
+
if (!step.uses && !step.run) {
|
|
189
|
+
errors.push({
|
|
190
|
+
path: `runs.steps[${idx}]`,
|
|
191
|
+
message: 'Step must have either "uses" or "run"',
|
|
192
|
+
severity: 'error'
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
if (step.run && !step.shell) {
|
|
196
|
+
errors.push({
|
|
197
|
+
path: `runs.steps[${idx}]`,
|
|
198
|
+
message: 'Composite action step with "run" must specify shell',
|
|
199
|
+
severity: 'error'
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
errors.push(...actionExpressionErrors(action));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// Validate inputs
|
|
208
|
+
if (action.inputs) {
|
|
209
|
+
Object.entries(action.inputs).forEach(([inputName, input]) => {
|
|
210
|
+
if (!input.description) {
|
|
211
|
+
errors.push({
|
|
212
|
+
path: `inputs.${inputName}.description`,
|
|
213
|
+
message: 'Input description is required',
|
|
214
|
+
severity: 'warning'
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
errors.push({
|
|
222
|
+
path: 'root',
|
|
223
|
+
message: error instanceof Error ? error.message : 'Unknown error',
|
|
224
|
+
severity: 'error'
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
valid: errors.length === 0 || errors.every((e) => e.severity === 'warning'),
|
|
229
|
+
errors
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
//# sourceMappingURL=validator.js.map
|