@secureci/cli 1.0.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -11
- package/dist/advisories.js +72 -12
- package/dist/cli.js +53 -1
- package/dist/config.js +24 -1
- package/dist/org-scan-script.js +6 -0
- package/dist/org-scan.js +88 -0
- package/dist/parser/pipeline-contract-validator.js +152 -24
- package/dist/runner/action-fetcher.js +6 -1
- package/dist/runner/actions.js +10 -8
- package/dist/runner/checkout.js +225 -9
- package/dist/runner/container-policy.js +32 -0
- package/dist/runner/container-runtime.js +74 -1
- package/dist/runner/docker-action.js +1 -1
- package/dist/runner/executor.js +78 -8
- package/dist/runner/expression.js +165 -3
- package/dist/runner/job-container.js +147 -14
- package/dist/runner/job-runtime.js +4 -3
- package/dist/runner/node-action.js +1 -1
- package/dist/runner/runner-env.js +20 -0
- package/dist/runner/scheduler.js +84 -2
- package/dist/runner/steps.js +2 -2
- package/package.json +1 -1
|
@@ -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
|
|
85
|
-
|
|
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,
|
|
382
|
+
const addUsage = (node, index, at, uses, runs) => {
|
|
359
383
|
graph.sequence.push({
|
|
360
384
|
node,
|
|
361
385
|
index,
|
|
362
386
|
path: at,
|
|
363
|
-
|
|
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,
|
|
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
|
|
376
|
-
addUsage(node, counter.next++, stepPath, step.uses,
|
|
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,
|
|
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,
|
|
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
|
|
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,
|
|
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],
|
|
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,
|
|
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)],
|
|
463
|
+
await addWorkflow(parsed.file, root, '', '', '', [], [path.resolve(parsed.path)], known.map(() => true));
|
|
436
464
|
return graph;
|
|
437
465
|
}
|
|
438
466
|
/**
|
|
439
|
-
*
|
|
440
|
-
*
|
|
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
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
})));
|
package/dist/runner/actions.js
CHANGED
|
@@ -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)(
|
|
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:
|
|
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(
|
|
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(
|
|
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(
|
|
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 {
|
package/dist/runner/checkout.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|