@secureci/cli 1.0.0 → 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.
@@ -43,6 +43,7 @@ const repository_1 = require("../utils/repository");
43
43
  const action_fetcher_1 = require("../runner/action-fetcher");
44
44
  const github_lockfile_1 = require("../github-lockfile");
45
45
  const permissions_1 = require("./permissions");
46
+ const expression_1 = require("../runner/expression");
46
47
  /** Maximum nesting of composite actions / reusable workflows. */
47
48
  const MAX_DEPTH = 10;
48
49
  /**
@@ -81,8 +82,17 @@ async function validatePipelineContract(parsed, spec, options = {}) {
81
82
  (spec.required ?? []).forEach((entry, idx) => {
82
83
  const path = `required[${idx}]`;
83
84
  const name = normalizeActionName(entry.uses);
84
- const candidates = sequence.filter((u) => u.name === name);
85
- if (candidates.length > 0 && candidates.every((c) => c.conditional)) {
85
+ const found = sequence.filter((u) => u.name === name);
86
+ const candidates = found.filter((u) => u.runs !== 'never');
87
+ if (found.length > 0 && candidates.length === 0) {
88
+ errors.push({
89
+ path,
90
+ message: `Required action "${entry.uses}" never runs: its if: conditions (on its step or job) can't be true for this workflow's triggers (${(graph.events ?? []).join(', ')})`,
91
+ severity: 'error'
92
+ });
93
+ return;
94
+ }
95
+ if (candidates.length > 0 && candidates.every((c) => c.runs === 'maybe')) {
86
96
  errors.push({
87
97
  path,
88
98
  message: `Required action "${entry.uses}" only runs under an if: condition (on its step or job), so it may be skipped`,
@@ -275,6 +285,20 @@ function needsTransitively(nodes, from, to) {
275
285
  async function buildGraph(parsed, recurse, offline) {
276
286
  const graph = { sequence: [], nodes: new Map(), errors: [] };
277
287
  const root = (0, repository_1.repositoryRoot)(parsed.path);
288
+ // `if:` conditions are decided, where they can be, for each trigger of
289
+ // the workflow (called workflows run with their caller's event). With
290
+ // workflow_call, the event is the caller's, so it isn't known.
291
+ graph.events = triggerEvents(parsed.file.on);
292
+ const known = graph.events
293
+ ? graph.events.map((event) => ({ github: { event_name: event } }))
294
+ : [{}];
295
+ /** `runs`, narrowed by a job or step's own `if:`. */
296
+ const within = (runs, condition) => runs.map((outer, i) => {
297
+ const own = (0, expression_1.staticCondition)(condition, known[i]);
298
+ if (outer === false || own === false)
299
+ return false;
300
+ return outer === true && own === true ? true : undefined;
301
+ });
278
302
  const isLocal = (uses) => uses.startsWith('./') || uses.startsWith('../');
279
303
  /**
280
304
  * The file behind a composite action / reusable workflow reference, or
@@ -355,31 +379,35 @@ async function buildGraph(parsed, recurse, offline) {
355
379
  }
356
380
  return true;
357
381
  };
358
- const addUsage = (node, index, at, uses, conditional) => {
382
+ const addUsage = (node, index, at, uses, runs) => {
359
383
  graph.sequence.push({
360
384
  node,
361
385
  index,
362
386
  path: at,
363
- conditional,
387
+ runs: runs.every((r) => r === false)
388
+ ? 'never'
389
+ : runs.every((r) => r === true)
390
+ ? 'always'
391
+ : 'maybe',
364
392
  ...splitActionRef(uses)
365
393
  });
366
394
  };
367
395
  /** Flatten a job's steps (and composite action steps) into `node`. */
