@secureci/cli 0.5.1 → 0.6.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/dist/cli.js CHANGED
@@ -36,6 +36,7 @@ var __importStar = (this && this.__importStar) || (function () {
36
36
  Object.defineProperty(exports, "__esModule", { value: true });
37
37
  const commander_1 = require("commander");
38
38
  const dry_run_1 = require("./runner/dry-run");
39
+ const job_plan_1 = require("./runner/job-plan");
39
40
  const executor_1 = require("./runner/executor");
40
41
  const action_fetcher_1 = require("./runner/action-fetcher");
41
42
  const progress_1 = require("./utils/progress");
@@ -46,24 +47,19 @@ const pipeline_contract_parser_1 = require("./parser/pipeline-contract-parser");
46
47
  const pipeline_contract_validator_1 = require("./parser/pipeline-contract-validator");
47
48
  const report_1 = require("./report");
48
49
  const init_1 = require("./init");
50
+ const pin_1 = require("./pin");
51
+ const advisories_1 = require("./advisories");
49
52
  const inventory_1 = require("./inventory");
50
53
  const github_lockfile_1 = require("./github-lockfile");
51
54
  const logger_1 = require("./utils/logger");
52
- const key_value_1 = require("./utils/key-value");
55
+ const env_file_1 = require("./utils/env-file");
53
56
  const config_1 = require("./config");
57
+ const scheduler_1 = require("./runner/scheduler");
58
+ const version_1 = require("./version");
54
59
  const fs = __importStar(require("fs"));
55
60
  const path = __importStar(require("path"));
56
61
  /** Committed at the repo root, like package-lock.json / yarn.lock. */
57
62
  const LOCKFILE_NAME = 'secureci.lock';
58
- function readVersion() {
59
- try {
60
- const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'));
61
- return String(pkg.version);
62
- }
63
- catch {
64
- return '0.0.0';
65
- }
66
- }
67
63
  const program = new commander_1.Command();
68
64
  const globalConfig = (0, config_1.loadConfig)();
69
65
  // An invalid container-runtime in the config file only stops `run`, so
@@ -77,10 +73,17 @@ catch (error) {
77
73
  configError = error instanceof Error ? error.message : String(error);
78
74
  defaultContainerRuntime = 'docker';
79
75
  }
76
+ let defaultParallel = 1;
77
+ try {
78
+ defaultParallel = (0, config_1.getDefaultParallel)(globalConfig);
79
+ }
80
+ catch (error) {
81
+ configError ?? (configError = error instanceof Error ? error.message : String(error));
82
+ }
80
83
  program
81
84
  .name('secureci')
82
85
  .description('SecureCI: validate, check and run GitHub Actions workflows locally')
83
- .version(readVersion(), '-V, --version', 'Print the version')
86
+ .version((0, version_1.readVersion)(), '-V, --version', 'Print the version')
84
87
  .helpOption('-h, --help', 'Show help')
85
88
  .addHelpCommand('help [command]', 'Show help for a command')
86
89
  .option('--verbose', 'Log debug output')
@@ -105,9 +108,14 @@ program
105
108
  .option('-s, --secret <key=value|NAME...>', 'Set a secret for the secrets context. A bare NAME reads $NAME from your shell, keeping the value off the command line. Repeatable')
106
109
  .option('--env <key=value|NAME...>', 'Set an env var for every step, below workflow/job/step env:. A bare NAME passes $NAME from your shell (steps get nothing else from it). Repeatable')
107
110
  .option('--var <key=value...>', 'Set a configuration variable for the vars context. Repeatable')
111
+ .option('--env-file <file...>', 'Read --env values from a .env-style file. Repeatable; later files win, --env wins over files. Config: env-file')
112
+ .option('--secret-file <file...>', 'Read --secret values from a .env-style file; every value is masked. Repeatable; later files win, --secret wins over files. Config: secret-file')
113
+ .option('--input-file <file...>', 'Read --input values from a .env-style file. Repeatable; later files win, --input wins over files. Config: input-file')
114
+ .option('--var-file <file...>', 'Read --var values from a .env-style file. Repeatable; later files win, --var wins over files. Config: var-file')
108
115
  .option('--event-path <file>', 'Read the event payload (github.event, $GITHUB_EVENT_PATH) from a JSON file')
109
116
  .option('--event-name <name>', 'Set github.event_name. Default: $GITHUB_EVENT_NAME, else workflow_dispatch if declared, else the first trigger')
110
117
  .option('-j, --job <name>', 'Run only this job')
118
+ .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)')
111
119
  .option('--offline', 'Use only cached remote actions and workflows; never download')
112
120
  .option('--no-local-checkout', 'Run the real actions/checkout for this repository instead of using the working copy')
113
121
  .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')
@@ -120,14 +128,23 @@ program
120
128
  .option('--container-memory <limit>', 'Limit job container memory, e.g. 512m. Config: container-memory', globalConfig['container-memory'])
121
129
  .option('--no-network', 'Turn off container network access (on by default)')
122
130
  .option('--allow-network', 'Turn on container network access, overriding allow-network: false in the config')
131
+ .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))
123
132
  .action(async (file, options) => {
124
133
  if (configError) {
125
134
  logger_1.logger.error(configError);
126
135
  process.exit(1);
127
136
  }
128
137
  requireFile(file);
138
+ let matrix;
139
+ try {
140
+ matrix = (0, job_plan_1.parseMatrixFilter)(options.matrix);
141
+ }
142
+ catch (error) {
143
+ logger_1.logger.error(error instanceof Error ? error.message : String(error));
144
+ process.exit(1);
145
+ }
129
146
  if (options.dryRun) {
130
- const result = (0, dry_run_1.dryRun)(file);
147
+ const result = (0, dry_run_1.dryRun)(file, { job: options.job, matrix });
131
148
  if (!result.success) {
132
149
  process.exit(1);
133
150
  }
@@ -135,15 +152,38 @@ program
135
152
  }
136
153
  logger_1.logger.info("Running the workflow for real (not a dry run). Use --dry-run to simulate without executing, or 'secureci validate' to only check for errors.");
137
154
  try {
138
- const inputs = (0, key_value_1.parseKeyValuePairs)(options.input, '--input');
139
- const secrets = (0, key_value_1.parseKeyValuePairs)(options.secret, '--secret', {
140
- redact: true,
155
+ const inputs = (0, env_file_1.loadKeyValues)({
156
+ flagName: '--input',
157
+ fileFlagName: '--input-file',
158
+ pairs: options.input,
159
+ files: options.inputFile,
160
+ configFiles: globalConfig['input-file']
161
+ });
162
+ const secrets = (0, env_file_1.loadKeyValues)({
163
+ flagName: '--secret',
164
+ fileFlagName: '--secret-file',
165
+ pairs: options.secret,
166
+ files: options.secretFile,
167
+ configFiles: globalConfig['secret-file'],
168
+ secret: true,
141
169
  fromEnv: process.env
142
170
  });
143
- const vars = (0, key_value_1.parseKeyValuePairs)(options.var, '--var');
144
- const env = (0, key_value_1.parseKeyValuePairs)(options.env, '--env', {
171
+ const vars = (0, env_file_1.loadKeyValues)({
172
+ flagName: '--var',
173
+ fileFlagName: '--var-file',
174
+ pairs: options.var,
175
+ files: options.varFile,
176
+ configFiles: globalConfig['var-file']
177
+ });
178
+ const env = (0, env_file_1.loadKeyValues)({
179
+ flagName: '--env',
180
+ fileFlagName: '--env-file',
181
+ pairs: options.env,
182
+ files: options.envFile,
183
+ configFiles: globalConfig['env-file'],
145
184
  fromEnv: process.env
146
185
  });
186
+ const parallel = (0, scheduler_1.parseParallel)(options.parallel);
147
187
  const result = await (0, executor_1.executeWorkflow)(file, {
148
188
  dryRun: false,
149
189
  inputs,
@@ -153,6 +193,7 @@ program
153
193
  eventPath: options.eventPath,
154
194
  eventName: options.eventName,
155
195
  job: options.job,
196
+ matrix,
156
197
  localCheckout: options.localCheckout,
157
198
  offline: options.offline,
158
199
  verbose: program.opts().verbose,
@@ -164,7 +205,8 @@ program
164
205
  ? Number(options.containerTimeout)
165
206
  : undefined,
166
207
  containerMemoryLimit: options.containerMemory,
167
- networkDisabled: (0, config_1.resolveNetworkDisabled)({ network: options.network, allowNetwork: options.allowNetwork }, globalConfig)
208
+ networkDisabled: (0, config_1.resolveNetworkDisabled)({ network: options.network, allowNetwork: options.allowNetwork }, globalConfig),
209
+ parallel
168
210
  });
169
211
  if (!result.success) {
170
212
  process.exit(1);
@@ -186,15 +228,16 @@ program
186
228
  .addOption(new commander_1.Option('--format <format>', 'Output format: text (default), or json / sarif for machine-readable results (sarif uploads to GitHub code scanning)')
187
229
  .choices(['text', 'json', 'sarif'])
188
230
  .default('text'))
231
+ .option('--offline', 'Do not use the network: skip the security advisory lookup (cached advisories are still used), and read remote contracts and actions only from the cache')
189
232
  .action(async (files, options) => {
190
233
  files.forEach(requireFile);
191
- await validateFiles(files, options.contract, options.contractRecurse, options.format);
234
+ await validateFiles(files, options.contract, options.contractRecurse, options.format, options.offline ?? false);
192
235
  });
193
236
  program
194
237
  .command('inventory')
195
238
  .description('List the actions and reusable workflows a workflow uses (including the actions those actions use), with their commit SHAs; track supply-chain drift with a committed secureci.lock')
196
239
  .argument('[file...]', 'Workflow files (default: every file in .github/workflows)')
197
- .option('--offline', 'Do not resolve tags to commit SHAs over the network (already-pinned refs still resolve)')
240
+ .option('--offline', 'Do not resolve tags to commit SHAs or query security advisories over the network (already-pinned refs still resolve; cached advisories are used)')
198
241
  .option('--dry-run', `Print the resolved inventory as JSON without writing or checking ${LOCKFILE_NAME}`)
199
242
  .option('--update-lock', `Write/update ./${LOCKFILE_NAME} with the current commit SHAs (commit it to your repo)`)
200
243
  .option('--update-lockfile', `Alias of --update-lock`)
@@ -203,6 +246,17 @@ program
203
246
  .action(async (files, options) => {
204
247
  await runInventory(files, options);
205
248
  });
249
+ program
250
+ .command('pin')
251
+ .description('Pin action and reusable-workflow tags to commit SHAs in place (`owner/repo@<sha> # v4`), keeping formatting and comments')
252
+ .argument('[file...]', 'Workflow or action files (default: every file in .github/workflows, plus the local actions and reusable workflows they use)')
253
+ .option('--check', 'Change nothing; exit non-zero if any reference would be pinned or cannot be resolved (for CI)')
254
+ .option('-c, --contract <file>', "Leave the owners/actions in this pipeline contract's pins.allow on their tags")
255
+ .option('--offline', 'Do not use the network: only refs already in the action cache resolve; others are reported, not changed')
256
+ .option('--update-lock', `Afterwards, write ./${LOCKFILE_NAME} from the pinned workflows (as \`inventory --update-lock\`)`)
257
+ .action(async (files, options) => {
258
+ await runPin(files, options);
259
+ });
206
260
  program
207
261
  .command('list')
208
262
  .description('List workflows in .github/workflows/')
@@ -376,6 +430,17 @@ async function runInventory(files, options) {
376
430
  if (identity && action.resolvedSha)
377
431
  Object.assign(action, identity);
378
432
  }
433
+ // 4. Known security advisories for every action, direct and transitive.
434
+ const repoCount = new Set(actions.map((a) => `${a.owner}/${a.repo}`.toLowerCase())).size;
435
+ const advising = new progress_1.Progress('Checking security advisories', online ? repoCount : 0);
436
+ const advisoryReport = await new advisories_1.AdvisoryChecker({
437
+ offline: !online,
438
+ onLookup: (repo) => {
439
+ if (online)
440
+ advising.tick(repo);
441
+ }
442
+ }).check((0, advisories_1.targetsFromInventory)(actions));
443
+ advising.done(`Checked security advisories for ${repoCount} repositor${repoCount === 1 ? 'y' : 'ies'}`);
379
444
  logger_1.logger.setLevel(level);
380
445
  const current = {};
381
446
  for (const action of actions) {
@@ -391,9 +456,22 @@ async function runInventory(files, options) {
391
456
  // --dry-run: print the resolved inventory as JSON, touching nothing.
392
457
  if (options.dryRun) {
393
458
  // eslint-disable-next-line no-console
394
- console.log(JSON.stringify({ actions }, null, 2));
459
+ console.log(JSON.stringify({
460
+ actions,
461
+ advisories: advisoryReport.findings,
462
+ advisoryUnknownVersions: advisoryReport.unknownVersions
463
+ }, null, 2));
464
+ // The findings are in the JSON; say on stderr what wasn't checked.
465
+ (0, advisories_1.describeReport)(advisoryReport)
466
+ .filter((line) => line.kind === 'note' && line.level === 'warn')
467
+ .forEach((line) => logger_1.logger.warn(line.text));
395
468
  return;
396
469
  }
470
+ // Advisories are reported, but don't change the exit code here: gate on
471
+ // them with `secureci validate` (rule secureci/vulnerable-action).
472
+ for (const line of (0, advisories_1.describeReport)(advisoryReport)) {
473
+ logger_1.logger[line.level](line.text);
474
+ }
397
475
  // Used references that couldn't be resolved to a commit now.
398
476
  const unresolved = actions.filter((a) => !a.resolvedSha);
399
477
  if (writing) {
@@ -463,7 +541,130 @@ async function runInventory(files, options) {
463
541
  }
464
542
  logger_1.logger.success(`No action drift against ${lockName}.`);
465
543
  }
466
- async function validateOneFile(filePath, contractFile, recurse, say) {
544
+ async function runPin(files, options) {
545
+ if (options.check && options.updateLock) {
546
+ logger_1.logger.error('Use either --update-lock or --check, not both.');
547
+ process.exit(1);
548
+ }
549
+ const given = files.length > 0 ? files : defaultWorkflowFiles();
550
+ if (given.length === 0) {
551
+ logger_1.logger.error('No workflow files given and none found in .github/workflows');
552
+ process.exit(1);
553
+ }
554
+ given.forEach(requireFile);
555
+ // Explicit files are pinned as given; the default run covers the whole
556
+ // repository, including local actions the workflows use.
557
+ const targets = files.length > 0 ? given : (0, pin_1.localPinTargets)(given);
558
+ let allow = [];
559
+ if (options.contract) {
560
+ try {
561
+ const source = await (0, contract_source_1.resolveContractSource)(options.contract, {
562
+ fetchFile: (uses) => (0, action_fetcher_1.fetchRepoFile)(uses, { offline: options.offline }),
563
+ resolveSha: (uses) => options.offline
564
+ ? Promise.resolve((0, action_fetcher_1.cachedActionSha)(uses))
565
+ : (0, action_fetcher_1.resolveActionSha)(uses),
566
+ warn: (message) => logger_1.logger.warn(message)
567
+ });
568
+ allow = (0, pipeline_contract_parser_1.parsePipelineContract)(source.file).pins?.allow ?? [];
569
+ }
570
+ catch (error) {
571
+ logger_1.logger.error(`Could not read contract ${options.contract}: ${error instanceof Error ? error.message : 'Unknown error'}`);
572
+ process.exit(1);
573
+ }
574
+ }
575
+ // Each distinct reference is resolved once, across all files.
576
+ const resolving = new progress_1.Progress('Resolving tags', 0);
577
+ const resolved = new Map();
578
+ const resolve = (uses) => {
579
+ let sha = resolved.get(uses);
580
+ if (!sha) {
581
+ if (options.offline) {
582
+ sha = Promise.resolve((0, action_fetcher_1.cachedActionSha)(uses));
583
+ }
584
+ else {
585
+ resolving.grow();
586
+ resolving.tick(uses);
587
+ sha = (0, action_fetcher_1.resolveActionSha)(uses);
588
+ }
589
+ resolved.set(uses, sha);
590
+ }
591
+ return sha;
592
+ };
593
+ const display = (file) => path.relative(process.cwd(), path.resolve(file)) || file;
594
+ let changes = 0;
595
+ let changedFiles = 0;
596
+ let unresolved = 0;
597
+ let unreadable = false;
598
+ const report = options.check
599
+ ? (m) => logger_1.logger.error(m)
600
+ : (m) => logger_1.logger.warn(m);
601
+ for (const file of targets) {
602
+ const name = display(file);
603
+ let result;
604
+ try {
605
+ result = await (0, pin_1.pinContent)(fs.readFileSync(file, 'utf-8'), resolve, {
606
+ allow
607
+ });
608
+ }
609
+ catch (error) {
610
+ logger_1.logger.error(`Could not read ${name}: ${error instanceof Error ? error.message : 'Unknown error'}`);
611
+ unreadable = true;
612
+ continue;
613
+ }
614
+ for (const a of result.allowed) {
615
+ logger_1.logger.info(`${name}:${a.line} ${a.uses} left on its tag (pins.allow)`);
616
+ }
617
+ for (const c of result.changes) {
618
+ logger_1.logger.info(`${name}:${c.line} ${c.uses} -> ${c.sha.slice(0, 12)} (${c.ref})`);
619
+ }
620
+ for (const u of result.unresolved) {
621
+ report(`${name}:${u.line} ${u.uses} ${u.reason}` +
622
+ (options.offline && u.reason.startsWith('could not')
623
+ ? ' (offline: only refs in the action cache resolve)'
624
+ : '') +
625
+ '; left unchanged');
626
+ }
627
+ changes += result.changes.length;
628
+ unresolved += result.unresolved.length;
629
+ if (result.changes.length > 0) {
630
+ changedFiles++;
631
+ if (!options.check)
632
+ fs.writeFileSync(file, result.content);
633
+ }
634
+ }
635
+ resolving.done(`Resolved ${resolved.size} reference(s)`);
636
+ const fileCount = `${changedFiles} file(s)`;
637
+ const unresolvedHint = options.offline
638
+ ? ''
639
+ : ` (check that each repository and ref exists${(0, action_fetcher_1.githubToken)() ? '' : '; set GITHUB_TOKEN for private repositories'})`;
640
+ if (options.check) {
641
+ if (changes > 0) {
642
+ logger_1.logger.error(`${changes} reference(s) would be pinned in ${fileCount}. Run \`secureci pin\` to pin them.`);
643
+ }
644
+ if (unresolved > 0) {
645
+ logger_1.logger.error(`${unresolved} reference(s) could not be resolved${unresolvedHint}.`);
646
+ }
647
+ if (changes > 0 || unresolved > 0 || unreadable)
648
+ process.exit(1);
649
+ logger_1.logger.success('Nothing to pin: every remote reference is pinned.');
650
+ return;
651
+ }
652
+ if (changes > 0) {
653
+ logger_1.logger.success(`Pinned ${changes} reference(s) in ${fileCount}.`);
654
+ }
655
+ else if (unresolved === 0) {
656
+ logger_1.logger.success('Nothing to pin: every remote reference is pinned.');
657
+ }
658
+ if (unresolved > 0) {
659
+ logger_1.logger.warn(`${unresolved} reference(s) could not be resolved and were left unchanged${unresolvedHint}.`);
660
+ }
661
+ if (unreadable)
662
+ process.exit(1);
663
+ if (options.updateLock) {
664
+ await runInventory(files, { offline: options.offline, updateLock: true });
665
+ }
666
+ }
667
+ async function validateOneFile(filePath, contractFile, recurse, say, advisories, offline) {
467
668
  const parsed = (0, workflow_parser_1.parseFile)(filePath);
468
669
  let result;
469
670
  if (parsed.type === 'workflow') {
@@ -474,6 +675,7 @@ async function validateOneFile(filePath, contractFile, recurse, say) {
474
675
  result = (0, validator_1.validateAction)(filePath);
475
676
  say(() => logger_1.logger.info(`Validating action: ${filePath}`));
476
677
  }
678
+ let failOn;
477
679
  if (contractFile) {
478
680
  say(() => logger_1.logger.info(`Validating against pipeline contract: ${contractFile}`));
479
681
  if (parsed.type !== 'workflow') {
@@ -491,18 +693,23 @@ async function validateOneFile(filePath, contractFile, recurse, say) {
491
693
  }
492
694
  else {
493
695
  const spec = (0, pipeline_contract_parser_1.parsePipelineContract)(contractFile);
696
+ failOn = spec.advisories?.failOn;
494
697
  const contractResult = await (0, pipeline_contract_validator_1.validatePipelineContract)(parsed, spec, {
495
- recurse
698
+ recurse,
699
+ offline
496
700
  });
497
- result = {
498
- valid: result.valid && contractResult.valid,
499
- errors: [...result.errors, ...contractResult.errors]
500
- };
701
+ result = (0, validator_1.mergeValidationResults)(result, contractResult);
501
702
  }
502
703
  }
503
- return result;
704
+ // Known security advisories for the file's own uses: references.
705
+ const { errors, report } = await (0, advisories_1.checkFileAdvisories)(parsed, advisories.checker, failOn);
706
+ advisories.reports.push(report);
707
+ return {
708
+ valid: result.valid && errors.every((e) => e.severity !== 'error'),
709
+ errors: [...result.errors, ...errors]
710
+ };
504
711
  }
505
- async function validateFiles(files, contractFile, recurse = 'local', format = 'text') {
712
+ async function validateFiles(files, contractFile, recurse, format, offline) {
506
713
  // Only text mode narrates; json/sarif write nothing but the report to
507
714
  // stdout, so the output is pipeable.
508
715
  const quiet = format !== 'text';
@@ -518,8 +725,8 @@ async function validateFiles(files, contractFile, recurse = 'local', format = 't
518
725
  if (contractFile) {
519
726
  try {
520
727
  const source = await (0, contract_source_1.resolveContractSource)(contractFile, {
521
- fetchFile: (uses) => (0, action_fetcher_1.fetchRepoFile)(uses),
522
- resolveSha: (uses) => (0, action_fetcher_1.resolveActionSha)(uses),
728
+ fetchFile: (uses) => (0, action_fetcher_1.fetchRepoFile)(uses, { offline }),
729
+ resolveSha: async (uses) => offline ? undefined : (0, action_fetcher_1.resolveActionSha)(uses),
523
730
  warn: (message) => logger_1.logger.warn(message)
524
731
  });
525
732
  contractPath = source.file;
@@ -540,12 +747,17 @@ async function validateFiles(files, contractFile, recurse = 'local', format = 't
540
747
  contractError = error instanceof Error ? error.message : 'Unknown error';
541
748
  }
542
749
  }
750
+ // One lookup per repository across all files (and a 24h disk cache).
751
+ const advisories = {
752
+ checker: new advisories_1.AdvisoryChecker({ offline }),
753
+ reports: []
754
+ };
543
755
  const entries = [];
544
756
  for (const filePath of files) {
545
757
  try {
546
758
  if (contractError)
547
759
  throw new Error(contractError);
548
- const result = await validateOneFile(filePath, contractPath, recurse, say);
760
+ const result = await validateOneFile(filePath, contractPath, recurse, say, advisories, offline);
549
761
  entries.push({ file: filePath, result });
550
762
  }
551
763
  catch (error) {
@@ -565,6 +777,16 @@ async function validateFiles(files, contractFile, recurse = 'local', format = 't
565
777
  }
566
778
  }
567
779
  const allValid = entries.every((e) => e.result.valid);
780
+ // What couldn't be checked, once for all files: warnings on stderr, and
781
+ // (text mode only) unknown versions as info. Findings are in the results.
782
+ for (const line of (0, advisories_1.describeReport)((0, advisories_1.mergeReports)(advisories.reports))) {
783
+ if (line.kind !== 'note')
784
+ continue;
785
+ if (line.level === 'warn')
786
+ logger_1.logger.warn(line.text);
787
+ else
788
+ say(() => logger_1.logger.info(line.text));
789
+ }
568
790
  if (quiet) {
569
791
  // eslint-disable-next-line no-console
570
792
  console.log((0, report_1.formatReport)(entries, format, { contract }));
@@ -592,7 +814,7 @@ function runInit(options) {
592
814
  process.exit(1);
593
815
  }
594
816
  const content = (0, init_1.renderInitWorkflow)({
595
- version: readVersion(),
817
+ version: (0, version_1.readVersion)(),
596
818
  contract: options.contract,
597
819
  upload: options.upload
598
820
  });
package/dist/config.js CHANGED
@@ -38,6 +38,7 @@ exports.getConfigFilePath = getConfigFilePath;
38
38
  exports.loadConfig = loadConfig;
39
39
  exports.saveConfig = saveConfig;
40
40
  exports.getDefaultContainerRuntime = getDefaultContainerRuntime;
41
+ exports.getDefaultParallel = getDefaultParallel;
41
42
  exports.isValidConfigKey = isValidConfigKey;
42
43
  exports.getConfigValue = getConfigValue;
43
44
  exports.setConfigValue = setConfigValue;
@@ -47,6 +48,7 @@ exports.resolveNetworkDisabled = resolveNetworkDisabled;
47
48
  const fs = __importStar(require("fs"));
48
49
  const path = __importStar(require("path"));
49
50
  const YAML = __importStar(require("yaml"));
51
+ const scheduler_1 = require("./runner/scheduler");
50
52
  /** Built-in defaults, used when a key is absent from the config file. */
51
53
  exports.DEFAULT_CONFIG = {
52
54
  'container-runtime': 'docker'
@@ -100,12 +102,22 @@ function getDefaultContainerRuntime(config = loadConfig()) {
100
102
  }
101
103
  return value;
102
104
  }
105
+ /** Resolve the effective `parallel`: config, else the CPU count. */
106
+ function getDefaultParallel(config = loadConfig()) {
107
+ const value = config.parallel;
108
+ return value === undefined ? (0, scheduler_1.defaultParallelism)() : (0, scheduler_1.parseParallel)(value);
109
+ }
103
110
  exports.CONFIG_KEYS = [
104
111
  'container-runtime',
105
112
  'container-image',
106
113
  'container-timeout',
107
114
  'container-memory',
108
- 'allow-network'
115
+ 'allow-network',
116
+ 'parallel',
117
+ 'env-file',
118
+ 'secret-file',
119
+ 'input-file',
120
+ 'var-file'
109
121
  ];
110
122
  /** `runner-images.<label>`: one entry of the runs-on -> image mapping. */
111
123
  const RUNNER_IMAGE_KEY = /^runner-images\.(.+)$/;
@@ -169,9 +181,19 @@ function parseConfigValue(key, rawValue) {
169
181
  throw new Error(`Invalid value "${rawValue}" for allow-network. Must be "true" or "false".`);
170
182
  }
171
183
  return rawValue === 'true';
184
+ case 'parallel':
185
+ return (0, scheduler_1.parseParallel)(rawValue);
172
186
  case 'container-image':
173
187
  case 'container-memory':
174
188
  return rawValue;
189
+ case 'env-file':
190
+ case 'secret-file':
191
+ case 'input-file':
192
+ case 'var-file':
193
+ if (rawValue === '') {
194
+ throw new Error(`Invalid value "" for ${key}. Must be a file path.`);
195
+ }
196
+ return rawValue;
175
197
  }
176
198
  }
177
199
  /**
package/dist/init.js CHANGED
@@ -7,7 +7,7 @@ exports.renderInitWorkflow = renderInitWorkflow;
7
7
  * the release tag matching this CLI's version.
8
8
  *
9
9
  * The output is deterministic for a given set of options, so it can be
10
- * regenerated and diffed. With uploads on (the default) the workflow asks for
10
+ * regenerated and diffed. With uploads on (the default) the job asks for
11
11
  * `security-events: write` and lets the action upload SARIF; with uploads off
12
12
  * it keeps `contents: read` only and passes `upload-sarif: false`.
13
13
  */
@@ -23,11 +23,18 @@ function renderInitWorkflow(options) {
23
23
  ' - pull_request',
24
24
  '',
25
25
  'permissions:',
26
- ' contents: read'
26
+ ' contents: read',
27
+ '',
28
+ 'jobs:',
29
+ ' secureci:',
30
+ ' runs-on: ubuntu-latest'
27
31
  ];
28
- if (upload)
29
- lines.push(' security-events: write');
30
- lines.push('', 'jobs:', ' secureci:', ' runs-on: ubuntu-latest', ' steps:', ' - uses: actions/checkout@v4', ` - uses: secure-ci/cli@v${options.version}`);
32
+ // Write scopes belong on the job, not the workflow. Job-level permissions
33
+ // replace the top-level ones, so contents: read is repeated for checkout.
34
+ if (upload) {
35
+ lines.push(' permissions:', ' contents: read', ' security-events: write');
36
+ }
37
+ lines.push(' steps:', ' - uses: actions/checkout@v4', ` - uses: secure-ci/cli@v${options.version}`);
31
38
  const withLines = [];
32
39
  if (contract)
33
40
  withLines.push(` contract: ${yamlScalar(contract)}`);