@secureci/cli 1.0.1 → 1.2.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/README.md +26 -11
- package/dist/advisories.js +72 -12
- package/dist/cli.js +53 -1
- package/dist/config.js +24 -1
- package/dist/org-scan-script.js +6 -0
- package/dist/org-scan.js +88 -0
- package/dist/parser/pipeline-contract-validator.js +152 -24
- package/dist/runner/action-fetcher.js +6 -1
- package/dist/runner/actions.js +10 -8
- package/dist/runner/checkout.js +225 -9
- package/dist/runner/container-policy.js +32 -0
- package/dist/runner/container-runtime.js +74 -1
- package/dist/runner/docker-action.js +1 -1
- package/dist/runner/executor.js +78 -8
- package/dist/runner/expression.js +165 -3
- package/dist/runner/job-container.js +147 -14
- package/dist/runner/job-runtime.js +4 -3
- package/dist/runner/node-action.js +1 -1
- package/dist/runner/runner-env.js +20 -0
- package/dist/runner/scheduler.js +84 -2
- package/dist/runner/steps.js +2 -2
- package/package.json +1 -1
|
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.invalidImageReason = invalidImageReason;
|
|
37
37
|
exports.unsafeServiceOptions = unsafeServiceOptions;
|
|
38
|
+
exports.splitReservedOptions = splitReservedOptions;
|
|
38
39
|
exports.unsafeServiceVolumeReason = unsafeServiceVolumeReason;
|
|
39
40
|
const fs = __importStar(require("fs"));
|
|
40
41
|
const path = __importStar(require("path"));
|
|
@@ -125,6 +126,37 @@ function unsafeServiceOptions(args) {
|
|
|
125
126
|
}
|
|
126
127
|
return found;
|
|
127
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* Split `args` into the options to pass on and the `reserved` ones (by
|
|
131
|
+
* flag name, in order), dropping a reserved option's value too. Values of
|
|
132
|
+
* other options are kept as they are, so `--health-cmd --entrypoint` stays.
|
|
133
|
+
*/
|
|
134
|
+
function splitReservedOptions(args, reserved) {
|
|
135
|
+
const kept = [];
|
|
136
|
+
const dropped = [];
|
|
137
|
+
for (let i = 0; i < args.length; i++) {
|
|
138
|
+
const arg = args[i];
|
|
139
|
+
let name;
|
|
140
|
+
let takesNext = false;
|
|
141
|
+
if (arg.startsWith('--')) {
|
|
142
|
+
const [flag, value] = arg.split(/=(.*)/s);
|
|
143
|
+
name = flag;
|
|
144
|
+
takesNext = value === undefined && VALUE_OPTIONS.has(flag);
|
|
145
|
+
}
|
|
146
|
+
else if (arg.startsWith('-') && arg.length > 1) {
|
|
147
|
+
name = arg.slice(0, 2);
|
|
148
|
+
takesNext = arg.length === 2 && SHORT_VALUE_OPTIONS.has(name);
|
|
149
|
+
}
|
|
150
|
+
const span = takesNext ? args.slice(i, i + 2) : [arg];
|
|
151
|
+
if (name !== undefined && reserved.has(name))
|
|
152
|
+
dropped.push(name);
|
|
153
|
+
else
|
|
154
|
+
kept.push(...span);
|
|
155
|
+
if (takesNext)
|
|
156
|
+
i++;
|
|
157
|
+
}
|
|
158
|
+
return { kept, dropped };
|
|
159
|
+
}
|
|
128
160
|
const VOLUME_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
|
129
161
|
function realpathOrSelf(p) {
|
|
130
162
|
try {
|
|
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.CONTAINER_EXCHANGE_DIR = exports.CONTAINER_WORKDIR = void 0;
|
|
37
|
+
exports.registryOf = registryOf;
|
|
37
38
|
exports.resolveContainerRuntime = resolveContainerRuntime;
|
|
38
39
|
exports._resetRuntimeCacheForTests = _resetRuntimeCacheForTests;
|
|
39
40
|
const child_process_1 = require("child_process");
|
|
@@ -50,6 +51,18 @@ const RUNTIME_BINARIES = {
|
|
|
50
51
|
podman: 'podman',
|
|
51
52
|
containerd: 'nerdctl'
|
|
52
53
|
};
|
|
54
|
+
/**
|
|
55
|
+
* The registry an image reference pulls from: its first path component
|
|
56
|
+
* when that names a host (has a `.` or `:`, or is `localhost`), else
|
|
57
|
+
* Docker Hub.
|
|
58
|
+
*/
|
|
59
|
+
function registryOf(image) {
|
|
60
|
+
const slash = image.indexOf('/');
|
|
61
|
+
if (slash === -1)
|
|
62
|
+
return 'docker.io';
|
|
63
|
+
const first = image.slice(0, slash);
|
|
64
|
+
return /[.:]/.test(first) || first === 'localhost' ? first : 'docker.io';
|
|
65
|
+
}
|
|
53
66
|
exports.CONTAINER_WORKDIR = '/secureci-workspace';
|
|
54
67
|
exports.CONTAINER_EXCHANGE_DIR = '/secureci-exchange';
|
|
55
68
|
/**
|
|
@@ -232,6 +245,7 @@ class CliContainerRuntime {
|
|
|
232
245
|
'exec',
|
|
233
246
|
'-w',
|
|
234
247
|
options.workingDir || exports.CONTAINER_WORKDIR,
|
|
248
|
+
...(options.user ? ['-u', options.user] : []),
|
|
235
249
|
...env.args
|
|
236
250
|
];
|
|
237
251
|
if (options.args) {
|
|
@@ -294,7 +308,64 @@ class CliContainerRuntime {
|
|
|
294
308
|
// Best effort: the container may already be gone
|
|
295
309
|
}
|
|
296
310
|
}
|
|
297
|
-
|
|
311
|
+
async pull(image, auth) {
|
|
312
|
+
const invalid = (0, container_policy_1.invalidImageReason)(image);
|
|
313
|
+
if (invalid)
|
|
314
|
+
return { success: false, error: invalid };
|
|
315
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'secureci-auth-'));
|
|
316
|
+
fs.chmodSync(dir, 0o700);
|
|
317
|
+
try {
|
|
318
|
+
const clientEnv = { ...process.env };
|
|
319
|
+
if (this.name === 'podman') {
|
|
320
|
+
clientEnv.REGISTRY_AUTH_FILE = path.join(dir, 'auth.json');
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
clientEnv.DOCKER_CONFIG = dir;
|
|
324
|
+
// A docker context lives in the user's config dir: pin its
|
|
325
|
+
// endpoint so the private config dir talks to the same engine.
|
|
326
|
+
if (this.name === 'docker' && !process.env.DOCKER_HOST) {
|
|
327
|
+
const context = await this.runCli([
|
|
328
|
+
'context',
|
|
329
|
+
'inspect',
|
|
330
|
+
'--format',
|
|
331
|
+
'{{.Endpoints.docker.Host}}'
|
|
332
|
+
]);
|
|
333
|
+
const host = context.stdout.trim();
|
|
334
|
+
if (context.success && host)
|
|
335
|
+
clientEnv.DOCKER_HOST = host;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const transport = {
|
|
339
|
+
args: [],
|
|
340
|
+
clientEnv,
|
|
341
|
+
cleanup: () => { }
|
|
342
|
+
};
|
|
343
|
+
const login = await this.runCli([
|
|
344
|
+
'login',
|
|
345
|
+
'--username',
|
|
346
|
+
auth.username,
|
|
347
|
+
'--password-stdin',
|
|
348
|
+
auth.registry
|
|
349
|
+
], undefined, transport, auth.password);
|
|
350
|
+
if (!login.success) {
|
|
351
|
+
return {
|
|
352
|
+
success: false,
|
|
353
|
+
error: `login to ${auth.registry} failed: ${login.stderr.trim() || `exit code ${login.exitCode}`}`
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
const pulled = await this.runCli(['pull', image], undefined, transport);
|
|
357
|
+
return pulled.success
|
|
358
|
+
? { success: true }
|
|
359
|
+
: {
|
|
360
|
+
success: false,
|
|
361
|
+
error: pulled.stderr.trim() || `exit code ${pulled.exitCode}`
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
finally {
|
|
365
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
runCli(args, timeoutMs, env, input) {
|
|
298
369
|
const startTime = Date.now();
|
|
299
370
|
return new Promise((resolve) => {
|
|
300
371
|
let child;
|
|
@@ -305,6 +376,8 @@ class CliContainerRuntime {
|
|
|
305
376
|
env?.cleanup();
|
|
306
377
|
throw err;
|
|
307
378
|
}
|
|
379
|
+
if (input !== undefined)
|
|
380
|
+
child.stdin.end(input);
|
|
308
381
|
let stdout = '';
|
|
309
382
|
let stderr = '';
|
|
310
383
|
let timedOut = false;
|
|
@@ -131,7 +131,7 @@ async function executeDockerAction(action, actionPath, step, actionInputs, calle
|
|
|
131
131
|
image,
|
|
132
132
|
args,
|
|
133
133
|
entrypoint: action.runs.entrypoint,
|
|
134
|
-
workingDir:
|
|
134
|
+
workingDir: runtime.workspace,
|
|
135
135
|
readOnlyPaths: (0, job_container_1.protectedWorkspacePaths)(options),
|
|
136
136
|
exchangeDir,
|
|
137
137
|
env: {
|
package/dist/runner/executor.js
CHANGED
|
@@ -49,6 +49,7 @@ const job_plan_1 = require("./job-plan");
|
|
|
49
49
|
const job_runtime_1 = require("./job-runtime");
|
|
50
50
|
const job_container_1 = require("./job-container");
|
|
51
51
|
const steps_1 = require("./steps");
|
|
52
|
+
const checkout_1 = require("./checkout");
|
|
52
53
|
const actions_1 = require("./actions");
|
|
53
54
|
const scheduler_1 = require("./scheduler");
|
|
54
55
|
// Public API, kept here so `executor` stays the entry point.
|
|
@@ -161,6 +162,20 @@ async function executeWorkflow(filePath, options = {}, internal = {}) {
|
|
|
161
162
|
return { success: false, jobs: [], duration: Date.now() - startTime };
|
|
162
163
|
}
|
|
163
164
|
logger_1.logger.info(`Executing workflow: ${parsed.file.name}`);
|
|
165
|
+
const concurrencyMode = options.concurrency ?? 'serialize';
|
|
166
|
+
if (parsed.file.concurrency && !internal.groups) {
|
|
167
|
+
logger_1.logger.info("workflow-level concurrency: isn't applied in a local run: there are no other runs to queue behind or cancel");
|
|
168
|
+
}
|
|
169
|
+
const notCancelling = Object.entries(parsed.file.jobs)
|
|
170
|
+
.filter(([, job]) => typeof job.concurrency === 'object' &&
|
|
171
|
+
job.concurrency['cancel-in-progress'] !== undefined &&
|
|
172
|
+
job.concurrency['cancel-in-progress'] !== false)
|
|
173
|
+
.map(([id]) => `jobs.${id}`);
|
|
174
|
+
if (concurrencyMode === 'serialize' && notCancelling.length > 0) {
|
|
175
|
+
logger_1.logger.info(`cancel-in-progress isn't applied with --concurrency serialize (${notCancelling.join(', ')}): jobs in a group run one at a time, all of them; pass --concurrency github to cancel as GitHub does`);
|
|
176
|
+
}
|
|
177
|
+
const groups = internal.groups ??
|
|
178
|
+
new scheduler_1.ConcurrencyGroups(concurrencyMode, (message) => logger_1.logger.info(message));
|
|
164
179
|
options = {
|
|
165
180
|
...options,
|
|
166
181
|
inputs: {
|
|
@@ -258,6 +273,40 @@ async function executeWorkflow(filePath, options = {}, internal = {}) {
|
|
|
258
273
|
// Each combination's result and outputs, by combination index.
|
|
259
274
|
const unitResults = [];
|
|
260
275
|
const unitOutputs = [];
|
|
276
|
+
// `concurrency:`: each combination waits for its group (with the
|
|
277
|
+
// group evaluated for its matrix values) before taking a slot.
|
|
278
|
+
const concurrencySpec = typeof job.concurrency === 'string'
|
|
279
|
+
? { group: job.concurrency }
|
|
280
|
+
: job.concurrency;
|
|
281
|
+
const concurrencyGate = concurrencySpec
|
|
282
|
+
? {
|
|
283
|
+
gate: (index, abort) => {
|
|
284
|
+
const values = selected[index].values;
|
|
285
|
+
const ctx = { ...jobIfCtx, matrix: values };
|
|
286
|
+
let group = '';
|
|
287
|
+
try {
|
|
288
|
+
group = (0, expression_1.substituteExpressions)(String(concurrencySpec.group ?? ''), ctx).trim();
|
|
289
|
+
}
|
|
290
|
+
catch (error) {
|
|
291
|
+
logger_1.logger.warn(`jobs.${jobId}.concurrency: ${error instanceof Error ? error.message : String(error)}; running without a group`);
|
|
292
|
+
}
|
|
293
|
+
if (!group)
|
|
294
|
+
return Promise.resolve(() => undefined);
|
|
295
|
+
return groups.enter(group, (0, job_plan_1.isTruthySetting)(concurrencySpec['cancel-in-progress'], ctx), abort, labelOf(values));
|
|
296
|
+
},
|
|
297
|
+
onGateCancelled: (index) => {
|
|
298
|
+
const { index: combinationIndex, values } = selected[index];
|
|
299
|
+
unitResults[combinationIndex] = {
|
|
300
|
+
job: labelOf(values),
|
|
301
|
+
success: false,
|
|
302
|
+
cancelled: true,
|
|
303
|
+
steps: [],
|
|
304
|
+
duration: 0
|
|
305
|
+
};
|
|
306
|
+
return { result: 'cancelled' };
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
: {};
|
|
261
310
|
let outcomes;
|
|
262
311
|
if (job.uses) {
|
|
263
312
|
// A reusable workflow call (once per matrix combination). The
|
|
@@ -266,7 +315,7 @@ async function executeWorkflow(filePath, options = {}, internal = {}) {
|
|
|
266
315
|
const { index: combinationIndex, values: matrixValues } = selected[index];
|
|
267
316
|
const label = labelOf(matrixValues);
|
|
268
317
|
return tagged(label, async () => {
|
|
269
|
-
const reusableResult = await executeReusableWorkflowJob(label, job, filePath, options, { ...jobIfCtx, matrix: matrixValues }, { slots, signal });
|
|
318
|
+
const reusableResult = await executeReusableWorkflowJob(label, job, filePath, options, { ...jobIfCtx, matrix: matrixValues }, { slots, signal, groups });
|
|
270
319
|
const cancelled = !reusableResult.success && signal.aborted;
|
|
271
320
|
unitResults[combinationIndex] = cancelled
|
|
272
321
|
? { ...reusableResult, cancelled: true }
|
|
@@ -281,7 +330,12 @@ async function executeWorkflow(filePath, options = {}, internal = {}) {
|
|
|
281
330
|
: 'failure'
|
|
282
331
|
};
|
|
283
332
|
}, true);
|
|
284
|
-
}, {
|
|
333
|
+
}, {
|
|
334
|
+
failFast,
|
|
335
|
+
maxParallel,
|
|
336
|
+
signal: internal.signal,
|
|
337
|
+
...concurrencyGate
|
|
338
|
+
});
|
|
285
339
|
}
|
|
286
340
|
else {
|
|
287
341
|
// Outputs so far, as each combination's result reports them.
|
|
@@ -291,11 +345,20 @@ async function executeWorkflow(filePath, options = {}, internal = {}) {
|
|
|
291
345
|
// its running step killed and its containers removed.
|
|
292
346
|
const runCombination = async (jobIndex, matrixValues, signal) => {
|
|
293
347
|
const jobLabel = labelOf(matrixValues);
|
|
294
|
-
|
|
295
|
-
|
|
348
|
+
// With --isolate-workspace, the job (and github.workspace) gets
|
|
349
|
+
// its own copy of the working copy.
|
|
350
|
+
const workspace = options.isolateWorkspace
|
|
351
|
+
? (0, checkout_1.createIsolatedWorkspace)(process.cwd(), runDir)
|
|
352
|
+
: process.cwd();
|
|
353
|
+
const comboGithub = options.isolateWorkspace
|
|
354
|
+
? { ...github, workspace }
|
|
355
|
+
: github;
|
|
356
|
+
const runtime = (0, job_runtime_1.createJobRuntime)(comboGithub, runDir, {
|
|
357
|
+
shell: job.defaults?.run?.shell ??
|
|
358
|
+
parsed.file.defaults?.run?.shell,
|
|
296
359
|
workingDirectory: job.defaults?.run?.['working-directory'] ??
|
|
297
360
|
parsed.file.defaults?.run?.['working-directory']
|
|
298
|
-
});
|
|
361
|
+
}, workspace);
|
|
299
362
|
runtime.signal = signal;
|
|
300
363
|
// Workflow-level env can't reference `env` itself; job-level
|
|
301
364
|
// env sees the workflow env plus matrix (matching GitHub's
|
|
@@ -303,17 +366,17 @@ async function executeWorkflow(filePath, options = {}, internal = {}) {
|
|
|
303
366
|
const workflowEnv = {
|
|
304
367
|
...options.env,
|
|
305
368
|
...(0, expression_1.substituteRecord)(parsed.file.env, {
|
|
306
|
-
...(0, expression_1.createContext)({ ...options.env }, inputs, options.secrets,
|
|
369
|
+
...(0, expression_1.createContext)({ ...options.env }, inputs, options.secrets, comboGithub),
|
|
307
370
|
vars
|
|
308
371
|
})
|
|
309
372
|
};
|
|
310
373
|
const jobEnv = (0, expression_1.substituteRecord)(job.env, {
|
|
311
|
-
...(0, expression_1.createContext)(workflowEnv, inputs, options.secrets,
|
|
374
|
+
...(0, expression_1.createContext)(workflowEnv, inputs, options.secrets, comboGithub, matrixValues),
|
|
312
375
|
needs,
|
|
313
376
|
vars
|
|
314
377
|
});
|
|
315
378
|
const jobCtx = {
|
|
316
|
-
...(0, expression_1.createContext)({ ...workflowEnv, ...jobEnv }, inputs, options.secrets,
|
|
379
|
+
...(0, expression_1.createContext)({ ...workflowEnv, ...jobEnv }, inputs, options.secrets, comboGithub, matrixValues),
|
|
317
380
|
needs,
|
|
318
381
|
vars,
|
|
319
382
|
job: { status: 'success' },
|
|
@@ -521,11 +584,17 @@ async function executeWorkflow(filePath, options = {}, internal = {}) {
|
|
|
521
584
|
finally {
|
|
522
585
|
signal.removeEventListener('abort', onAbort);
|
|
523
586
|
await stopping;
|
|
587
|
+
if (options.isolateWorkspace) {
|
|
588
|
+
await (0, job_container_1.clearIsolatedWorkspace)(runtime);
|
|
589
|
+
}
|
|
524
590
|
await (0, job_container_1.stopJobContainer)(runtime);
|
|
525
591
|
}
|
|
526
592
|
}
|
|
527
593
|
finally {
|
|
528
594
|
await stopping;
|
|
595
|
+
if (options.isolateWorkspace) {
|
|
596
|
+
(0, checkout_1.removeIsolatedWorkspace)(workspace);
|
|
597
|
+
}
|
|
529
598
|
}
|
|
530
599
|
const jobSuccess = stepResults.every((s) => s.success || s.step['continue-on-error']);
|
|
531
600
|
// Job-level continue-on-error (often
|
|
@@ -555,6 +624,7 @@ async function executeWorkflow(filePath, options = {}, internal = {}) {
|
|
|
555
624
|
maxParallel,
|
|
556
625
|
slots,
|
|
557
626
|
signal: internal.signal,
|
|
627
|
+
...concurrencyGate,
|
|
558
628
|
onFailFast: () => logger_1.logger.info(concurrent
|
|
559
629
|
? `Cancelling remaining matrix jobs for ${jobId} (fail-fast)`
|
|
560
630
|
: `Skipping remaining matrix jobs for ${jobId} (fail-fast)`)
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.createContext = createContext;
|
|
4
|
+
exports.knownPrefix = knownPrefix;
|
|
4
5
|
exports.resolveExpressionValue = resolveExpressionValue;
|
|
5
6
|
exports.substituteExpressions = substituteExpressions;
|
|
6
7
|
exports.substituteRecord = substituteRecord;
|
|
7
8
|
exports.withImplicitSuccess = withImplicitSuccess;
|
|
8
9
|
exports.evaluateCondition = evaluateCondition;
|
|
10
|
+
exports.staticCondition = staticCondition;
|
|
9
11
|
exports.ifEvaluationError = ifEvaluationError;
|
|
10
12
|
exports.validateExpression = validateExpression;
|
|
11
13
|
exports.expressionDataFlow = expressionDataFlow;
|
|
@@ -388,16 +390,69 @@ function callFunction(name, rawArgs, ctx) {
|
|
|
388
390
|
}
|
|
389
391
|
/** The `*` filter in `x.*` / `x[*]`, as opposed to a `'*'` key. */
|
|
390
392
|
const STAR = Symbol('*');
|
|
393
|
+
/**
|
|
394
|
+
* A value static evaluation (`staticCondition`) doesn't know, with its
|
|
395
|
+
* truthiness when that is known anyway (`unknown || true` is truthy).
|
|
396
|
+
*/
|
|
397
|
+
class Unknown {
|
|
398
|
+
constructor(truthy) {
|
|
399
|
+
this.truthy = truthy;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* A string static evaluation knows only the start of (lower-cased), e.g.
|
|
404
|
+
* `github.ref` on a tags-only push: `refs/tags/`. Decides `==` / `!=`
|
|
405
|
+
* against a literal that can't share the prefix and `startsWith`.
|
|
406
|
+
*/
|
|
407
|
+
class KnownPrefix extends Unknown {
|
|
408
|
+
constructor(prefix) {
|
|
409
|
+
super(prefix !== '' ? true : undefined);
|
|
410
|
+
this.prefix = prefix;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
/** A string known to start with `prefix` (compared case-insensitively). */
|
|
414
|
+
function knownPrefix(prefix) {
|
|
415
|
+
return new KnownPrefix(prefix.toLowerCase());
|
|
416
|
+
}
|
|
417
|
+
/** False when `a == b` can't hold because of a known prefix, else undefined. */
|
|
418
|
+
function prefixRulesOutEquality(a, b) {
|
|
419
|
+
const [known, other] = a instanceof KnownPrefix ? [a, unwrap(b)] : [b, unwrap(a)];
|
|
420
|
+
if (!(known instanceof KnownPrefix) || typeof other !== 'string') {
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
return !other.toLowerCase().startsWith(known.prefix);
|
|
424
|
+
}
|
|
425
|
+
/** Contexts an expression may name; static evaluation knows only some. */
|
|
426
|
+
const NAMED_CONTEXTS = new Set([
|
|
427
|
+
'env',
|
|
428
|
+
'inputs',
|
|
429
|
+
'secrets',
|
|
430
|
+
'steps',
|
|
431
|
+
'github',
|
|
432
|
+
'matrix',
|
|
433
|
+
'needs',
|
|
434
|
+
'job',
|
|
435
|
+
'runner',
|
|
436
|
+
'vars',
|
|
437
|
+
'strategy'
|
|
438
|
+
]);
|
|
439
|
+
/** Truthiness of a (possibly Unknown) value; undefined when unknown. */
|
|
440
|
+
function truthOf(value) {
|
|
441
|
+
return value instanceof Unknown ? value.truthy : toBoolean(unwrap(value));
|
|
442
|
+
}
|
|
391
443
|
class Parser {
|
|
392
444
|
/**
|
|
393
445
|
* With a scope, only check names and syntax; with `flow`, compute data
|
|
394
446
|
* flow (Flow values) instead. Either way nothing is evaluated.
|
|
395
447
|
*/
|
|
396
|
-
constructor(tokens, ctx, scope, flow = false
|
|
448
|
+
constructor(tokens, ctx, scope, flow = false,
|
|
449
|
+
/** Static evaluation: only these contexts are known (see Unknown). */
|
|
450
|
+
known) {
|
|
397
451
|
this.tokens = tokens;
|
|
398
452
|
this.ctx = ctx;
|
|
399
453
|
this.scope = scope;
|
|
400
454
|
this.flow = flow;
|
|
455
|
+
this.known = known;
|
|
401
456
|
this.pos = 0;
|
|
402
457
|
}
|
|
403
458
|
peek() {
|
|
@@ -429,6 +484,16 @@ class Parser {
|
|
|
429
484
|
while (this.isOp('||')) {
|
|
430
485
|
this.next();
|
|
431
486
|
const right = this.parseAnd();
|
|
487
|
+
if (this.known) {
|
|
488
|
+
const l = truthOf(left);
|
|
489
|
+
left =
|
|
490
|
+
l === true
|
|
491
|
+
? left
|
|
492
|
+
: l === false
|
|
493
|
+
? right
|
|
494
|
+
: new Unknown(truthOf(right) === true ? true : undefined);
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
432
497
|
left = this.flow
|
|
433
498
|
? Flow.union(left, right)
|
|
434
499
|
: toBoolean(unwrap(left))
|
|
@@ -442,6 +507,16 @@ class Parser {
|
|
|
442
507
|
while (this.isOp('&&')) {
|
|
443
508
|
this.next();
|
|
444
509
|
const right = this.parseEquality();
|
|
510
|
+
if (this.known) {
|
|
511
|
+
const l = truthOf(left);
|
|
512
|
+
left =
|
|
513
|
+
l === false
|
|
514
|
+
? left
|
|
515
|
+
: l === true
|
|
516
|
+
? right
|
|
517
|
+
: new Unknown(truthOf(right) === false ? false : undefined);
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
445
520
|
left = this.flow
|
|
446
521
|
? Flow.union(left, right)
|
|
447
522
|
: toBoolean(unwrap(left))
|
|
@@ -455,6 +530,14 @@ class Parser {
|
|
|
455
530
|
while (this.isOp('==', '!=')) {
|
|
456
531
|
const op = this.next().value;
|
|
457
532
|
const right = this.parseComparison();
|
|
533
|
+
if (prefixRulesOutEquality(left, right)) {
|
|
534
|
+
left = op !== '==';
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
if (left instanceof Unknown || right instanceof Unknown) {
|
|
538
|
+
left = new Unknown();
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
458
541
|
const eq = looseEquals(unwrap(left), unwrap(right));
|
|
459
542
|
left = op === '==' ? eq : !eq;
|
|
460
543
|
}
|
|
@@ -465,6 +548,10 @@ class Parser {
|
|
|
465
548
|
while (this.isOp('<', '<=', '>', '>=')) {
|
|
466
549
|
const op = this.next().value;
|
|
467
550
|
const right = this.parseUnary();
|
|
551
|
+
if (left instanceof Unknown || right instanceof Unknown) {
|
|
552
|
+
left = new Unknown();
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
468
555
|
const c = compare(unwrap(left), unwrap(right));
|
|
469
556
|
left =
|
|
470
557
|
!Number.isNaN(c) &&
|
|
@@ -481,7 +568,11 @@ class Parser {
|
|
|
481
568
|
parseUnary() {
|
|
482
569
|
if (this.isOp('!')) {
|
|
483
570
|
this.next();
|
|
484
|
-
|
|
571
|
+
const operand = this.parseUnary();
|
|
572
|
+
if (operand instanceof Unknown) {
|
|
573
|
+
return new Unknown(operand.truthy === undefined ? undefined : !operand.truthy);
|
|
574
|
+
}
|
|
575
|
+
return !toBoolean(unwrap(operand));
|
|
485
576
|
}
|
|
486
577
|
return this.parsePostfix(this.parsePrimary());
|
|
487
578
|
}
|
|
@@ -509,7 +600,12 @@ class Parser {
|
|
|
509
600
|
}
|
|
510
601
|
else {
|
|
511
602
|
const key = unwrap(this.parseOr());
|
|
512
|
-
value =
|
|
603
|
+
value =
|
|
604
|
+
key instanceof Unknown
|
|
605
|
+
? new Unknown()
|
|
606
|
+
: this.access(value, typeof key === 'number' || key instanceof Flow
|
|
607
|
+
? key
|
|
608
|
+
: toText(key));
|
|
513
609
|
}
|
|
514
610
|
this.expect('rbracket');
|
|
515
611
|
}
|
|
@@ -526,6 +622,12 @@ class Parser {
|
|
|
526
622
|
// `.*` and a computed key (`x[matrix.field]`) may be any key.
|
|
527
623
|
return value.member(key === STAR || key instanceof Flow ? '*' : String(key).toLowerCase());
|
|
528
624
|
}
|
|
625
|
+
if (this.known) {
|
|
626
|
+
if (value instanceof Unknown)
|
|
627
|
+
return new Unknown();
|
|
628
|
+
const result = key === STAR ? star(value) : member(value, key);
|
|
629
|
+
return result === undefined ? new Unknown() : result;
|
|
630
|
+
}
|
|
529
631
|
if (key === STAR)
|
|
530
632
|
return star(value);
|
|
531
633
|
return member(value, key);
|
|
@@ -566,6 +668,8 @@ class Parser {
|
|
|
566
668
|
}
|
|
567
669
|
if (this.flow)
|
|
568
670
|
return flowCall(tok.value, args);
|
|
671
|
+
if (this.known)
|
|
672
|
+
return this.staticCall(tok.value, args);
|
|
569
673
|
return callFunction(tok.value, args, this.ctx);
|
|
570
674
|
}
|
|
571
675
|
if (this.scope) {
|
|
@@ -576,12 +680,46 @@ class Parser {
|
|
|
576
680
|
}
|
|
577
681
|
if (this.flow)
|
|
578
682
|
return new Flow([[lower]]);
|
|
683
|
+
if (this.known) {
|
|
684
|
+
if (!NAMED_CONTEXTS.has(lower)) {
|
|
685
|
+
throw new Error(`Unrecognized named-value: '${tok.value}'`);
|
|
686
|
+
}
|
|
687
|
+
return lower in this.known ? this.known[lower] : new Unknown();
|
|
688
|
+
}
|
|
579
689
|
return rootContext(lower, this.ctx);
|
|
580
690
|
}
|
|
581
691
|
default:
|
|
582
692
|
throw new Error(`Unexpected token: ${tok.type}`);
|
|
583
693
|
}
|
|
584
694
|
}
|
|
695
|
+
/**
|
|
696
|
+
* A function call in static evaluation: `success()` and `always()` hold
|
|
697
|
+
* for a step that runs at all; other status functions, `hashFiles` and
|
|
698
|
+
* calls with an unknown argument are unknown.
|
|
699
|
+
*/
|
|
700
|
+
staticCall(name, args) {
|
|
701
|
+
checkCall(name, args.length);
|
|
702
|
+
const lower = name.toLowerCase();
|
|
703
|
+
if (lower === 'success' || lower === 'always')
|
|
704
|
+
return true;
|
|
705
|
+
if (lower === 'startswith' && args[0] instanceof KnownPrefix) {
|
|
706
|
+
const search = unwrap(args[1]);
|
|
707
|
+
if (typeof search === 'string') {
|
|
708
|
+
const { prefix } = args[0];
|
|
709
|
+
const wanted = search.toLowerCase();
|
|
710
|
+
if (prefix.startsWith(wanted))
|
|
711
|
+
return true;
|
|
712
|
+
if (!wanted.startsWith(prefix))
|
|
713
|
+
return false;
|
|
714
|
+
}
|
|
715
|
+
return new Unknown();
|
|
716
|
+
}
|
|
717
|
+
if (['failure', 'cancelled', 'hashfiles'].includes(lower) ||
|
|
718
|
+
args.some((arg) => arg instanceof Unknown)) {
|
|
719
|
+
return new Unknown();
|
|
720
|
+
}
|
|
721
|
+
return callFunction(name, args, this.ctx);
|
|
722
|
+
}
|
|
585
723
|
}
|
|
586
724
|
function evaluate(expr, ctx) {
|
|
587
725
|
return new Parser(tokenize(expr), ctx).parse();
|
|
@@ -670,6 +808,30 @@ function evaluateCondition(expr, ctx) {
|
|
|
670
808
|
// it fails the step or job), never a condition that passes.
|
|
671
809
|
return toBoolean(evaluate(trimmed, ctx));
|
|
672
810
|
}
|
|
811
|
+
/**
|
|
812
|
+
* Whether an `if:` holds when only the `known` contexts are known, e.g.
|
|
813
|
+
* `{ github: { event_name: 'push' } }`: true or false when that settles
|
|
814
|
+
* it, undefined when it depends on anything else (or can't be parsed).
|
|
815
|
+
* Used to check a contract without running the workflow.
|
|
816
|
+
*/
|
|
817
|
+
function staticCondition(expr, known) {
|
|
818
|
+
if (expr === undefined || typeof expr === 'boolean')
|
|
819
|
+
return expr ?? true;
|
|
820
|
+
let trimmed = expr.trim();
|
|
821
|
+
const wrapped = trimmed.match(/^\$\{\{([\s\S]*)\}\}$/);
|
|
822
|
+
if (wrapped)
|
|
823
|
+
trimmed = wrapped[1].trim();
|
|
824
|
+
if (trimmed === '')
|
|
825
|
+
return true;
|
|
826
|
+
try {
|
|
827
|
+
return truthOf(new Parser(tokenize(trimmed), createContext(), undefined, false, {
|
|
828
|
+
...known
|
|
829
|
+
}).parse());
|
|
830
|
+
}
|
|
831
|
+
catch {
|
|
832
|
+
return undefined;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
673
835
|
/** The error for an `if:` that couldn't be evaluated, worded like GitHub's. */
|
|
674
836
|
function ifEvaluationError(target, error) {
|
|
675
837
|
return `Error when evaluating 'if' for ${target}. ${error instanceof Error ? error.message : String(error)}`;
|