@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 CHANGED
@@ -73,10 +73,10 @@ less install.sh
73
73
  sh install.sh
74
74
  ```
75
75
 
76
- Set `SECURECI_VERSION=1.1.0` to install a specific release,
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.1.0 sh`).
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:` your local
267
- repository has is checked out only into a `path:`; other refs and
268
- `fetch-depth:` don't change what you get. Other repositories check out for
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
- - **Not emulated:** `concurrency:` (noted at run start), deployment
273
- `environment:` (protection rules and environment-scoped secrets/vars: pass
274
- them with `--secret`/`--var`), and OIDC tokens for `id-token: write`.
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`) and, without `--contract-recurse all`, don't look inside
277
- _remote_ composite actions / reusable workflows.
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).
@@ -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
- if (cached && !isStale)
393
- return { advisories: cached.advisories };
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
- for (let pages = 1; pages < MAX_ADVISORY_PAGES; pages++) {
438
+ const sameOriginNext = () => {
437
439
  const next = nextPageUrl(response.link);
438
- if (!next || new URL(next, url).origin !== origin)
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(new URL(next, url).toString());
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 = { api, fetchedAt: now, advisories };
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
- ? { advisories: cached.advisories, stale: true, error: message }
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) => ({ github: { event_name: 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://')) {
@@ -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) {
@@ -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 (!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 === '') {
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
- 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) {
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
- runCli(args, timeoutMs, env) {
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;
@@ -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 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`);
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
- }, { failFast, maxParallel, signal: internal.signal });
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,
@@ -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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@secureci/cli",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "SecureCI: validate, check against pipeline standards, and run GitHub Actions workflows locally",
5
5
  "main": "dist/index.js",
6
6
  "bin": {