@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.
- package/README.md +20 -11
- package/dist/advisories.js +33 -6
- package/dist/cli.js +46 -1
- package/dist/config.js +12 -1
- package/dist/org-scan-script.js +6 -0
- package/dist/org-scan.js +88 -0
- package/dist/parser/pipeline-contract-validator.js +56 -25
- package/dist/runner/action-fetcher.js +6 -1
- package/dist/runner/actions.js +7 -7
- package/dist/runner/checkout.js +124 -8
- package/dist/runner/container-policy.js +32 -0
- package/dist/runner/docker-action.js +1 -1
- package/dist/runner/executor.js +28 -6
- package/dist/runner/expression.js +125 -3
- package/dist/runner/job-container.js +57 -11
- 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/steps.js +2 -2
- package/package.json +1 -1
package/dist/runner/checkout.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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:
|
|
134
|
+
workingDir: runtime.workspace,
|
|
135
135
|
readOnlyPaths: (0, job_container_1.protectedWorkspacePaths)(options),
|
|
136
136
|
exchangeDir,
|
|
137
137
|
env: {
|
package/dist/runner/executor.js
CHANGED
|
@@ -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
|
-
|
|
295
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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,
|
|
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:
|
|
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
|
|
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-')),
|