368
- const addSteps = async (steps, node, at, counter, stack, workflowRoot, conditional) => {
396
+ const addSteps = async (steps, node, at, counter, stack, workflowRoot, runs) => {
369
397
  for (const [i, step] of steps.entries()) {
370
398
  const stepPath = `${at}steps[${i}]`;
371
399
  if (!step.uses) {
372
400
  counter.next++;
373
401
  continue;
374
402
  }
375
- const stepConditional = conditional || hasCondition(step.if);
376
- addUsage(node, counter.next++, stepPath, step.uses, stepConditional);
403
+ const stepRuns = within(runs, step.if);
404
+ addUsage(node, counter.next++, stepPath, step.uses, stepRuns);
377
405
  const file = await resolveRef(step.uses, 'action', workflowRoot, stepPath);
378
406
  if (!file || !canEnter(file, stack, stepPath))
379
407
  continue;
380
408
  const action = parse(file, stepPath);
381
409
  if (action?.type === 'action' && action.file.runs.using === 'composite') {
382
- await addSteps(action.file.runs.steps ?? [], node, `${stepPath} > ${step.uses} > `, counter, [...stack, file], workflowRoot, stepConditional);
410
+ await addSteps(action.file.runs.steps ?? [], node, `${stepPath} > ${step.uses} > `, counter, [...stack, file], workflowRoot, stepRuns);
383
411
  }
384
412
  }
385
413
  };
@@ -388,7 +416,7 @@ async function buildGraph(parsed, recurse, offline) {
388
416
  * reusable-workflow call itself needs, plus the call). Returns the ids
389
417
  * of the added jobs.
390
418
  */
391
- const addWorkflow = async (workflow, workflowRoot, prefix, labelPrefix, at, after, stack, conditional) => {
419
+ const addWorkflow = async (workflow, workflowRoot, prefix, labelPrefix, at, after, stack, runs) => {
392
420
  const added = [];
393
421
  // A job id -> the graph nodes standing for it (a reusable-workflow call
394
422
  // stands for the call and all of its called jobs).
@@ -408,17 +436,17 @@ async function buildGraph(parsed, recurse, offline) {
408
436
  });
409
437
  added.push(node);
410
438
  const jobPath = `${at}jobs.${jobId}`;
411
- const jobConditional = conditional || hasCondition(job.if);
439
+ const jobRuns = within(runs, job.if);
412
440
  if (job.uses) {
413
441
  // The call itself is a one-step "job" its called jobs run after.
414
- addUsage(node, 0, jobPath, job.uses, jobConditional);
442
+ addUsage(node, 0, jobPath, job.uses, jobRuns);
415
443
  graph.nodes.get(node).length = 1;
416
444
  const ids = [node];
417
445
  const file = await resolveRef(job.uses, 'workflow', workflowRoot, jobPath);
418
446
  if (file && canEnter(file, stack, jobPath)) {
419
447
  const called = parse(file, jobPath);
420
448
  if (called?.type === 'workflow') {
421
- ids.push(...(await addWorkflow(called.file, (0, repository_1.repositoryRoot)(file), `${node}>`, `${labelPrefix}${jobId} > ${job.uses} > `, `${jobPath} > ${job.uses} > `, [...needs, node], [...stack, file], jobConditional)));
449
+ ids.push(...(await addWorkflow(called.file, (0, repository_1.repositoryRoot)(file), `${node}>`, `${labelPrefix}${jobId} > ${job.uses} > `, `${jobPath} > ${job.uses} > `, [...needs, node], [...stack, file], jobRuns)));
422
450
  }
423
451
  }
424
452
  alias.set(jobId, ids);
@@ -427,27 +455,30 @@ async function buildGraph(parsed, recurse, offline) {
427
455
  }
428
456
  alias.set(jobId, [node]);
429
457
  const counter = { next: 0 };
430
- await addSteps(job.steps ?? [], node, `${jobPath}.`, counter, stack, workflowRoot, jobConditional);
458
+ await addSteps(job.steps ?? [], node, `${jobPath}.`, counter, stack, workflowRoot, jobRuns);
431
459
  graph.nodes.get(node).length = counter.next;
432
460
  }
433
461
  return added;
434
462
  };
435
- await addWorkflow(parsed.file, root, '', '', '', [], [path.resolve(parsed.path)], false);
463
+ await addWorkflow(parsed.file, root, '', '', '', [], [path.resolve(parsed.path)], known.map(() => true));
436
464
  return graph;
437
465
  }
438
466
  /**
439
- * Whether an `if:` can skip the step/job: anything but a missing value,
440
- * `true`, `success()` (the default) or `always()`.
467
+ * The events that trigger a workflow (`on:` as a string, list or map), or
468
+ * undefined when the event isn't known: none are listed, or the workflow
469
+ * is reusable (it runs with its caller's event).
441
470
  */
