@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.
@@ -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
- runCli(args, timeoutMs, env) {
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: process.cwd(),
134
+ workingDir: runtime.workspace,
135
135
  readOnlyPaths: (0, job_container_1.protectedWorkspacePaths)(options),
136
136
  exchangeDir,
137
137
  env: {
@@ -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
- }, { failFast, maxParallel, signal: internal.signal });
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
- const runtime = (0, job_runtime_1.createJobRuntime)(github, runDir, {
295
- shell: job.defaults?.run?.shell ?? parsed.file.defaults?.run?.shell,
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, github),
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, github, matrixValues),
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, github, matrixValues),
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
- return !toBoolean(unwrap(this.parseUnary()));
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 = this.access(value, typeof key === 'number' || key instanceof Flow ? key : toText(key));
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)}`;