@secureci/cli 1.0.1 → 1.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.
@@ -34,10 +34,13 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.copyWorkingCopy = copyWorkingCopy;
37
+ exports.createIsolatedWorkspace = createIsolatedWorkspace;
38
+ exports.removeIsolatedWorkspace = removeIsolatedWorkspace;
37
39
  exports.isLocalCheckout = isLocalCheckout;
38
40
  exports.localCheckout = localCheckout;
39
41
  const fs = __importStar(require("fs"));
40
42
  const path = __importStar(require("path"));
43
+ const child_process_1 = require("child_process");
41
44
  const logger_1 = require("../utils/logger");
42
45
  const expression_1 = require("./expression");
43
46
  /** Marks a `path:` copy made by localCheckout, so it may be replaced. */
@@ -64,10 +67,34 @@ function copyWorkingCopy(srcDir, dstDir, exclude) {
64
67
  fs.cpSync(src, dst, {
65
68
  recursive: true,
66
69
  verbatimSymlinks: true,
70
+ // Copy-on-write clones where the file system supports them.
71
+ mode: fs.constants.COPYFILE_FICLONE,
67
72
  filter: (file) => !fs.existsSync(path.join(file, CHECKOUT_MARKER))
68
73
  });
69
74
  }
70
75
  }
76
+ /**
77
+ * A copy of the working copy (uncommitted changes and .git included) for
78
+ * one job, in `parentDir`, for --isolate-workspace. Remove it with
79
+ * removeIsolatedWorkspace.
80
+ */
81
+ function createIsolatedWorkspace(workingCopy, parentDir) {
82
+ const dir = fs.mkdtempSync(path.join(parentDir, 'workspace-'));
83
+ copyWorkingCopy(workingCopy, dir, dir);
84
+ return dir;
85
+ }
86
+ /**
87
+ * Remove a job's isolated workspace. Best effort: files a container step
88
+ * created as another user may not be removable, which is only warned about.
89
+ */
90
+ function removeIsolatedWorkspace(dir) {
91
+ try {
92
+ fs.rmSync(dir, { recursive: true, force: true });
93
+ }
94
+ catch (error) {
95
+ logger_1.logger.warn(`Couldn't remove the job's workspace copy ${dir}: ${error instanceof Error ? error.message : String(error)}`);
96
+ }
97
+ }
71
98
  /**
72
99
  * Whether a step is `actions/checkout` of the repository being run (no
73
100
  * `repository:` input, or the current one in any case), which is served
@@ -101,6 +128,54 @@ function escapesViaSymlink(workspace, dest) {
101
128
  const real = fs.realpathSync(existing);
102
129
  return real !== root && !real.startsWith(root + path.sep);
103
130
  }
131
+ /** Run git in `cwd` on the host; undefined when it fails. */
132
+ function gitOutput(cwd, args) {
133
+ try {
134
+ return (0, child_process_1.execFileSync)('git', args, {
135
+ cwd,
136
+ encoding: 'utf8',
137
+ stdio: ['ignore', 'pipe', 'ignore']
138
+ }).trim();
139
+ }
140
+ catch {
141
+ return undefined;
142
+ }
143
+ }
144
+ /**
145
+ * The commit `ref` names in the local repository (as given, else as a
146
+ * branch of `origin`), or undefined. A ref that looks like an option is
147
+ * never passed to git.
148
+ */
149
+ function resolveLocalRef(workspace, ref) {
150
+ if (ref.startsWith('-'))
151
+ return undefined;
152
+ for (const candidate of [ref, `refs/remotes/origin/${ref}`]) {
153
+ const sha = gitOutput(workspace, [
154
+ 'rev-parse',
155
+ '--verify',
156
+ '--quiet',
157
+ '--end-of-options',
158
+ `${candidate}^{commit}`
159
+ ]);
160
+ if (sha)
161
+ return sha;
162
+ }
163
+ return undefined;
164
+ }
165
+ /**
166
+ * Submodules of the working copy that aren't initialized (`git submodule
167
+ * status` marks them with "-"), by path.
168
+ */
169
+ function uninitializedSubmodules(workspace) {
170
+ if (!fs.existsSync(path.join(workspace, '.gitmodules')))
171
+ return [];
172
+ const status = gitOutput(workspace, ['submodule', 'status', '--recursive']);
173
+ return (status ?? '')
174
+ .split('\n')
175
+ .filter((line) => line.startsWith('-'))
176
+ .map((line) => line.slice(1).split(' ')[1])
177
+ .filter(Boolean);
178
+ }
104
179
  function localCheckout(step, ctx, runtime, startTime) {
105
180
  const withValues = (0, expression_1.substituteRecord)(step.with, ctx);
106
181
  const outputs = {
@@ -111,12 +186,7 @@ function localCheckout(step, ctx, runtime, startTime) {
111
186
  logger_1.logger.error(error);
112
187
  return { step, success: false, error, duration: Date.now() - startTime };
113
188
  };
114
- const ref = withValues.ref;
115
- if (ref &&
116
- ![ctx.github.ref, ctx.github.ref_name, ctx.github.sha].includes(ref)) {
117
- logger_1.logger.warn(`actions/checkout: ignoring ref "${ref}"; using the local working copy (pass --no-local-checkout to run the real action)`);
118
- }
119
- const workspace = process.cwd();
189
+ const workspace = runtime.workspace;
120
190
  const dest = path.resolve(workspace, withValues.path || '.');
121
191
  const relative = path.relative(workspace, dest);
122
192
  if (relative.startsWith('..') ||
@@ -124,6 +194,28 @@ function localCheckout(step, ctx, runtime, startTime) {
124
194
  escapesViaSymlink(workspace, dest)) {
125
195
  return fail(`actions/checkout: path "${withValues.path}" is outside the workspace`);
126
196
  }
197
+ // A ref other than the current one: check out that commit, when the local
198
+ // repository has it, into path: (never over the user's working copy).
199
+ const ref = withValues.ref;
200
+ let commit;
201
+ if (ref &&
202
+ ![ctx.github.ref, ctx.github.ref_name, ctx.github.sha].includes(ref)) {
203
+ commit = resolveLocalRef(workspace, ref);
204
+ if (!commit) {
205
+ logger_1.logger.warn(`actions/checkout: ref "${ref}" isn't in the local repository; using the local working copy (fetch it, or pass --no-local-checkout to run the real action)`);
206
+ }
207
+ else if (relative === '') {
208
+ logger_1.logger.warn(`actions/checkout: not checking out ref "${ref}" over your working copy; add a path: to check it out there, or pass --no-local-checkout to run the real action`);
209
+ commit = undefined;
210
+ }
211
+ }
212
+ const submodules = String(withValues.submodules ?? '').toLowerCase();
213
+ if (submodules === 'true' || submodules === 'recursive') {
214
+ const missing = uninitializedSubmodules(workspace);
215
+ if (missing.length > 0) {
216
+ logger_1.logger.warn(`actions/checkout: submodules aren't initialized in your working copy (${missing.join(', ')}); run \`git submodule update --init --recursive\` first`);
217
+ }
218
+ }
127
219
  let output = 'Using local working copy';
128
220
  if (relative !== '') {
129
221
  if (fs.existsSync(dest)) {
@@ -134,9 +226,33 @@ function localCheckout(step, ctx, runtime, startTime) {
134
226
  return fail(`actions/checkout: path "${withValues.path}" already exists in the working copy; remove it or pass --no-local-checkout`);
135
227
  }
136
228
  }
137
- copyWorkingCopy(workspace, dest, dest);
229
+ if (commit) {
230
+ // A local clone, sharing the repository's objects: nothing is fetched
231
+ // and the working copy's .git is only read.
232
+ const cloned = gitOutput(workspace, [
233
+ 'clone',
234
+ '--quiet',
235
+ '--shared',
236
+ '--no-checkout',
237
+ '--',
238
+ workspace,
239
+ dest
240
+ ]) !== undefined &&
241
+ gitOutput(dest, ['checkout', '--quiet', '--detach', commit]) !==
242
+ undefined;
243
+ if (!cloned) {
244
+ fs.rmSync(dest, { recursive: true, force: true });
245
+ return fail(`actions/checkout: couldn't check out ref "${ref}" to "${withValues.path}"`);
246
+ }
247
+ outputs.ref = String(ref);
248
+ outputs.commit = commit;
249
+ output = `Checked out ${ref} (${commit.slice(0, 7)}) to ${relative}`;
250
+ }
251
+ else {
252
+ copyWorkingCopy(workspace, dest, dest);
253
+ output = `Using local working copy (copied to ${relative})`;
254
+ }
138
255
  fs.writeFileSync(path.join(dest, CHECKOUT_MARKER), '');
139
- output = `Using local working copy (copied to ${relative})`;
140
256
  const postStep = { name: `Post ${step.name || step.uses}` };
141
257
  runtime.postSteps.push({
142
258
  step: postStep,
@@ -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 {
@@ -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,15 @@ 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 concurrencyAt = [
166
+ ...(parsed.file.concurrency ? ['workflow'] : []),
167
+ ...Object.entries(parsed.file.jobs)
168
+ .filter(([, job]) => job.concurrency)
169
+ .map(([id]) => `jobs.${id}`)
170
+ ];
171
+ if (concurrencyAt.length > 0) {
172
+ logger_1.logger.info(`concurrency: isn't applied in a local run (${concurrencyAt.join(', ')}): it doesn't queue behind or cancel other runs, and jobs run as needs: and --parallel allow`);
173
+ }
164
174
  options = {
165
175
  ...options,
166
176
  inputs: {
@@ -291,11 +301,20 @@ async function executeWorkflow(filePath, options = {}, internal = {}) {
291
301
  // its running step killed and its containers removed.
292
302
  const runCombination = async (jobIndex, matrixValues, signal) => {
293
303
  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,
304
+ // With --isolate-workspace, the job (and github.workspace) gets
305
+ // its own copy of the working copy.
306
+ const workspace = options.isolateWorkspace
307
+ ? (0, checkout_1.createIsolatedWorkspace)(process.cwd(), runDir)
308
+ : process.cwd();
309
+ const comboGithub = options.isolateWorkspace
310
+ ? { ...github, workspace }
311
+ : github;
312
+ const runtime = (0, job_runtime_1.createJobRuntime)(comboGithub, runDir, {
313
+ shell: job.defaults?.run?.shell ??
314
+ parsed.file.defaults?.run?.shell,
296
315
  workingDirectory: job.defaults?.run?.['working-directory'] ??
297
316
  parsed.file.defaults?.run?.['working-directory']
298
- });
317
+ }, workspace);
299
318
  runtime.signal = signal;
300
319
  // Workflow-level env can't reference `env` itself; job-level
301
320
  // env sees the workflow env plus matrix (matching GitHub's
@@ -303,17 +322,17 @@ async function executeWorkflow(filePath, options = {}, internal = {}) {
303
322
  const workflowEnv = {
304
323
  ...options.env,
305
324
  ...(0, expression_1.substituteRecord)(parsed.file.env, {
306
- ...(0, expression_1.createContext)({ ...options.env }, inputs, options.secrets, github),
325
+ ...(0, expression_1.createContext)({ ...options.env }, inputs, options.secrets, comboGithub),
307
326
  vars
308
327
  })
309
328
  };
310
329
  const jobEnv = (0, expression_1.substituteRecord)(job.env, {
311
- ...(0, expression_1.createContext)(workflowEnv, inputs, options.secrets, github, matrixValues),
330
+ ...(0, expression_1.createContext)(workflowEnv, inputs, options.secrets, comboGithub, matrixValues),
312
331
  needs,
313
332
  vars
314
333
  });
315
334
  const jobCtx = {
316
- ...(0, expression_1.createContext)({ ...workflowEnv, ...jobEnv }, inputs, options.secrets, github, matrixValues),
335
+ ...(0, expression_1.createContext)({ ...workflowEnv, ...jobEnv }, inputs, options.secrets, comboGithub, matrixValues),
317
336
  needs,
318
337
  vars,
319
338
  job: { status: 'success' },
@@ -526,6 +545,9 @@ async function executeWorkflow(filePath, options = {}, internal = {}) {
526
545
  }
527
546
  finally {
528
547
  await stopping;
548
+ if (options.isolateWorkspace) {
549
+ (0, checkout_1.removeIsolatedWorkspace)(workspace);
550
+ }
529
551
  }
530
552
  const jobSuccess = stepResults.every((s) => s.success || s.step['continue-on-error']);
531
553
  // Job-level continue-on-error (often
@@ -6,6 +6,7 @@ exports.substituteExpressions = substituteExpressions;
6
6
  exports.substituteRecord = substituteRecord;
7
7
  exports.withImplicitSuccess = withImplicitSuccess;
8
8
  exports.evaluateCondition = evaluateCondition;
9
+ exports.staticCondition = staticCondition;
9
10
  exports.ifEvaluationError = ifEvaluationError;
10
11
  exports.validateExpression = validateExpression;
11
12
  exports.expressionDataFlow = expressionDataFlow;
@@ -388,16 +389,46 @@ function callFunction(name, rawArgs, ctx) {
388
389
  }
389
390
  /** The `*` filter in `x.*` / `x[*]`, as opposed to a `'*'` key. */
390
391
  const STAR = Symbol('*');
392
+ /**
393
+ * A value static evaluation (`staticCondition`) doesn't know, with its
394
+ * truthiness when that is known anyway (`unknown || true` is truthy).
395
+ */
396
+ class Unknown {
397
+ constructor(truthy) {
398
+ this.truthy = truthy;
399
+ }
400
+ }
401
+ /** Contexts an expression may name; static evaluation knows only some. */
402
+ const NAMED_CONTEXTS = new Set([
403
+ 'env',
404
+ 'inputs',
405
+ 'secrets',
406
+ 'steps',
407
+ 'github',
408
+ 'matrix',
409
+ 'needs',
410
+ 'job',
411
+ 'runner',
412
+ 'vars',
413
+ 'strategy'
414
+ ]);
415
+ /** Truthiness of a (possibly Unknown) value; undefined when unknown. */
416
+ function truthOf(value) {
417
+ return value instanceof Unknown ? value.truthy : toBoolean(unwrap(value));
418
+ }
391
419
  class Parser {
392
420
  /**
393
421
  * With a scope, only check names and syntax; with `flow`, compute data
394
422
  * flow (Flow values) instead. Either way nothing is evaluated.
395
423
  */
396
- constructor(tokens, ctx, scope, flow = false) {
424
+ constructor(tokens, ctx, scope, flow = false,
425
+ /** Static evaluation: only these contexts are known (see Unknown). */
426
+ known) {
397
427
  this.tokens = tokens;
398
428
  this.ctx = ctx;
399
429
  this.scope = scope;
400
430
  this.flow = flow;
431
+ this.known = known;
401
432
  this.pos = 0;
402
433
  }
403
434
  peek() {
@@ -429,6 +460,16 @@ class Parser {
429
460
  while (this.isOp('||')) {
430
461
  this.next();
431
462
  const right = this.parseAnd();
463
+ if (this.known) {
464
+ const l = truthOf(left);
465
+ left =
466
+ l === true
467
+ ? left
468
+ : l === false
469
+ ? right
470
+ : new Unknown(truthOf(right) === true ? true : undefined);
471
+ continue;
472
+ }
432
473
  left = this.flow
433
474
  ? Flow.union(left, right)
434
475
  : toBoolean(unwrap(left))
@@ -442,6 +483,16 @@ class Parser {
442
483
  while (this.isOp('&&')) {
443
484
  this.next();
444
485
  const right = this.parseEquality();
486
+ if (this.known) {
487
+ const l = truthOf(left);
488
+ left =
489
+ l === false
490
+ ? left
491
+ : l === true
492
+ ? right
493
+ : new Unknown(truthOf(right) === false ? false : undefined);
494
+ continue;
495
+ }
445
496
  left = this.flow
446
497
  ? Flow.union(left, right)
447
498
  : toBoolean(unwrap(left))
@@ -455,6 +506,10 @@ class Parser {
455
506
  while (this.isOp('==', '!=')) {
456
507
  const op = this.next().value;
457
508
  const right = this.parseComparison();
509
+ if (left instanceof Unknown || right instanceof Unknown) {
510
+ left = new Unknown();
511
+ continue;
512
+ }
458
513
  const eq = looseEquals(unwrap(left), unwrap(right));
459
514
  left = op === '==' ? eq : !eq;
460
515
  }
@@ -465,6 +520,10 @@ class Parser {
465
520
  while (this.isOp('<', '<=', '>', '>=')) {
466
521
  const op = this.next().value;
467
522
  const right = this.parseUnary();
523
+ if (left instanceof Unknown || right instanceof Unknown) {
524
+ left = new Unknown();
525
+ continue;
526
+ }
468
527
  const c = compare(unwrap(left), unwrap(right));
469
528
  left =
470
529
  !Number.isNaN(c) &&
@@ -481,7 +540,11 @@ class Parser {
481
540
  parseUnary() {
482
541
  if (this.isOp('!')) {
483
542
  this.next();
484
- return !toBoolean(unwrap(this.parseUnary()));
543
+ const operand = this.parseUnary();
544
+ if (operand instanceof Unknown) {
545
+ return new Unknown(operand.truthy === undefined ? undefined : !operand.truthy);
546
+ }
547
+ return !toBoolean(unwrap(operand));
485
548
  }
486
549
  return this.parsePostfix(this.parsePrimary());
487
550
  }
@@ -509,7 +572,12 @@ class Parser {
509
572
  }
510
573
  else {
511
574
  const key = unwrap(this.parseOr());
512
- value = this.access(value, typeof key === 'number' || key instanceof Flow ? key : toText(key));
575
+ value =
576
+ key instanceof Unknown
577
+ ? new Unknown()
578
+ : this.access(value, typeof key === 'number' || key instanceof Flow
579
+ ? key
580
+ : toText(key));
513
581
  }
514
582
  this.expect('rbracket');
515
583
  }
@@ -526,6 +594,12 @@ class Parser {
526
594
  // `.*` and a computed key (`x[matrix.field]`) may be any key.
527
595
  return value.member(key === STAR || key instanceof Flow ? '*' : String(key).toLowerCase());
528
596
  }
597
+ if (this.known) {
598
+ if (value instanceof Unknown)
599
+ return new Unknown();
600
+ const result = key === STAR ? star(value) : member(value, key);
601
+ return result === undefined ? new Unknown() : result;
602
+ }
529
603
  if (key === STAR)
530
604
  return star(value);
531
605
  return member(value, key);
@@ -566,6 +640,8 @@ class Parser {
566
640
  }
567
641
  if (this.flow)
568
642
  return flowCall(tok.value, args);
643
+ if (this.known)
644
+ return this.staticCall(tok.value, args);
569
645
  return callFunction(tok.value, args, this.ctx);
570
646
  }
571
647
  if (this.scope) {
@@ -576,12 +652,34 @@ class Parser {
576
652
  }
577
653
  if (this.flow)
578
654
  return new Flow([[lower]]);
655
+ if (this.known) {
656
+ if (!NAMED_CONTEXTS.has(lower)) {
657
+ throw new Error(`Unrecognized named-value: '${tok.value}'`);
658
+ }
659
+ return lower in this.known ? this.known[lower] : new Unknown();
660
+ }
579
661
  return rootContext(lower, this.ctx);
580
662
  }
581
663
  default:
582
664
  throw new Error(`Unexpected token: ${tok.type}`);
583
665
  }
584
666
  }
667
+ /**
668
+ * A function call in static evaluation: `success()` and `always()` hold
669
+ * for a step that runs at all; other status functions, `hashFiles` and
670
+ * calls with an unknown argument are unknown.
671
+ */
672
+ staticCall(name, args) {
673
+ checkCall(name, args.length);
674
+ const lower = name.toLowerCase();
675
+ if (lower === 'success' || lower === 'always')
676
+ return true;
677
+ if (['failure', 'cancelled', 'hashfiles'].includes(lower) ||
678
+ args.some((arg) => arg instanceof Unknown)) {
679
+ return new Unknown();
680
+ }
681
+ return callFunction(name, args, this.ctx);
682
+ }
585
683
  }
586
684
  function evaluate(expr, ctx) {
587
685
  return new Parser(tokenize(expr), ctx).parse();
@@ -670,6 +768,30 @@ function evaluateCondition(expr, ctx) {
670
768
  // it fails the step or job), never a condition that passes.
671
769
  return toBoolean(evaluate(trimmed, ctx));
672
770
  }
771
+ /**
772
+ * Whether an `if:` holds when only the `known` contexts are known, e.g.
773
+ * `{ github: { event_name: 'push' } }`: true or false when that settles
774
+ * it, undefined when it depends on anything else (or can't be parsed).
775
+ * Used to check a contract without running the workflow.
776
+ */
777
+ function staticCondition(expr, known) {
778
+ if (expr === undefined || typeof expr === 'boolean')
779
+ return expr ?? true;
780
+ let trimmed = expr.trim();
781
+ const wrapped = trimmed.match(/^\$\{\{([\s\S]*)\}\}$/);
782
+ if (wrapped)
783
+ trimmed = wrapped[1].trim();
784
+ if (trimmed === '')
785
+ return true;
786
+ try {
787
+ return truthOf(new Parser(tokenize(trimmed), createContext(), undefined, false, {
788
+ ...known
789
+ }).parse());
790
+ }
791
+ catch {
792
+ return undefined;
793
+ }
794
+ }
673
795
  /** The error for an `if:` that couldn't be evaluated, worded like GitHub's. */
674
796
  function ifEvaluationError(target, error) {
675
797
  return `Error when evaluating 'if' for ${target}. ${error instanceof Error ? error.message : String(error)}`;
@@ -50,6 +50,13 @@ const expression_1 = require("./expression");
50
50
  const container_runtime_1 = require("./container-runtime");
51
51
  const shell_1 = require("./shell");
52
52
  const container_policy_1 = require("./container-policy");
53
+ /**
54
+ * `jobs.<id>.container.options` the CLI sets on the job container itself:
55
+ * it idles with its own entrypoint and steps run in the mounted workspace.
56
+ */
57
+ const JOB_CONTAINER_RESERVED = new Set(['--entrypoint', '--workdir', '-w']);
58
+ /** Also reserved when `--container-memory-limit` caps the job container. */
59
+ const MEMORY_OPTIONS = ['--memory', '-m', '--memory-swap'];
53
60
  /**
54
61
  * Image for jobs whose `runs-on` has no image mapping. Runner-like (Node.js,
55
62
  * git, curl and common tools), as used by nektos/act.
@@ -123,14 +130,6 @@ async function startJobContainer(job, ctx, options, runtime, runDir) {
123
130
  logger_1.logger.error(noRuntimeMessage(preference));
124
131
  return false;
125
132
  }
126
- if (spec &&
127
- 'image' in spec &&
128
- (('options' in spec && spec.options) ||
129
- ('volumes' in spec && spec.volumes) ||
130
- ('ports' in spec && spec.ports) ||
131
- ('credentials' in spec && spec.credentials))) {
132
- logger_1.logger.warn('jobs.<id>.container: only image and env are supported; ignoring options/volumes/ports/credentials.');
133
- }
134
133
  // `container: ${{ inputs.image || null }}` that comes out empty means no
135
134
  // container image, as on GitHub: use the image for runs-on.
136
135
  const specImage = spec?.image
@@ -140,19 +139,25 @@ async function startJobContainer(job, ctx, options, runtime, runDir) {
140
139
  options.containerImage ||
141
140
  runnerImage(job?.['runs-on'], ctx, options);
142
141
  const containerEnv = (0, expression_1.substituteRecord)(specImage && spec && 'env' in spec ? spec.env : undefined, ctx);
142
+ const extras = specImage && spec
143
+ ? jobContainerExtras(spec, ctx, options, runtime.workspace)
144
+ : {};
145
+ if (!extras)
146
+ return false;
143
147
  const exchangeDir = fs.mkdtempSync(path.join(runDir, 'container-'));
144
148
  fs.chmodSync(exchangeDir, 0o777);
145
149
  fs.copyFileSync(runtime.eventPath, path.join(exchangeDir, 'event.json'));
146
150
  logger_1.logger.info(`Starting job container: ${image} (${containerRuntime.name})`);
147
151
  const started = await containerRuntime.start({
148
152
  image,
149
- workingDir: process.cwd(),
153
+ workingDir: runtime.workspace,
150
154
  readOnlyPaths: protectedWorkspacePaths(options),
151
155
  exchangeDir,
152
156
  env: containerEnv,
153
157
  memoryLimit: options.containerMemoryLimit,
154
158
  networkDisabled: options.networkDisabled === true,
155
- network: runtime.services?.network
159
+ network: runtime.services?.network,
160
+ ...extras
156
161
  });
157
162
  if (!started.success || !started.id) {
158
163
  logger_1.logger.error(`Failed to start job container (${image}): ${started.error}`);
@@ -173,6 +178,47 @@ async function startJobContainer(job, ctx, options, runtime, runDir) {
173
178
  activeContainers.add(runtime.container);
174
179
  return true;
175
180
  }
181
+ /**
182
+ * The `options`, `ports` and `volumes` of `jobs.<id>.container`, checked
183
+ * with the same policy as service containers: options and host mounts that
184
+ * would reach this machine are refused (undefined: the job fails), options
185
+ * the CLI sets itself are dropped with a warning.
186
+ */
187
+ function jobContainerExtras(spec, ctx, options, workspace) {
188
+ const label = `jobs.${ctx.github.job ?? '<id>'}.container`;
189
+ const args = spec.options
190
+ ? (0, shell_1.splitArgs)((0, expression_1.substituteExpressions)(spec.options, ctx))
191
+ : [];
192
+ const unsafe = (0, container_policy_1.unsafeServiceOptions)(args);
193
+ if (unsafe.length > 0) {
194
+ logger_1.logger.error(`${label}: refusing unsafe options ${[...new Set(unsafe)].join(', ')}: they would give the container access to this machine.`);
195
+ return undefined;
196
+ }
197
+ const reserved = new Set([
198
+ ...JOB_CONTAINER_RESERVED,
199
+ ...(options.containerMemoryLimit ? MEMORY_OPTIONS : [])
200
+ ]);
201
+ const { kept, dropped } = (0, container_policy_1.splitReservedOptions)(args, reserved);
202
+ if (dropped.length > 0) {
203
+ logger_1.logger.warn(`${label}: ignoring options the CLI sets itself: ${[...new Set(dropped)].join(', ')}.`);
204
+ }
205
+ const volumes = (spec.volumes ?? []).map((v) => (0, expression_1.substituteExpressions)(v, ctx));
206
+ for (const volume of volumes) {
207
+ const reason = (0, container_policy_1.unsafeServiceVolumeReason)(volume, workspace);
208
+ if (reason) {
209
+ logger_1.logger.error(`${label}: ${reason}.`);
210
+ return undefined;
211
+ }
212
+ }
213
+ if (spec.credentials) {
214
+ logger_1.logger.warn(`${label}: credentials are ignored; log in with \`docker login\` instead.`);
215
+ }
216
+ return {
217
+ extraArgs: kept,
218
+ ports: (spec.ports ?? []).map((port) => (0, expression_1.substituteExpressions)(String(port), ctx)),
219
+ volumes
220
+ };
221
+ }
176
222
  /**
177
223
  * The image for a job's `runs-on`: the first label with a mapping in
178
224
  * `runner-images` (or the built-in one), else the default image, with a
@@ -267,7 +313,7 @@ async function startJobServices(job, ctx, options, runtime) {
267
313
  return false;
268
314
  }
269
315
  for (const volume of volumes) {
270
- const reason = (0, container_policy_1.unsafeServiceVolumeReason)(volume, process.cwd());
316
+ const reason = (0, container_policy_1.unsafeServiceVolumeReason)(volume, runtime.workspace);
271
317
  if (reason) {
272
318
  logger_1.logger.error(`services.${serviceId}: ${reason}.`);
273
319
  return false;
@@ -107,7 +107,7 @@ function runnerContext(runtime) {
107
107
  return {
108
108
  name: 'secureci',
109
109
  os: runtime.runnerEnv.RUNNER_OS,
110
- arch: process.arch === 'arm64' ? 'ARM64' : 'X64',
110
+ arch: runtime.runnerEnv.RUNNER_ARCH,
111
111
  temp: runtime.runnerEnv.RUNNER_TEMP,
112
112
  tool_cache: runtime.runnerEnv.RUNNER_TOOL_CACHE
113
113
  };
@@ -126,12 +126,13 @@ function hostHasBash() {
126
126
  }
127
127
  return hostBash;
128
128
  }
129
- function createJobRuntime(github, runDir, defaults = {}) {
129
+ function createJobRuntime(github, runDir, defaults = {}, workspace = process.cwd()) {
130
130
  const homeDir = process.env.HOME || process.env.USERPROFILE || os.tmpdir();
131
131
  const eventPath = path.join(runDir, 'event.json');
132
132
  return {
133
+ workspace,
133
134
  runnerEnv: (0, runner_env_1.buildRunnerEnv)(github, {
134
- workspace: process.cwd(),
135
+ workspace,
135
136
  eventPath,
136
137
  // Per job, so concurrent jobs don't share $RUNNER_TEMP.
137
138
  tempDir: fs.mkdtempSync(path.join(runDir, 'tmp-')),