442
- function hasCondition(condition) {
443
- if (condition === undefined || condition === null || condition === true) {
444
- return false;
445
- }
446
- const text = String(condition)
447
- .trim()
448
- .replace(/^\$\{\{([\s\S]*)\}\}$/, '$1')
449
- .trim();
450
- return !['', 'true', 'success()', 'always()'].includes(text);
471
+ function triggerEvents(on) {
472
+ const events = typeof on === 'string'
473
+ ? [on]
474
+ : Array.isArray(on)
475
+ ? on.map(String)
476
+ : on && typeof on === 'object'
477
+ ? Object.keys(on)
478
+ : [];
479
+ return events.length === 0 || events.includes('workflow_call')
480
+ ? undefined
481
+ : events;
451
482
  }
452
483
  function splitActionRef(uses) {
453
484
  const atIndex = uses.lastIndexOf('@');
@@ -43,6 +43,7 @@ exports.lsRemoteSha = lsRemoteSha;
43
43
  exports.parseLsRemoteTags = parseLsRemoteTags;
44
44
  exports.lsRemoteTags = lsRemoteTags;
45
45
  exports.getText = getText;
46
+ exports.getResponse = getResponse;
46
47
  exports.followRedirect = followRedirect;
47
48
  exports.fetchAction = fetchAction;
48
49
  exports.fetchRepoFile = fetchRepoFile;
@@ -222,6 +223,10 @@ async function lsRemoteTags(url) {
222
223
  * with an error carrying `status`.
223
224
  */
224
225
  function getText(url, headers, timeoutMs) {
226
+ return getResponse(url, headers, timeoutMs).then((r) => r.body);
227
+ }
228
+ /** getText, with the response headers (e.g. `link` for pagination). */
229
+ function getResponse(url, headers, timeoutMs) {
225
230
  return new Promise((resolve, reject) => {
226
231
  const client = url.startsWith('http://') ? http : https;
227
232
  const request = client.get(url, { headers }, (response) => {
@@ -237,7 +242,7 @@ function getText(url, headers, timeoutMs) {
237
242
  body += chunk;
238
243
  });
239
244
  response.on('end', () => response.statusCode === 200
240
- ? resolve(body)
245
+ ? resolve({ body, headers: response.headers })
241
246
  : reject(Object.assign(new Error(`HTTP ${response.statusCode}`), {
242
247
  status: response.statusCode
243
248
  })));
@@ -93,7 +93,7 @@ function warnUnsandboxedActions(steps, ctx, options) {
93
93
  async function stepFileSystem(runtime) {
94
94
  const container = runtime.container;
95
95
  if (!container)
96
- return (0, builtin_actions_1.hostFileSystem)(process.cwd());
96
+ return (0, builtin_actions_1.hostFileSystem)(runtime.workspace);
97
97
  if (container.home === undefined) {
98
98
  const probe = await container.runtime.exec(container.id, {
99
99
  command: 'printf %s "$HOME"'
@@ -102,7 +102,7 @@ async function stepFileSystem(runtime) {
102
102
  }
103
103
  return (0, builtin_actions_1.containerFileSystem)({
104
104
  workspace: container_runtime_1.CONTAINER_WORKDIR,
105
- hostWorkspace: process.cwd(),
105
+ hostWorkspace: runtime.workspace,
106
106
  home: container.home,
107
107
  copyFrom: (from, to) => container.runtime.copyFrom(container.id, from, to),
108
108
  copyTo: (from, to) => container.runtime.copyTo(container.id, from, to),
@@ -259,8 +259,8 @@ function isLocalActionRef(uses) {
259
259
  * Resolve a local action (`uses: ./path/to/action`) relative to the
260
260
  * workspace, like the runner does, to its action.yml / action.yaml.
261
261
  */
262
- function resolveLocalAction(uses) {
263
- const dir = path.resolve(process.cwd(), uses);
262
+ function resolveLocalAction(uses, workspace = process.cwd()) {
263
+ const dir = path.resolve(workspace, uses);
264
264
  for (const name of ['action.yml', 'action.yaml']) {
265
265
  const candidate = path.join(dir, name);
266
266
  if (fs.existsSync(candidate))
@@ -297,7 +297,7 @@ async function executeAction(step, options, startTime, runtime, ctx) {
297
297
  args: withValues.args ? (0, shell_1.splitArgs)(withValues.args) : []
298
298
  }
299
299
  };
300
- return await (0, docker_action_1.executeDockerAction)(dockerAction, path.join(process.cwd(), 'action.yml'), step, {}, callerCtx.env, options, runtime, startTime);
300
+ return await (0, docker_action_1.executeDockerAction)(dockerAction, path.join(runtime.workspace, 'action.yml'), step, {}, callerCtx.env, options, runtime, startTime);
301
301
  }
302
302
  if (options.localCheckout !== false &&
303
303
  (0, checkout_1.isLocalCheckout)(step.uses, step.with, callerCtx)) {
@@ -308,11 +308,11 @@ async function executeAction(step, options, startTime, runtime, ctx) {
308
308
  return await runBuiltinAction(builtin, step, callerCtx, options, runtime, startTime);
309
309
  }
310
310
  const actionPath = isLocalActionRef(step.uses)
311
- ? resolveLocalAction(step.uses)
311
+ ? resolveLocalAction(step.uses, runtime.workspace)
312
312
  : await fetchActionOnce(step.uses, options, runtime);
313
313
  if (!actionPath) {
314
314
  const error = `Could not resolve action "${step.uses}"${isLocalActionRef(step.uses)
315
- ? ` (no action.yml or action.yaml in ${path.resolve(process.cwd(), step.uses)})`
315
+ ? ` (no action.yml or action.yaml in ${path.resolve(runtime.workspace, step.uses)})`
316
316
  : ''}`;
317
317
  logger_1.logger.error(error);
318
318
  return {
@@ -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