@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.
@@ -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) => triggerFacts(parsed.file.on, 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,127 @@ 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;
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;
482
+ }
483
+ /** Characters that make a branch/tag filter pattern a glob. */
484
+ const GLOB_CHARS = /[*?+[\]]/;
485
+ /** The literal text a filter pattern starts with (before any glob). */
486
+ function literalStart(pattern) {
487
+ const at = pattern.search(GLOB_CHARS);
488
+ return at === -1 ? pattern : pattern.slice(0, at);
489
+ }
490
+ /** Longest common prefix of `values` ('' for none). */
491
+ function commonPrefix(values) {
492
+ if (values.length === 0)
493
+ return '';
494
+ let prefix = values[0];
495
+ for (const value of values.slice(1)) {
496
+ while (!value.startsWith(prefix))
497
+ prefix = prefix.slice(0, -1);
445
498
  }
446
- const text = String(condition)
447
- .trim()
448
- .replace(/^\$\{\{([\s\S]*)\}\}$/, '$1')
449
- .trim();
450
- return !['', 'true', 'success()', 'always()'].includes(text);
499
+ return prefix;
500
+ }
501
+ /**
502
+ * `github.ref` / `github.ref_name` for a ref under `kind` (`refs/heads/`
503
+ * or `refs/tags/`) matching the positive `patterns`: exact for a single
504
+ * literal pattern, else known by prefix. `!negations` only narrow further.
505
+ */
506
+ function refFromPatterns(kind, patterns) {
507
+ const positive = (Array.isArray(patterns) ? patterns : [])
508
+ .map(String)
509
+ .filter((p) => !p.startsWith('!'));
510
+ if (positive.length === 1 && !GLOB_CHARS.test(positive[0])) {
511
+ return { ref: kind + positive[0], ref_name: positive[0] };
512
+ }
513
+ const start = commonPrefix(positive.map(literalStart));
514
+ return { ref: (0, expression_1.knownPrefix)(kind + start), ref_name: (0, expression_1.knownPrefix)(start) };
515
+ }
516
+ /**
517
+ * What a workflow's `on.<event>` filters tell about `github.ref`: a push
518
+ * filtered only by branches (or only by tags) runs only for those refs;
519
+ * pull_request runs on `refs/pull/<n>/merge`; pull_request_target on the
520
+ * base branch, which its `branches:` filter matches. Unfiltered pushes and
521
+ * other events leave the ref unknown.
522
+ */
523
+ function refFacts(event, config) {
524
+ const filters = config && typeof config === 'object' && !Array.isArray(config)
525
+ ? config
526
+ : {};
527
+ const has = (key) => key in filters;
528
+ const branchFilter = has('branches') || has('branches-ignore');
529
+ const tagFilter = has('tags') || has('tags-ignore');
530
+ if (event === 'push') {
531
+ if (branchFilter && !tagFilter) {
532
+ return refFromPatterns('refs/heads/', filters.branches);
533
+ }
534
+ if (tagFilter && !branchFilter) {
535
+ return refFromPatterns('refs/tags/', filters.tags);
536
+ }
537
+ return {};
538
+ }
539
+ if (event === 'pull_request')
540
+ return { ref: (0, expression_1.knownPrefix)('refs/pull/') };
541
+ if (event === 'pull_request_target') {
542
+ return refFromPatterns('refs/heads/', filters.branches);
543
+ }
544
+ return {};
545
+ }
546
+ /**
547
+ * What's known about one trigger of a workflow for static `if:` checks:
548
+ * `github.event_name`, `github.ref` / `ref_name` where its filters pin
549
+ * them, and `inputs.*`. Inputs are empty on events other than
550
+ * workflow_dispatch, so a declared dispatch input is null there; on
551
+ * workflow_dispatch only a choice with a single option is known (a
552
+ * `default` can be overridden by whoever dispatches).
553
+ */
554
+ function triggerFacts(on, event) {
555
+ const config = on && typeof on === 'object' && !Array.isArray(on)
556
+ ? on[event]
557
+ : undefined;
558
+ const dispatch = on && typeof on === 'object' && !Array.isArray(on)
559
+ ? on
560
+ .workflow_dispatch
561
+ : undefined;
562
+ const declared = Object.entries(dispatch?.inputs ?? {});
563
+ const inputs = {};
564
+ for (const [name, spec] of declared) {
565
+ const input = (spec ?? {});
566
+ if (event !== 'workflow_dispatch') {
567
+ inputs[name] = null;
568
+ }
569
+ else if (input.type === 'choice' &&
570
+ Array.isArray(input.options) &&
571
+ input.options.length === 1) {
572
+ inputs[name] = String(input.options[0]);
573
+ }
574
+ }
575
+ return {
576
+ github: { event_name: event, ...refFacts(event, config) },
577
+ ...(declared.length > 0 ? { inputs } : {})
578
+ };
451
579
  }
