@secureci/cli 1.1.0 → 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 +17 -11
- package/dist/advisories.js +42 -9
- package/dist/cli.js +7 -0
- package/dist/config.js +12 -0
- package/dist/parser/pipeline-contract-validator.js +98 -1
- package/dist/runner/actions.js +3 -1
- package/dist/runner/checkout.js +122 -22
- package/dist/runner/container-runtime.js +74 -1
- package/dist/runner/executor.js +58 -10
- package/dist/runner/expression.js +40 -0
- package/dist/runner/job-container.js +93 -6
- package/dist/runner/scheduler.js +84 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -73,10 +73,10 @@ less install.sh
|
|
|
73
73
|
sh install.sh
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
-
Set `SECURECI_VERSION=1.
|
|
76
|
+
Set `SECURECI_VERSION=1.2.0` to install a specific release,
|
|
77
77
|
`SECURECI_INSTALL_DIR` to install somewhere else, and
|
|
78
78
|
`SECURECI_REQUIRE_ATTESTATION=1` to fail unless `gh` verifies build provenance
|
|
79
|
-
(e.g. `curl -fsSL https://secure-ci.dev/install.sh | SECURECI_VERSION=1.
|
|
79
|
+
(e.g. `curl -fsSL https://secure-ci.dev/install.sh | SECURECI_VERSION=1.2.0 sh`).
|
|
80
80
|
If the release's download link fails (GitHub has returned `504` for one while
|
|
81
81
|
its API still served the files), the script fetches the same files through the
|
|
82
82
|
GitHub API instead; the `SHA256SUMS` and provenance checks are unchanged.
|
|
@@ -263,18 +263,24 @@ things to know:
|
|
|
263
263
|
`--isolate-workspace` to give each job its own copy, or `--parallel 1` to run
|
|
264
264
|
jobs one after another.
|
|
265
265
|
- **`actions/checkout` of the current repo doesn't clone** — it uses your working
|
|
266
|
-
copy as-is (so uncommitted changes are visible). A `ref:`
|
|
267
|
-
|
|
268
|
-
`fetch-depth:`
|
|
269
|
-
real and need a token.
|
|
266
|
+
copy as-is (so uncommitted changes are visible). A `ref:` is checked out only
|
|
267
|
+
into a `path:`, from your local repository, or fetched from `origin` with
|
|
268
|
+
`--fetch-refs` (opt-in); `fetch-depth:` applies to that clone. Other
|
|
269
|
+
repositories check out for real and need a token.
|
|
270
270
|
- **Artifacts and cache are local stand-ins**, stored under `~/.secureci/`;
|
|
271
271
|
nothing is uploaded to or downloaded from GitHub.
|
|
272
|
-
-
|
|
273
|
-
`
|
|
274
|
-
|
|
272
|
+
- **`concurrency:`** groups of jobs run one at a time within a run;
|
|
273
|
+
`--concurrency github` also cancels as GitHub does. Workflow-level
|
|
274
|
+
`concurrency:` has no other runs to apply to.
|
|
275
|
+
- **Container `credentials:`** are used only with `--registry-login`, through a
|
|
276
|
+
temporary login removed right after the pull; otherwise use `docker login`.
|
|
277
|
+
- **Not emulated:** deployment `environment:` (protection rules and
|
|
278
|
+
environment-scoped secrets/vars: pass them with `--secret`/`--var`), and OIDC
|
|
279
|
+
tokens for `id-token: write`.
|
|
275
280
|
- **Contract checks** decide `if:` conditions only from the workflow's triggers
|
|
276
|
-
(`github.event_name
|
|
277
|
-
|
|
281
|
+
(`github.event_name`, and `github.ref` / `inputs` where the triggers pin
|
|
282
|
+
them) and, without `--contract-recurse all`, don't look inside _remote_
|
|
283
|
+
composite actions / reusable workflows.
|
|
278
284
|
|
|
279
285
|
The exact scope of each is documented in
|
|
280
286
|
[DOCS.md](DOCS.md#contract-limitations-v1-scope).
|
package/dist/advisories.js
CHANGED
|
@@ -389,11 +389,13 @@ async function fetchRepoAdvisories(owner, repo, options = {}) {
|
|
|
389
389
|
cached = undefined;
|
|
390
390
|
}
|
|
391
391
|
const isStale = cached ? now - cached.fetchedAt >= ttl : false;
|
|
392
|
-
|
|
393
|
-
|
|
392
|
+
const cachedTruncated = cached?.truncated ? { truncated: true } : {};
|
|
393
|
+
if (cached && !isStale) {
|
|
394
|
+
return { advisories: cached.advisories, ...cachedTruncated };
|
|
395
|
+
}
|
|
394
396
|
if (options.offline) {
|
|
395
397
|
return cached
|
|
396
|
-
? { advisories: cached.advisories, stale: true }
|
|
398
|
+
? { advisories: cached.advisories, stale: true, ...cachedTruncated }
|
|
397
399
|
: { error: 'offline and not cached' };
|
|
398
400
|
}
|
|
399
401
|
const httpGet = options.httpGet ??
|
|
@@ -433,22 +435,39 @@ async function fetchRepoAdvisories(owner, repo, options = {}) {
|
|
|
433
435
|
const advisories = parseAdvisories(JSON.parse(response.body));
|
|
434
436
|
// Later pages only from the API's own origin, which the token is for.
|
|
435
437
|
const origin = new URL(url).origin;
|
|
436
|
-
|
|
438
|
+
const sameOriginNext = () => {
|
|
437
439
|
const next = nextPageUrl(response.link);
|
|
438
|
-
|
|
440
|
+
return next && new URL(next, url).origin === origin
|
|
441
|
+
? new URL(next, url).toString()
|
|
442
|
+
: undefined;
|
|
443
|
+
};
|
|
444
|
+
for (let pages = 1; pages < MAX_ADVISORY_PAGES; pages++) {
|
|
445
|
+
const next = sameOriginNext();
|
|
446
|
+
if (!next)
|
|
439
447
|
break;
|
|
440
|
-
response = await page(
|
|
448
|
+
response = await page(next);
|
|
441
449
|
advisories.push(...parseAdvisories(JSON.parse(response.body)));
|
|
442
450
|
}
|
|
451
|
+
const truncated = sameOriginNext() !== undefined;
|
|
443
452
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
444
|
-
const entry = {
|
|
453
|
+
const entry = {
|
|
454
|
+
api,
|
|
455
|
+
fetchedAt: now,
|
|
456
|
+
advisories,
|
|
457
|
+
...(truncated ? { truncated } : {})
|
|
458
|
+
};
|
|
445
459
|
fs.writeFileSync(file, JSON.stringify(entry, null, 2));
|
|
446
|
-
return { advisories };
|
|
460
|
+
return { advisories, ...(truncated ? { truncated } : {}) };
|
|
447
461
|
}
|
|
448
462
|
catch (error) {
|
|
449
463
|
const message = describeError(error, !!token);
|
|
450
464
|
return cached
|
|
451
|
-
? {
|
|
465
|
+
? {
|
|
466
|
+
advisories: cached.advisories,
|
|
467
|
+
stale: true,
|
|
468
|
+
error: message,
|
|
469
|
+
...cachedTruncated
|
|
470
|
+
}
|
|
452
471
|
: { error: message };
|
|
453
472
|
}
|
|
454
473
|
}
|
|
@@ -507,6 +526,9 @@ class AdvisoryChecker {
|
|
|
507
526
|
else if (result.stale) {
|
|
508
527
|
report.stale.push(repoName);
|
|
509
528
|
}
|
|
529
|
+
if (result.truncated) {
|
|
530
|
+
(report.truncated ?? (report.truncated = [])).push(repoName);
|
|
531
|
+
}
|
|
510
532
|
}
|
|
511
533
|
const advisories = relevant(target, result.advisories ?? []);
|
|
512
534
|
if (advisories.length === 0)
|
|
@@ -589,6 +611,13 @@ function describeReport(report, failOn) {
|
|
|
589
611
|
text: `Used cached security advisories older than 24h for ${report.stale.join(', ')} (they could not be refreshed).`
|
|
590
612
|
});
|
|
591
613
|
}
|
|
614
|
+
if (report.truncated?.length) {
|
|
615
|
+
lines.push({
|
|
616
|
+
level: 'warn',
|
|
617
|
+
kind: 'note',
|
|
618
|
+
text: `Only the first ${(MAX_ADVISORY_PAGES * 100).toLocaleString('en-US')} security advisories were read for ${report.truncated.join(', ')}; later ones were not checked.`
|
|
619
|
+
});
|
|
620
|
+
}
|
|
592
621
|
return lines;
|
|
593
622
|
}
|
|
594
623
|
/**
|
|
@@ -624,6 +653,10 @@ function mergeReports(reports) {
|
|
|
624
653
|
if (!merged.stale.includes(s))
|
|
625
654
|
merged.stale.push(s);
|
|
626
655
|
}
|
|
656
|
+
for (const t of r.truncated ?? []) {
|
|
657
|
+
if (!merged.truncated?.includes(t))
|
|
658
|
+
(merged.truncated ?? (merged.truncated = [])).push(t);
|
|
659
|
+
}
|
|
627
660
|
}
|
|
628
661
|
return merged;
|
|
629
662
|
}
|
package/dist/cli.js
CHANGED
|
@@ -123,6 +123,8 @@ program
|
|
|
123
123
|
.option('--matrix <key:value...>', 'Run only the matrix combinations whose key has this value (as shown in the job name). Repeat for more keys (all must match) or values (any matches)')
|
|
124
124
|
.option('--offline', 'Use only cached remote actions and workflows; never download')
|
|
125
125
|
.option('--no-local-checkout', 'Run the real actions/checkout for this repository instead of using the working copy')
|
|
126
|
+
.option('--fetch-refs', "Fetch an actions/checkout ref: the local repository doesn't have (e.g. refs/pull/N/merge) from origin into its path: copy. Uses your git credentials, or GITHUB_TOKEN for the server's own host. Off by default: a run doesn't touch the network for checkouts")
|
|
127
|
+
.option('--registry-login', 'Pull job and service container images that set credentials: with them: a temporary login, removed right after the pull, with the password on stdin and masked in logs. Off by default: credentials: are warned about and your own docker login is used')
|
|
126
128
|
.addOption(new commander_1.Option('--container-runtime <name>', 'Container engine for jobs. "host" runs steps directly on this machine, without a container. Config: container-runtime')
|
|
127
129
|
.choices(['auto', 'docker', 'podman', 'containerd', 'host'])
|
|
128
130
|
.default(defaultContainerRuntime))
|
|
@@ -136,6 +138,7 @@ program
|
|
|
136
138
|
.option('--isolate-workspace', "Run each job in its own copy of the working copy, removed when the job ends, so parallel jobs can't overwrite each other's files. Config: isolate-workspace")
|
|
137
139
|
.option('--no-isolate-workspace', 'Share the working copy between jobs, overriding isolate-workspace: true in the config')
|
|
138
140
|
.option('--parallel <n>', 'Run up to this many jobs (and matrix combinations) at once; 1 runs them one after another. Default: the number of CPUs. Config: parallel', String(defaultParallel))
|
|
141
|
+
.option('--concurrency <mode>', 'How jobs sharing a concurrency: group run: serialize (one at a time, all of them) or github (also cancel as GitHub does: one pending per group, newer pending replaces older, cancel-in-progress). Default: serialize. Config: concurrency')
|
|
139
142
|
.action(async (file, options) => {
|
|
140
143
|
if (configError) {
|
|
141
144
|
logger_1.logger.error(configError);
|
|
@@ -191,6 +194,7 @@ program
|
|
|
191
194
|
fromEnv: process.env
|
|
192
195
|
});
|
|
193
196
|
const parallel = (0, scheduler_1.parseParallel)(options.parallel);
|
|
197
|
+
const concurrency = (0, config_1.resolveConcurrency)(options.concurrency, globalConfig);
|
|
194
198
|
const result = await (0, executor_1.executeWorkflow)(file, {
|
|
195
199
|
dryRun: false,
|
|
196
200
|
inputs,
|
|
@@ -202,6 +206,8 @@ program
|
|
|
202
206
|
job: options.job,
|
|
203
207
|
matrix,
|
|
204
208
|
localCheckout: options.localCheckout,
|
|
209
|
+
fetchRefs: options.fetchRefs === true,
|
|
210
|
+
registryLogin: options.registryLogin === true,
|
|
205
211
|
offline: options.offline,
|
|
206
212
|
verbose: program.opts().verbose,
|
|
207
213
|
containerRuntime: options.containerRuntime,
|
|
@@ -214,6 +220,7 @@ program
|
|
|
214
220
|
containerMemoryLimit: options.containerMemory,
|
|
215
221
|
networkDisabled: (0, config_1.resolveNetworkDisabled)({ network: options.network, allowNetwork: options.allowNetwork }, globalConfig),
|
|
216
222
|
parallel,
|
|
223
|
+
concurrency,
|
|
217
224
|
isolateWorkspace: (0, config_1.resolveIsolateWorkspace)(options.isolateWorkspace, globalConfig)
|
|
218
225
|
});
|
|
219
226
|
if (!result.success) {
|
package/dist/config.js
CHANGED
|
@@ -45,6 +45,7 @@ exports.setConfigValue = setConfigValue;
|
|
|
45
45
|
exports.unsetConfigValue = unsetConfigValue;
|
|
46
46
|
exports.parseConfigValue = parseConfigValue;
|
|
47
47
|
exports.resolveIsolateWorkspace = resolveIsolateWorkspace;
|
|
48
|
+
exports.resolveConcurrency = resolveConcurrency;
|
|
48
49
|
exports.resolveNetworkDisabled = resolveNetworkDisabled;
|
|
49
50
|
const fs = __importStar(require("fs"));
|
|
50
51
|
const path = __importStar(require("path"));
|
|
@@ -116,6 +117,7 @@ exports.CONFIG_KEYS = [
|
|
|
116
117
|
'allow-network',
|
|
117
118
|
'parallel',
|
|
118
119
|
'isolate-workspace',
|
|
120
|
+
'concurrency',
|
|
119
121
|
'env-file',
|
|
120
122
|
'secret-file',
|
|
121
123
|
'input-file',
|
|
@@ -186,6 +188,8 @@ function parseConfigValue(key, rawValue) {
|
|
|
186
188
|
return rawValue === 'true';
|
|
187
189
|
case 'parallel':
|
|
188
190
|
return (0, scheduler_1.parseParallel)(rawValue);
|
|
191
|
+
case 'concurrency':
|
|
192
|
+
return (0, scheduler_1.parseConcurrencyMode)(rawValue);
|
|
189
193
|
case 'container-image':
|
|
190
194
|
case 'container-memory':
|
|
191
195
|
return rawValue;
|
|
@@ -207,6 +211,14 @@ function parseConfigValue(key, rawValue) {
|
|
|
207
211
|
function resolveIsolateWorkspace(flag, config = loadConfig()) {
|
|
208
212
|
return flag ?? config['isolate-workspace'] === true;
|
|
209
213
|
}
|
|
214
|
+
/**
|
|
215
|
+
* How job `concurrency:` groups apply: `--concurrency`, else
|
|
216
|
+
* `concurrency` in the config file, else serialize.
|
|
217
|
+
*/
|
|
218
|
+
function resolveConcurrency(flag, config = loadConfig()) {
|
|
219
|
+
const value = flag ?? config.concurrency;
|
|
220
|
+
return value === undefined ? 'serialize' : (0, scheduler_1.parseConcurrencyMode)(value);
|
|
221
|
+
}
|
|
210
222
|
/**
|
|
211
223
|
* Whether container networking should be disabled. Networking is on by
|
|
212
224
|
* default (matching GitHub-hosted runners); `--no-network` turns it off and
|
|
@@ -290,7 +290,7 @@ async function buildGraph(parsed, recurse, offline) {
|
|
|
290
290
|
// workflow_call, the event is the caller's, so it isn't known.
|
|
291
291
|
graph.events = triggerEvents(parsed.file.on);
|
|
292
292
|
const known = graph.events
|
|
293
|
-
? graph.events.map((event) => (
|
|
293
|
+
? graph.events.map((event) => triggerFacts(parsed.file.on, event))
|
|
294
294
|
: [{}];
|
|
295
295
|
/** `runs`, narrowed by a job or step's own `if:`. */
|
|
296
296
|
const within = (runs, condition) => runs.map((outer, i) => {
|
|
@@ -480,6 +480,103 @@ function triggerEvents(on) {
|
|
|
480
480
|
? undefined
|
|
481
481
|
: events;
|
|
482
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);
|
|
498
|
+
}
|
|
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
|
+
};
|
|
579
|
+
}
|
|
483
580
|
function splitActionRef(uses) {
|
|
484
581
|
const atIndex = uses.lastIndexOf('@');
|
|
485
582
|
if (atIndex === -1 || uses.startsWith('docker://')) {
|
package/dist/runner/actions.js
CHANGED
|
@@ -301,7 +301,9 @@ async function executeAction(step, options, startTime, runtime, ctx) {
|
|
|
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) {
|
package/dist/runner/checkout.js
CHANGED
|
@@ -37,12 +37,15 @@ exports.copyWorkingCopy = copyWorkingCopy;
|
|
|
37
37
|
exports.createIsolatedWorkspace = createIsolatedWorkspace;
|
|
38
38
|
exports.removeIsolatedWorkspace = removeIsolatedWorkspace;
|
|
39
39
|
exports.isLocalCheckout = isLocalCheckout;
|
|
40
|
+
exports.gitAuthEnv = gitAuthEnv;
|
|
40
41
|
exports.localCheckout = localCheckout;
|
|
41
42
|
const fs = __importStar(require("fs"));
|
|
42
43
|
const path = __importStar(require("path"));
|
|
43
44
|
const child_process_1 = require("child_process");
|
|
44
45
|
const logger_1 = require("../utils/logger");
|
|
45
46
|
const expression_1 = require("./expression");
|
|
47
|
+
const job_container_1 = require("./job-container");
|
|
48
|
+
const container_runtime_1 = require("./container-runtime");
|
|
46
49
|
/** Marks a `path:` copy made by localCheckout, so it may be replaced. */
|
|
47
50
|
const CHECKOUT_MARKER = '.secureci-checkout';
|
|
48
51
|
/**
|
|
@@ -129,18 +132,98 @@ function escapesViaSymlink(workspace, dest) {
|
|
|
129
132
|
return real !== root && !real.startsWith(root + path.sep);
|
|
130
133
|
}
|
|
131
134
|
/** Run git in `cwd` on the host; undefined when it fails. */
|
|
132
|
-
function gitOutput(cwd, args) {
|
|
135
|
+
function gitOutput(cwd, args, env) {
|
|
133
136
|
try {
|
|
134
137
|
return (0, child_process_1.execFileSync)('git', args, {
|
|
135
138
|
cwd,
|
|
136
139
|
encoding: 'utf8',
|
|
137
|
-
stdio: ['ignore', 'pipe', 'ignore']
|
|
140
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
141
|
+
...(env ? { env: { ...process.env, ...env } } : {})
|
|
138
142
|
}).trim();
|
|
139
143
|
}
|
|
140
144
|
catch {
|
|
141
145
|
return undefined;
|
|
142
146
|
}
|
|
143
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
|
+
}
|
|
144
227
|
/**
|
|
145
228
|
* The commit `ref` names in the local repository (as given, else as a
|
|
146
229
|
* branch of `origin`), or undefined. A ref that looks like an option is
|
|
@@ -176,7 +259,7 @@ function uninitializedSubmodules(workspace) {
|
|
|
176
259
|
.map((line) => line.slice(1).split(' ')[1])
|
|
177
260
|
.filter(Boolean);
|
|
178
261
|
}
|
|
179
|
-
function localCheckout(step, ctx, runtime, startTime) {
|
|
262
|
+
function localCheckout(step, ctx, runtime, startTime, options = {}) {
|
|
180
263
|
const withValues = (0, expression_1.substituteRecord)(step.with, ctx);
|
|
181
264
|
const outputs = {
|
|
182
265
|
ref: String(ctx.github.ref ?? ''),
|
|
@@ -196,18 +279,27 @@ function localCheckout(step, ctx, runtime, startTime) {
|
|
|
196
279
|
}
|
|
197
280
|
// A ref other than the current one: check out that commit, when the local
|
|
198
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).
|
|
199
284
|
const ref = withValues.ref;
|
|
200
285
|
let commit;
|
|
286
|
+
let fetchFrom;
|
|
201
287
|
if (ref &&
|
|
202
288
|
![ctx.github.ref, ctx.github.ref_name, ctx.github.sha].includes(ref)) {
|
|
203
289
|
commit = resolveLocalRef(workspace, ref);
|
|
204
|
-
if (
|
|
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 === '') {
|
|
290
|
+
if (relative === '') {
|
|
208
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`);
|
|
209
292
|
commit = undefined;
|
|
210
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
|
+
}
|
|
211
303
|
}
|
|
212
304
|
const submodules = String(withValues.submodules ?? '').toLowerCase();
|
|
213
305
|
if (submodules === 'true' || submodules === 'recursive') {
|
|
@@ -226,21 +318,26 @@ function localCheckout(step, ctx, runtime, startTime) {
|
|
|
226
318
|
return fail(`actions/checkout: path "${withValues.path}" already exists in the working copy; remove it or pass --no-local-checkout`);
|
|
227
319
|
}
|
|
228
320
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
'
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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)) {
|
|
244
341
|
fs.rmSync(dest, { recursive: true, force: true });
|
|
245
342
|
return fail(`actions/checkout: couldn't check out ref "${ref}" to "${withValues.path}"`);
|
|
246
343
|
}
|
|
@@ -258,6 +355,9 @@ function localCheckout(step, ctx, runtime, startTime) {
|
|
|
258
355
|
step: postStep,
|
|
259
356
|
condition: 'always()',
|
|
260
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)));
|
|
261
361
|
fs.rmSync(dest, { recursive: true, force: true });
|
|
262
362
|
return { step: postStep, success: true, duration: 0 };
|
|
263
363
|
}
|
|
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.CONTAINER_EXCHANGE_DIR = exports.CONTAINER_WORKDIR = void 0;
|
|
37
|
+
exports.registryOf = registryOf;
|
|
37
38
|
exports.resolveContainerRuntime = resolveContainerRuntime;
|
|
38
39
|
exports._resetRuntimeCacheForTests = _resetRuntimeCacheForTests;
|
|
39
40
|
const child_process_1 = require("child_process");
|
|
@@ -50,6 +51,18 @@ const RUNTIME_BINARIES = {
|
|
|
50
51
|
podman: 'podman',
|
|
51
52
|
containerd: 'nerdctl'
|
|
52
53
|
};
|
|
54
|
+
/**
|
|
55
|
+
* The registry an image reference pulls from: its first path component
|
|
56
|
+
* when that names a host (has a `.` or `:`, or is `localhost`), else
|
|
57
|
+
* Docker Hub.
|
|
58
|
+
*/
|
|
59
|
+
function registryOf(image) {
|
|
60
|
+
const slash = image.indexOf('/');
|
|
61
|
+
if (slash === -1)
|
|
62
|
+
return 'docker.io';
|
|
63
|
+
const first = image.slice(0, slash);
|
|
64
|
+
return /[.:]/.test(first) || first === 'localhost' ? first : 'docker.io';
|
|
65
|
+
}
|
|
53
66
|
exports.CONTAINER_WORKDIR = '/secureci-workspace';
|
|
54
67
|
exports.CONTAINER_EXCHANGE_DIR = '/secureci-exchange';
|
|
55
68
|
/**
|
|
@@ -232,6 +245,7 @@ class CliContainerRuntime {
|
|
|
232
245
|
'exec',
|
|
233
246
|
'-w',
|
|
234
247
|
options.workingDir || exports.CONTAINER_WORKDIR,
|
|
248
|
+
...(options.user ? ['-u', options.user] : []),
|
|
235
249
|
...env.args
|
|
236
250
|
];
|
|
237
251
|
if (options.args) {
|
|
@@ -294,7 +308,64 @@ class CliContainerRuntime {
|
|
|
294
308
|
// Best effort: the container may already be gone
|
|
295
309
|
}
|
|
296
310
|
}
|
|
297
|
-
|
|
311
|
+
async pull(image, auth) {
|
|
312
|
+
const invalid = (0, container_policy_1.invalidImageReason)(image);
|
|
313
|
+
if (invalid)
|
|
314
|
+
return { success: false, error: invalid };
|
|
315
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'secureci-auth-'));
|
|
316
|
+
fs.chmodSync(dir, 0o700);
|
|
317
|
+
try {
|
|
318
|
+
const clientEnv = { ...process.env };
|
|
319
|
+
if (this.name === 'podman') {
|
|
320
|
+
clientEnv.REGISTRY_AUTH_FILE = path.join(dir, 'auth.json');
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
clientEnv.DOCKER_CONFIG = dir;
|
|
324
|
+
// A docker context lives in the user's config dir: pin its
|
|
325
|
+
// endpoint so the private config dir talks to the same engine.
|
|
326
|
+
if (this.name === 'docker' && !process.env.DOCKER_HOST) {
|
|
327
|
+
const context = await this.runCli([
|
|
328
|
+
'context',
|
|
329
|
+
'inspect',
|
|
330
|
+
'--format',
|
|
331
|
+
'{{.Endpoints.docker.Host}}'
|
|
332
|
+
]);
|
|
333
|
+
const host = context.stdout.trim();
|
|
334
|
+
if (context.success && host)
|
|
335
|
+
clientEnv.DOCKER_HOST = host;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const transport = {
|
|
339
|
+
args: [],
|
|
340
|
+
clientEnv,
|
|
341
|
+
cleanup: () => { }
|
|
342
|
+
};
|
|
343
|
+
const login = await this.runCli([
|
|
344
|
+
'login',
|
|
345
|
+
'--username',
|
|
346
|
+
auth.username,
|
|
347
|
+
'--password-stdin',
|
|
348
|
+
auth.registry
|
|
349
|
+
], undefined, transport, auth.password);
|
|
350
|
+
if (!login.success) {
|
|
351
|
+
return {
|
|
352
|
+
success: false,
|
|
353
|
+
error: `login to ${auth.registry} failed: ${login.stderr.trim() || `exit code ${login.exitCode}`}`
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
const pulled = await this.runCli(['pull', image], undefined, transport);
|
|
357
|
+
return pulled.success
|
|
358
|
+
? { success: true }
|
|
359
|
+
: {
|
|
360
|
+
success: false,
|
|
361
|
+
error: pulled.stderr.trim() || `exit code ${pulled.exitCode}`
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
finally {
|
|
365
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
runCli(args, timeoutMs, env, input) {
|
|
298
369
|
const startTime = Date.now();
|
|
299
370
|
return new Promise((resolve) => {
|
|
300
371
|
let child;
|
|
@@ -305,6 +376,8 @@ class CliContainerRuntime {
|
|
|
305
376
|
env?.cleanup();
|
|
306
377
|
throw err;
|
|
307
378
|
}
|
|
379
|
+
if (input !== undefined)
|
|
380
|
+
child.stdin.end(input);
|
|
308
381
|
let stdout = '';
|
|
309
382
|
let stderr = '';
|
|
310
383
|
let timedOut = false;
|
package/dist/runner/executor.js
CHANGED
|
@@ -162,15 +162,20 @@ async function executeWorkflow(filePath, options = {}, internal = {}) {
|
|
|
162
162
|
return { success: false, jobs: [], duration: Date.now() - startTime };
|
|
163
163
|
}
|
|
164
164
|
logger_1.logger.info(`Executing workflow: ${parsed.file.name}`);
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
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`);
|
|
165
|
+
const concurrencyMode = options.concurrency ?? 'serialize';
|
|
166
|
+
if (parsed.file.concurrency && !internal.groups) {
|
|
167
|
+
logger_1.logger.info("workflow-level concurrency: isn't applied in a local run: there are no other runs to queue behind or cancel");
|
|
173
168
|
}
|
|
169
|
+
const notCancelling = Object.entries(parsed.file.jobs)
|
|
170
|
+
.filter(([, job]) => typeof job.concurrency === 'object' &&
|
|
171
|
+
job.concurrency['cancel-in-progress'] !== undefined &&
|
|
172
|
+
job.concurrency['cancel-in-progress'] !== false)
|
|
173
|
+
.map(([id]) => `jobs.${id}`);
|
|
174
|
+
if (concurrencyMode === 'serialize' && notCancelling.length > 0) {
|
|
175
|
+
logger_1.logger.info(`cancel-in-progress isn't applied with --concurrency serialize (${notCancelling.join(', ')}): jobs in a group run one at a time, all of them; pass --concurrency github to cancel as GitHub does`);
|
|
176
|
+
}
|
|
177
|
+
const groups = internal.groups ??
|
|
178
|
+
new scheduler_1.ConcurrencyGroups(concurrencyMode, (message) => logger_1.logger.info(message));
|
|
174
179
|
options = {
|
|
175
180
|
...options,
|
|
176
181
|
inputs: {
|
|
@@ -268,6 +273,40 @@ async function executeWorkflow(filePath, options = {}, internal = {}) {
|
|
|
268
273
|
// Each combination's result and outputs, by combination index.
|
|
269
274
|
const unitResults = [];
|
|
270
275
|
const unitOutputs = [];
|
|
276
|
+
// `concurrency:`: each combination waits for its group (with the
|
|
277
|
+
// group evaluated for its matrix values) before taking a slot.
|
|
278
|
+
const concurrencySpec = typeof job.concurrency === 'string'
|
|
279
|
+
? { group: job.concurrency }
|
|
280
|
+
: job.concurrency;
|
|
281
|
+
const concurrencyGate = concurrencySpec
|
|
282
|
+
? {
|
|
283
|
+
gate: (index, abort) => {
|
|
284
|
+
const values = selected[index].values;
|
|
285
|
+
const ctx = { ...jobIfCtx, matrix: values };
|
|
286
|
+
let group = '';
|
|
287
|
+
try {
|
|
288
|
+
group = (0, expression_1.substituteExpressions)(String(concurrencySpec.group ?? ''), ctx).trim();
|
|
289
|
+
}
|
|
290
|
+
catch (error) {
|
|
291
|
+
logger_1.logger.warn(`jobs.${jobId}.concurrency: ${error instanceof Error ? error.message : String(error)}; running without a group`);
|
|
292
|
+
}
|
|
293
|
+
if (!group)
|
|
294
|
+
return Promise.resolve(() => undefined);
|
|
295
|
+
return groups.enter(group, (0, job_plan_1.isTruthySetting)(concurrencySpec['cancel-in-progress'], ctx), abort, labelOf(values));
|
|
296
|
+
},
|
|
297
|
+
onGateCancelled: (index) => {
|
|
298
|
+
const { index: combinationIndex, values } = selected[index];
|
|
299
|
+
unitResults[combinationIndex] = {
|
|
300
|
+
job: labelOf(values),
|
|
301
|
+
success: false,
|
|
302
|
+
cancelled: true,
|
|
303
|
+
steps: [],
|
|
304
|
+
duration: 0
|
|
305
|
+
};
|
|
306
|
+
return { result: 'cancelled' };
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
: {};
|
|
271
310
|
let outcomes;
|
|
272
311
|
if (job.uses) {
|
|
273
312
|
// A reusable workflow call (once per matrix combination). The
|
|
@@ -276,7 +315,7 @@ async function executeWorkflow(filePath, options = {}, internal = {}) {
|
|
|
276
315
|
const { index: combinationIndex, values: matrixValues } = selected[index];
|
|
277
316
|
const label = labelOf(matrixValues);
|
|
278
317
|
return tagged(label, async () => {
|
|
279
|
-
const reusableResult = await executeReusableWorkflowJob(label, job, filePath, options, { ...jobIfCtx, matrix: matrixValues }, { slots, signal });
|
|
318
|
+
const reusableResult = await executeReusableWorkflowJob(label, job, filePath, options, { ...jobIfCtx, matrix: matrixValues }, { slots, signal, groups });
|
|
280
319
|
const cancelled = !reusableResult.success && signal.aborted;
|
|
281
320
|
unitResults[combinationIndex] = cancelled
|
|
282
321
|
? { ...reusableResult, cancelled: true }
|
|
@@ -291,7 +330,12 @@ async function executeWorkflow(filePath, options = {}, internal = {}) {
|
|
|
291
330
|
: 'failure'
|
|
292
331
|
};
|
|
293
332
|
}, true);
|
|
294
|
-
}, {
|
|
333
|
+
}, {
|
|
334
|
+
failFast,
|
|
335
|
+
maxParallel,
|
|
336
|
+
signal: internal.signal,
|
|
337
|
+
...concurrencyGate
|
|
338
|
+
});
|
|
295
339
|
}
|
|
296
340
|
else {
|
|
297
341
|
// Outputs so far, as each combination's result reports them.
|
|
@@ -540,6 +584,9 @@ async function executeWorkflow(filePath, options = {}, internal = {}) {
|
|
|
540
584
|
finally {
|
|
541
585
|
signal.removeEventListener('abort', onAbort);
|
|
542
586
|
await stopping;
|
|
587
|
+
if (options.isolateWorkspace) {
|
|
588
|
+
await (0, job_container_1.clearIsolatedWorkspace)(runtime);
|
|
589
|
+
}
|
|
543
590
|
await (0, job_container_1.stopJobContainer)(runtime);
|
|
544
591
|
}
|
|
545
592
|
}
|
|
@@ -577,6 +624,7 @@ async function executeWorkflow(filePath, options = {}, internal = {}) {
|
|
|
577
624
|
maxParallel,
|
|
578
625
|
slots,
|
|
579
626
|
signal: internal.signal,
|
|
627
|
+
...concurrencyGate,
|
|
580
628
|
onFailFast: () => logger_1.logger.info(concurrent
|
|
581
629
|
? `Cancelling remaining matrix jobs for ${jobId} (fail-fast)`
|
|
582
630
|
: `Skipping remaining matrix jobs for ${jobId} (fail-fast)`)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.createContext = createContext;
|
|
4
|
+
exports.knownPrefix = knownPrefix;
|
|
4
5
|
exports.resolveExpressionValue = resolveExpressionValue;
|
|
5
6
|
exports.substituteExpressions = substituteExpressions;
|
|
6
7
|
exports.substituteRecord = substituteRecord;
|
|
@@ -398,6 +399,29 @@ class Unknown {
|
|
|
398
399
|
this.truthy = truthy;
|
|
399
400
|
}
|
|
400
401
|
}
|
|
402
|
+
/**
|
|
403
|
+
* A string static evaluation knows only the start of (lower-cased), e.g.
|
|
404
|
+
* `github.ref` on a tags-only push: `refs/tags/`. Decides `==` / `!=`
|
|
405
|
+
* against a literal that can't share the prefix and `startsWith`.
|
|
406
|
+
*/
|
|
407
|
+
class KnownPrefix extends Unknown {
|
|
408
|
+
constructor(prefix) {
|
|
409
|
+
super(prefix !== '' ? true : undefined);
|
|
410
|
+
this.prefix = prefix;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
/** A string known to start with `prefix` (compared case-insensitively). */
|
|
414
|
+
function knownPrefix(prefix) {
|
|
415
|
+
return new KnownPrefix(prefix.toLowerCase());
|
|
416
|
+
}
|
|
417
|
+
/** False when `a == b` can't hold because of a known prefix, else undefined. */
|
|
418
|
+
function prefixRulesOutEquality(a, b) {
|
|
419
|
+
const [known, other] = a instanceof KnownPrefix ? [a, unwrap(b)] : [b, unwrap(a)];
|
|
420
|
+
if (!(known instanceof KnownPrefix) || typeof other !== 'string') {
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
return !other.toLowerCase().startsWith(known.prefix);
|
|
424
|
+
}
|
|
401
425
|
/** Contexts an expression may name; static evaluation knows only some. */
|
|
402
426
|
const NAMED_CONTEXTS = new Set([
|
|
403
427
|
'env',
|
|
@@ -506,6 +530,10 @@ class Parser {
|
|
|
506
530
|
while (this.isOp('==', '!=')) {
|
|
507
531
|
const op = this.next().value;
|
|
508
532
|
const right = this.parseComparison();
|
|
533
|
+
if (prefixRulesOutEquality(left, right)) {
|
|
534
|
+
left = op !== '==';
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
509
537
|
if (left instanceof Unknown || right instanceof Unknown) {
|
|
510
538
|
left = new Unknown();
|
|
511
539
|
continue;
|
|
@@ -674,6 +702,18 @@ class Parser {
|
|
|
674
702
|
const lower = name.toLowerCase();
|
|
675
703
|
if (lower === 'success' || lower === 'always')
|
|
676
704
|
return true;
|
|
705
|
+
if (lower === 'startswith' && args[0] instanceof KnownPrefix) {
|
|
706
|
+
const search = unwrap(args[1]);
|
|
707
|
+
if (typeof search === 'string') {
|
|
708
|
+
const { prefix } = args[0];
|
|
709
|
+
const wanted = search.toLowerCase();
|
|
710
|
+
if (prefix.startsWith(wanted))
|
|
711
|
+
return true;
|
|
712
|
+
if (!wanted.startsWith(prefix))
|
|
713
|
+
return false;
|
|
714
|
+
}
|
|
715
|
+
return new Unknown();
|
|
716
|
+
}
|
|
677
717
|
if (['failure', 'cancelled', 'hashfiles'].includes(lower) ||
|
|
678
718
|
args.some((arg) => arg instanceof Unknown)) {
|
|
679
719
|
return new Unknown();
|
|
@@ -38,6 +38,8 @@ exports.installContainerCleanup = installContainerCleanup;
|
|
|
38
38
|
exports.noRuntimeMessage = noRuntimeMessage;
|
|
39
39
|
exports.startJobContainer = startJobContainer;
|
|
40
40
|
exports.runnerImage = runnerImage;
|
|
41
|
+
exports.clearIsolatedWorkspace = clearIsolatedWorkspace;
|
|
42
|
+
exports.removeInContainer = removeInContainer;
|
|
41
43
|
exports.stopJobContainer = stopJobContainer;
|
|
42
44
|
exports.startJobServices = startJobServices;
|
|
43
45
|
exports.stopJobServices = stopJobServices;
|
|
@@ -144,6 +146,11 @@ async function startJobContainer(job, ctx, options, runtime, runDir) {
|
|
|
144
146
|
: {};
|
|
145
147
|
if (!extras)
|
|
146
148
|
return false;
|
|
149
|
+
if (specImage &&
|
|
150
|
+
spec &&
|
|
151
|
+
!(await pullWithCredentials(`jobs.${ctx.github.job ?? '<id>'}.container`, image, spec.credentials, ctx, options, containerRuntime))) {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
147
154
|
const exchangeDir = fs.mkdtempSync(path.join(runDir, 'container-'));
|
|
148
155
|
fs.chmodSync(exchangeDir, 0o777);
|
|
149
156
|
fs.copyFileSync(runtime.eventPath, path.join(exchangeDir, 'event.json'));
|
|
@@ -210,15 +217,42 @@ function jobContainerExtras(spec, ctx, options, workspace) {
|
|
|
210
217
|
return undefined;
|
|
211
218
|
}
|
|
212
219
|
}
|
|
213
|
-
if (spec.credentials) {
|
|
214
|
-
logger_1.logger.warn(`${label}: credentials are ignored; log in with \`docker login\` instead.`);
|
|
215
|
-
}
|
|
216
220
|
return {
|
|
217
221
|
extraArgs: kept,
|
|
218
222
|
ports: (spec.ports ?? []).map((port) => (0, expression_1.substituteExpressions)(String(port), ctx)),
|
|
219
223
|
volumes
|
|
220
224
|
};
|
|
221
225
|
}
|
|
226
|
+
/**
|
|
227
|
+
* `credentials:` of a job or service container. Without --registry-login
|
|
228
|
+
* they're only warned about. With it, the image is pulled with them first
|
|
229
|
+
* (a private, temporary login: see ContainerRuntime.pull), and the
|
|
230
|
+
* password is masked in the logs. False (the job fails) when they're
|
|
231
|
+
* incomplete or the pull fails.
|
|
232
|
+
*/
|
|
233
|
+
async function pullWithCredentials(label, image, credentials, ctx, options, runtime) {
|
|
234
|
+
if (!credentials)
|
|
235
|
+
return true;
|
|
236
|
+
if (!options.registryLogin) {
|
|
237
|
+
logger_1.logger.warn(`${label}: credentials are ignored; pass --registry-login to log in for this run, or log in with \`docker login\`.`);
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
const username = (0, expression_1.substituteExpressions)(String(credentials.username ?? ''), ctx).trim();
|
|
241
|
+
const password = (0, expression_1.substituteExpressions)(String(credentials.password ?? ''), ctx);
|
|
242
|
+
logger_1.logger.registerSecrets({ password });
|
|
243
|
+
if (!username || !password) {
|
|
244
|
+
logger_1.logger.error(`${label}: credentials need a username and a password.`);
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
const registry = (0, container_runtime_1.registryOf)(image);
|
|
248
|
+
logger_1.logger.info(`${label}: pulling ${image} with credentials for ${registry}`);
|
|
249
|
+
const pulled = await runtime.pull(image, { registry, username, password });
|
|
250
|
+
if (!pulled.success) {
|
|
251
|
+
logger_1.logger.error(`${label}: couldn't pull ${image}: ${pulled.error}`);
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
222
256
|
/**
|
|
223
257
|
* The image for a job's `runs-on`: the first label with a mapping in
|
|
224
258
|
* `runner-images` (or the built-in one), else the default image, with a
|
|
@@ -250,6 +284,59 @@ function runnerImage(runsOn, ctx, options) {
|
|
|
250
284
|
}
|
|
251
285
|
return fallback;
|
|
252
286
|
}
|
|
287
|
+
/**
|
|
288
|
+
* Empty a job's --isolate-workspace copy from inside its container, as
|
|
289
|
+
* root, before the container is stopped: files a step created as root
|
|
290
|
+
* (rootful engines) can't always be removed by the host user afterwards.
|
|
291
|
+
* Removing them from inside works with rootful and rootless engines alike
|
|
292
|
+
* (a chown back would map to a subordinate uid when rootless). `.git` is
|
|
293
|
+
* mounted read-only, so it's left for the host to remove. Only ever call
|
|
294
|
+
* this for an isolated copy, never for the user's working copy.
|
|
295
|
+
*/
|
|
296
|
+
async function clearIsolatedWorkspace(runtime) {
|
|
297
|
+
const container = runtime.container;
|
|
298
|
+
if (!container)
|
|
299
|
+
return;
|
|
300
|
+
const result = await container.runtime.exec(container.id, {
|
|
301
|
+
args: [
|
|
302
|
+
'find',
|
|
303
|
+
container_runtime_1.CONTAINER_WORKDIR,
|
|
304
|
+
'-mindepth',
|
|
305
|
+
'1',
|
|
306
|
+
'-maxdepth',
|
|
307
|
+
'1',
|
|
308
|
+
'!',
|
|
309
|
+
'-name',
|
|
310
|
+
'.git',
|
|
311
|
+
'-exec',
|
|
312
|
+
'rm',
|
|
313
|
+
'-rf',
|
|
314
|
+
'--',
|
|
315
|
+
'{}',
|
|
316
|
+
'+'
|
|
317
|
+
],
|
|
318
|
+
user: '0',
|
|
319
|
+
workingDir: '/'
|
|
320
|
+
});
|
|
321
|
+
if (!result.success) {
|
|
322
|
+
logger_1.logger.debug(`Couldn't empty the workspace copy in the job container: ${result.stderr.trim()}`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Remove `containerPath` from inside the job's container, as root, so a
|
|
327
|
+
* copy holding files a step created as root can then be removed on the
|
|
328
|
+
* host. No-op without a container.
|
|
329
|
+
*/
|
|
330
|
+
async function removeInContainer(runtime, containerPath) {
|
|
331
|
+
const container = runtime.container;
|
|
332
|
+
if (!container)
|
|
333
|
+
return;
|
|
334
|
+
await container.runtime.exec(container.id, {
|
|
335
|
+
args: ['rm', '-rf', '--', containerPath],
|
|
336
|
+
user: '0',
|
|
337
|
+
workingDir: '/'
|
|
338
|
+
});
|
|
339
|
+
}
|
|
253
340
|
async function stopJobContainer(runtime) {
|
|
254
341
|
const container = runtime.container;
|
|
255
342
|
if (container) {
|
|
@@ -319,10 +406,10 @@ async function startJobServices(job, ctx, options, runtime) {
|
|
|
319
406
|
return false;
|
|
320
407
|
}
|
|
321
408
|
}
|
|
322
|
-
if (service.credentials) {
|
|
323
|
-
logger_1.logger.warn(`services.${serviceId}: credentials are ignored; log in with \`docker login\` instead.`);
|
|
324
|
-
}
|
|
325
409
|
const image = (0, expression_1.substituteExpressions)(service.image, ctx);
|
|
410
|
+
if (!(await pullWithCredentials(`services.${serviceId}`, image, service.credentials, ctx, options, containerRuntime))) {
|
|
411
|
+
return false;
|
|
412
|
+
}
|
|
326
413
|
logger_1.logger.info(`Starting service ${serviceId}: ${image}`);
|
|
327
414
|
const started = await containerRuntime.start({
|
|
328
415
|
image,
|
package/dist/runner/scheduler.js
CHANGED
|
@@ -33,9 +33,10 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.Slots = void 0;
|
|
36
|
+
exports.ConcurrencyGroups = exports.Slots = void 0;
|
|
37
37
|
exports.defaultParallelism = defaultParallelism;
|
|
38
38
|
exports.parseParallel = parseParallel;
|
|
39
|
+
exports.parseConcurrencyMode = parseConcurrencyMode;
|
|
39
40
|
exports.runMatrix = runMatrix;
|
|
40
41
|
exports.runJobGraph = runJobGraph;
|
|
41
42
|
const os = __importStar(require("os"));
|
|
@@ -84,6 +85,65 @@ function parseParallel(raw) {
|
|
|
84
85
|
}
|
|
85
86
|
return n;
|
|
86
87
|
}
|
|
88
|
+
/** Parse `--concurrency` / config `concurrency`. */
|
|
89
|
+
function parseConcurrencyMode(raw) {
|
|
90
|
+
const mode = raw.trim().toLowerCase();
|
|
91
|
+
if (mode === 'serialize' || mode === 'github')
|
|
92
|
+
return mode;
|
|
93
|
+
throw new Error(`Invalid value "${raw}" for concurrency. Must be serialize or github.`);
|
|
94
|
+
}
|
|
95
|
+
/** `concurrency:` groups of one run, keyed case-insensitively. */
|
|
96
|
+
class ConcurrencyGroups {
|
|
97
|
+
constructor(mode, note = () => undefined) {
|
|
98
|
+
this.mode = mode;
|
|
99
|
+
this.note = note;
|
|
100
|
+
this.holders = new Map();
|
|
101
|
+
this.waiting = new Map();
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Wait to run in `group`. `abort` cancels this job once it's running
|
|
105
|
+
* (github mode, for a newcomer with cancel-in-progress).
|
|
106
|
+
*/
|
|
107
|
+
enter(group, cancelInProgress, abort, label) {
|
|
108
|
+
const key = group.toLowerCase();
|
|
109
|
+
return new Promise((admit) => {
|
|
110
|
+
const entry = { label, abort, admit };
|
|
111
|
+
const holder = this.holders.get(key);
|
|
112
|
+
if (!holder) {
|
|
113
|
+
this.admit(key, entry);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const queue = this.waiting.get(key) ?? [];
|
|
117
|
+
this.waiting.set(key, queue);
|
|
118
|
+
if (this.mode === 'github') {
|
|
119
|
+
if (cancelInProgress) {
|
|
120
|
+
this.note(`Cancelling ${holder.label}: ${label} joined concurrency group "${group}" with cancel-in-progress`);
|
|
121
|
+
holder.abort();
|
|
122
|
+
}
|
|
123
|
+
for (const older of queue.splice(0)) {
|
|
124
|
+
this.note(`Cancelling ${older.label}: ${label} is now pending in concurrency group "${group}"`);
|
|
125
|
+
older.admit('cancelled');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
queue.push(entry);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
admit(key, entry) {
|
|
132
|
+
this.holders.set(key, entry);
|
|
133
|
+
let left = false;
|
|
134
|
+
entry.admit(() => {
|
|
135
|
+
if (left)
|
|
136
|
+
return;
|
|
137
|
+
left = true;
|
|
138
|
+
const next = this.waiting.get(key)?.shift();
|
|
139
|
+
if (next)
|
|
140
|
+
this.admit(key, next);
|
|
141
|
+
else
|
|
142
|
+
this.holders.delete(key);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
exports.ConcurrencyGroups = ConcurrencyGroups;
|
|
87
147
|
/**
|
|
88
148
|
* Run `count` matrix combinations, at most `maxParallel` at once and each
|
|
89
149
|
* holding one of the global `slots` while it runs. With fail-fast, a
|
|
@@ -105,6 +165,29 @@ async function runMatrix(count, run, options) {
|
|
|
105
165
|
};
|
|
106
166
|
options.signal?.addEventListener('abort', onParentAbort);
|
|
107
167
|
const runOne = async (index) => {
|
|
168
|
+
const controller = new AbortController();
|
|
169
|
+
let leaveGroup;
|
|
170
|
+
if (options.gate) {
|
|
171
|
+
const admission = await options.gate(index, () => controller.abort());
|
|
172
|
+
if (admission === 'cancelled' || controller.signal.aborted) {
|
|
173
|
+
if (admission !== 'cancelled')
|
|
174
|
+
admission();
|
|
175
|
+
if (!stopped && options.onGateCancelled) {
|
|
176
|
+
outcomes[index] = await options.onGateCancelled(index);
|
|
177
|
+
finished++;
|
|
178
|
+
}
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
leaveGroup = admission;
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
await runAdmitted(index, controller);
|
|
185
|
+
}
|
|
186
|
+
finally {
|
|
187
|
+
leaveGroup?.();
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
const runAdmitted = async (index, controller) => {
|
|
108
191
|
const releaseLocal = await local.acquire();
|
|
109
192
|
try {
|
|
110
193
|
if (stopped)
|
|
@@ -115,7 +198,6 @@ async function runMatrix(count, run, options) {
|
|
|
115
198
|
try {
|
|
116
199
|
if (stopped)
|
|
117
200
|
return;
|
|
118
|
-
const controller = new AbortController();
|
|
119
201
|
controllers.set(index, controller);
|
|
120
202
|
try {
|
|
121
203
|
const outcome = await run(index, controller.signal);
|