452
580
  function splitActionRef(uses) {
453
581
  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,22 +297,24 @@ 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)) {
304
- return (0, checkout_1.localCheckout)(step, callerCtx, runtime, startTime);
304
+ return (0, checkout_1.localCheckout)(step, callerCtx, runtime, startTime, {
305
+ fetchRefs: options.fetchRefs
306
+ });
305
307
  }
306
308
  const builtin = (0, builtin_actions_1.builtinActionFor)(step.uses);
307
309
  if (builtin) {
308
310
  return await runBuiltinAction(builtin, step, callerCtx, options, runtime, startTime);
309
311
  }
310
312
  const actionPath = isLocalActionRef(step.uses)
311
- ? resolveLocalAction(step.uses)
313
+ ? resolveLocalAction(step.uses, runtime.workspace)
312
314
  : await fetchActionOnce(step.uses, options, runtime);
313
315
  if (!actionPath) {
314
316
  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)})`
317
+ ? ` (no action.yml or action.yaml in ${path.resolve(runtime.workspace, step.uses)})`
316
318
  : ''}`;
317
319
  logger_1.logger.error(error);
318
320
  return {
@@ -34,12 +34,18 @@ 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;
40
+ exports.gitAuthEnv = gitAuthEnv;
38
41
  exports.localCheckout = localCheckout;
39
42
  const fs = __importStar(require("fs"));
40
43
  const path = __importStar(require("path"));
44
+ const child_process_1 = require("child_process");
41
45
  const logger_1 = require("../utils/logger");
42
46
  const expression_1 = require("./expression");
47
+ const job_container_1 = require("./job-container");
48
+ const container_runtime_1 = require("./container-runtime");
43
49
  /** Marks a `path:` copy made by localCheckout, so it may be replaced. */
44
50
  const CHECKOUT_MARKER = '.secureci-checkout';
45
51
  /**
@@ -64,10 +70,34 @@ function copyWorkingCopy(srcDir, dstDir, exclude) {
64
70
  fs.cpSync(src, dst, {
65
71
  recursive: true,
66
72
  verbatimSymlinks: true,
73
+ // Copy-on-write clones where the file system supports them.
74
+ mode: fs.constants.COPYFILE_FICLONE,
67
75
  filter: (file) => !fs.existsSync(path.join(file, CHECKOUT_MARKER))
68
76
  });
69
77
  }
70
78
  }
79
+ /**
80
+ * A copy of the working copy (uncommitted changes and .git included) for
81
+ * one job, in `parentDir`, for --isolate-workspace. Remove it with
82
+ * removeIsolatedWorkspace.
83
+ */
84
+ function createIsolatedWorkspace(workingCopy, parentDir) {
85
+ const dir = fs.mkdtempSync(path.join(parentDir, 'workspace-'));
86
+ copyWorkingCopy(workingCopy, dir, dir);
87
+ return dir;
88
+ }
89
+ /**
90
+ * Remove a job's isolated workspace. Best effort: files a container step
91
+ * created as another user may not be removable, which is only warned about.
92
+ */
93
+ function removeIsolatedWorkspace(dir) {
94
+ try {
95
+ fs.rmSync(dir, { recursive: true, force: true });
96
+ }
97
+ catch (error) {
98
+ logger_1.logger.warn(`Couldn't remove the job's workspace copy ${dir}: ${error instanceof Error ? error.message : String(error)}`);
99
+ }
100
+ }
71
101
  /**
72
102
  * Whether a step is `actions/checkout` of the repository being run (no
73
103
  * `repository:` input, or the current one in any case), which is served
@@ -101,7 +131,135 @@ function escapesViaSymlink(workspace, dest) {
101
131
  const real = fs.realpathSync(existing);
102
132
  return real !== root && !real.startsWith(root + path.sep);
103
133
  }
104
- function localCheckout(step, ctx, runtime, startTime) {
134
+ /** Run git in `cwd` on the host; undefined when it fails. */
135
+ function gitOutput(cwd, args, env) {
136
+ try {
137
+ return (0, child_process_1.execFileSync)('git', args, {
138
+ cwd,
139
+ encoding: 'utf8',
140
+ stdio: ['ignore', 'pipe', 'ignore'],
141
+ ...(env ? { env: { ...process.env, ...env } } : {})
142
+ }).trim();
143
+ }
144
+ catch {
145
+ return undefined;
146
+ }
147
+ }
148
+ /**
149
+ * Environment for a git fetch from `url`: never prompts, and sends
150
+ * `token` (as actions/checkout does) only to an https URL on the server's
151
+ * own host, scoped to that host by the config key. Passed through the
152
+ * environment, so it never shows in the process list.
153
+ */
154
+ function gitAuthEnv(url, token, serverUrl) {
155
+ const env = { GIT_TERMINAL_PROMPT: '0' };
156
+ let target;
157
+ let server;
158
+ try {
159
+ target = new URL(url);
160
+ server = new URL(serverUrl);
161
+ }
162
+ catch {
163
+ return env;
164
+ }
165
+ if (!token || target.protocol !== 'https:' || target.host !== server.host) {
166
+ return env;
167
+ }
168
+ return {
169
+ ...env,
170
+ GIT_CONFIG_COUNT: '1',
171
+ GIT_CONFIG_KEY_0: `http.https://${target.host}/.extraheader`,
172
+ GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${Buffer.from(`x-access-token:${token}`).toString('base64')}`
173
+ };
174
+ }
175
+ /** `fetch-depth`: a non-negative integer, default 1 as on GitHub; 0 is all. */
176
+ function fetchDepth(raw) {
177
+ const n = Number(raw === undefined || raw === '' ? 1 : raw);
178
+ return Number.isInteger(n) && n >= 0 ? n : 1;
179
+ }
180
+ /**
181
+ * Check out `commit` (fetched from `source` when given, else from the
182
+ * local repository) into an empty `dest`, with `depth` commits of history
183
+ * (0: all). The working copy's `.git` is only read: a full-depth local
184
+ * checkout shares its objects; anything fetched lands in `dest` only.
185
+ * Returns the commit checked out, or undefined.
186
+ */
187
+ function cloneInto(workspace, dest, what, depth) {
188
+ const ok = (args, cwd = dest, env) => gitOutput(cwd, args, env) !== undefined;
189
+ const depthArgs = depth > 0 ? ['--depth', String(depth)] : [];
190
+ if ('commit' in what && depth === 0) {
191
+ return ok(['clone', '--quiet', '--shared', '--no-checkout', '--', workspace, dest], workspace) && ok(['checkout', '--quiet', '--detach', what.commit])
192
+ ? what.commit
193
+ : undefined;
194
+ }
195
+ fs.mkdirSync(dest, { recursive: true });
196
+ if (!ok(['init', '--quiet']) || !ok(['remote', 'add', 'origin', workspace])) {
197
+ return undefined;
198
+ }
199
+ const [source, ref, env] = 'commit' in what
200
+ ? [`file://${workspace}`, what.commit, undefined]
201
+ : [what.source, what.ref, what.env];
202
+ // `source` (the origin URL) and `ref` come from outside: never let either
203
+ // be read as an option (`--upload-pack=<cmd>`), and never allow the
204
+ // `ext::` transport, which runs a command, whatever the user's config says.
205
+ if (!ok([
206
+ '-c',
207
+ 'protocol.ext.allow=never',
208
+ 'fetch',
209
+ '--quiet',
210
+ '--no-tags',
211
+ ...depthArgs,
212
+ '--end-of-options',
213
+ source,
214
+ ref
215
+ ], dest, env)) {
216
+ return undefined;
217
+ }
218
+ const commit = gitOutput(dest, [
219
+ 'rev-parse',
220
+ '--verify',
221
+ 'FETCH_HEAD^{commit}'
222
+ ]);
223
+ return commit && ok(['checkout', '--quiet', '--detach', commit])
224
+ ? commit
225
+ : undefined;
226
+ }
227
+ /**
228
+ * The commit `ref` names in the local repository (as given, else as a
229
+ * branch of `origin`), or undefined. A ref that looks like an option is
230
+ * never passed to git.
231
+ */
232
+ function resolveLocalRef(workspace, ref) {
233
+ if (ref.startsWith('-'))
234
+ return undefined;
235
+ for (const candidate of [ref, `refs/remotes/origin/${ref}`]) {
236
+ const sha = gitOutput(workspace, [
237
+ 'rev-parse',
238
+ '--verify',
239
+ '--quiet',
240
+ '--end-of-options',
241
+ `${candidate}^{commit}`
242
+ ]);
243
+ if (sha)
244
+ return sha;
245
+ }
246
+ return undefined;
247
+ }
248
+ /**
249
+ * Submodules of the working copy that aren't initialized (`git submodule
250
+ * status` marks them with "-"), by path.
251
+ */
252
+ function uninitializedSubmodules(workspace) {
253
+ if (!fs.existsSync(path.join(workspace, '.gitmodules')))
254
+ return [];
255
+ const status = gitOutput(workspace, ['submodule', 'status', '--recursive']);
256
+ return (status ?? '')
257
+ .split('\n')
258
+ .filter((line) => line.startsWith('-'))
259
+ .map((line) => line.slice(1).split(' ')[1])
260
+ .filter(Boolean);
261
+ }
262
+ function localCheckout(step, ctx, runtime, startTime, options = {}) {
105
263
  const withValues = (0, expression_1.substituteRecord)(step.with, ctx);
106
264
  const outputs = {
107
265
  ref: String(ctx.github.ref ?? ''),
@@ -111,12 +269,7 @@ function localCheckout(step, ctx, runtime, startTime) {
111
269
  logger_1.logger.error(error);
112
270
  return { step, success: false, error, duration: Date.now() - startTime };
113
271
  };
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();
272
+ const workspace = runtime.workspace;
120
273
  const dest = path.resolve(workspace, withValues.path || '.');
121
274
  const relative = path.relative(workspace, dest);
122
275
  if (relative.startsWith('..') ||
@@ -124,6 +277,37 @@ function localCheckout(step, ctx, runtime, startTime) {
124
277
  escapesViaSymlink(workspace, dest)) {
125
278
  return fail(`actions/checkout: path "${withValues.path}" is outside the workspace`);
126
279
  }
280
+ // A ref other than the current one: check out that commit, when the local
281
+ // repository has it, into path: (never over the user's working copy).
282
+ // With --fetch-refs, a ref the local repository lacks is fetched from
283
+ // origin into the path: copy (never into the user's repository).
284
+ const ref = withValues.ref;
285
+ let commit;
286
+ let fetchFrom;
287
+ if (ref &&
288
+ ![ctx.github.ref, ctx.github.ref_name, ctx.github.sha].includes(ref)) {
289
+ commit = resolveLocalRef(workspace, ref);
290
+ if (relative === '') {
291
+ 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`);
292
+ commit = undefined;
293
+ }
294
+ else if (!commit && options.fetchRefs && !ref.startsWith('-')) {
295
+ fetchFrom = gitOutput(workspace, ['remote', 'get-url', 'origin']);
296
+ if (!fetchFrom) {
297
+ return fail(`actions/checkout: can't fetch ref "${ref}": the local repository has no origin remote`);
298
+ }
299
+ }
300
+ else if (!commit) {
301
+ logger_1.logger.warn(`actions/checkout: ref "${ref}" isn't in the local repository; using the local working copy (pass --fetch-refs to fetch it from origin, or --no-local-checkout to run the real action)`);
302
+ }
303
+ }
304
+ const submodules = String(withValues.submodules ?? '').toLowerCase();
305
+ if (submodules === 'true' || submodules === 'recursive') {
306
+ const missing = uninitializedSubmodules(workspace);
307
+ if (missing.length > 0) {
308
+ logger_1.logger.warn(`actions/checkout: submodules aren't initialized in your working copy (${missing.join(', ')}); run \`git submodule update --init --recursive\` first`);
309
+ }
310
+ }
127
311
  let output = 'Using local working copy';
128
312
  if (relative !== '') {
129
313
  if (fs.existsSync(dest)) {
@@ -134,14 +318,46 @@ function localCheckout(step, ctx, runtime, startTime) {
134
318
  return fail(`actions/checkout: path "${withValues.path}" already exists in the working copy; remove it or pass --no-local-checkout`);
135
319
  }
136
320
  }
137
- copyWorkingCopy(workspace, dest, dest);
321
+ const depth = fetchDepth(withValues['fetch-depth']);
322
+ if (fetchFrom) {
323
+ const token = withValues.token || String(ctx.github.token ?? '');
324
+ const fetched = cloneInto(workspace, dest, {
325
+ ref: String(ref),
326
+ source: fetchFrom,
327
+ env: gitAuthEnv(fetchFrom, token, String(ctx.github.server_url ?? 'https://github.com'))
328
+ }, depth);
329
+ if (!fetched) {
330
+ fs.rmSync(dest, { recursive: true, force: true });
331
+ return fail(`actions/checkout: couldn't fetch ref "${ref}" from origin (${fetchFrom})`);
332
+ }
333
+ outputs.ref = String(ref);
334
+ outputs.commit = fetched;
335
+ output = `Fetched ${ref} (${fetched.slice(0, 7)}) to ${relative}`;
336
+ }
337
+ else if (commit) {
338
+ // A local clone (sharing the repository's objects at full depth):
339
+ // nothing is fetched and the working copy's .git is only read.
340
+ if (!cloneInto(workspace, dest, { commit }, depth)) {
341
+ fs.rmSync(dest, { recursive: true, force: true });
342
+ return fail(`actions/checkout: couldn't check out ref "${ref}" to "${withValues.path}"`);
343
+ }
344
+ outputs.ref = String(ref);
345
+ outputs.commit = commit;
346
+ output = `Checked out ${ref} (${commit.slice(0, 7)}) to ${relative}`;
347
+ }
348
+ else {
349
+ copyWorkingCopy(workspace, dest, dest);
350
+ output = `Using local working copy (copied to ${relative})`;
351
+ }
138
352
  fs.writeFileSync(path.join(dest, CHECKOUT_MARKER), '');
139
- output = `Using local working copy (copied to ${relative})`;
140
353
  const postStep = { name: `Post ${step.name || step.uses}` };
141
354
  runtime.postSteps.push({
142
355
  step: postStep,
143
356
  condition: 'always()',
144
357
  run: async () => {
358
+ // Files a container step created as root may not be removable by
359
+ // the host user: remove the copy from inside the container first.
360
+ await (0, job_container_1.removeInContainer)(runtime, path.posix.join(container_runtime_1.CONTAINER_WORKDIR, ...relative.split(path.sep)));
145
361
  fs.rmSync(dest, { recursive: true, force: true });
146
362
  return { step: postStep, success: true, duration: 0 };
147
363
  }