@secureci/cli 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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;
@@ -50,6 +52,13 @@ const expression_1 = require("./expression");
50
52
  const container_runtime_1 = require("./container-runtime");
51
53
  const shell_1 = require("./shell");
52
54
  const container_policy_1 = require("./container-policy");
55
+ /**
56
+ * `jobs.<id>.container.options` the CLI sets on the job container itself:
57
+ * it idles with its own entrypoint and steps run in the mounted workspace.
58
+ */
59
+ const JOB_CONTAINER_RESERVED = new Set(['--entrypoint', '--workdir', '-w']);
60
+ /** Also reserved when `--container-memory-limit` caps the job container. */
61
+ const MEMORY_OPTIONS = ['--memory', '-m', '--memory-swap'];
53
62
  /**
54
63
  * Image for jobs whose `runs-on` has no image mapping. Runner-like (Node.js,
55
64
  * git, curl and common tools), as used by nektos/act.
@@ -123,14 +132,6 @@ async function startJobContainer(job, ctx, options, runtime, runDir) {
123
132
  logger_1.logger.error(noRuntimeMessage(preference));
124
133
  return false;
125
134
  }
126
- if (spec &&
127
- 'image' in spec &&
128
- (('options' in spec && spec.options) ||
129
- ('volumes' in spec && spec.volumes) ||
130
- ('ports' in spec && spec.ports) ||
131
- ('credentials' in spec && spec.credentials))) {
132
- logger_1.logger.warn('jobs.<id>.container: only image and env are supported; ignoring options/volumes/ports/credentials.');
133
- }
134
135
  // `container: ${{ inputs.image || null }}` that comes out empty means no
135
136
  // container image, as on GitHub: use the image for runs-on.
136
137
  const specImage = spec?.image
@@ -140,19 +141,30 @@ async function startJobContainer(job, ctx, options, runtime, runDir) {
140
141
  options.containerImage ||
141
142
  runnerImage(job?.['runs-on'], ctx, options);
142
143
  const containerEnv = (0, expression_1.substituteRecord)(specImage && spec && 'env' in spec ? spec.env : undefined, ctx);
144
+ const extras = specImage && spec
145
+ ? jobContainerExtras(spec, ctx, options, runtime.workspace)
146
+ : {};
147
+ if (!extras)
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
+ }
143
154
  const exchangeDir = fs.mkdtempSync(path.join(runDir, 'container-'));
144
155
  fs.chmodSync(exchangeDir, 0o777);
145
156
  fs.copyFileSync(runtime.eventPath, path.join(exchangeDir, 'event.json'));
146
157
  logger_1.logger.info(`Starting job container: ${image} (${containerRuntime.name})`);
147
158
  const started = await containerRuntime.start({
148
159
  image,
149
- workingDir: process.cwd(),
160
+ workingDir: runtime.workspace,
150
161
  readOnlyPaths: protectedWorkspacePaths(options),
151
162
  exchangeDir,
152
163
  env: containerEnv,
153
164
  memoryLimit: options.containerMemoryLimit,
154
165
  networkDisabled: options.networkDisabled === true,
155
- network: runtime.services?.network
166
+ network: runtime.services?.network,
167
+ ...extras
156
168
  });
157
169
  if (!started.success || !started.id) {
158
170
  logger_1.logger.error(`Failed to start job container (${image}): ${started.error}`);
@@ -173,6 +185,74 @@ async function startJobContainer(job, ctx, options, runtime, runDir) {
173
185
  activeContainers.add(runtime.container);
174
186
  return true;
175
187
  }
188
+ /**
189
+ * The `options`, `ports` and `volumes` of `jobs.<id>.container`, checked
190
+ * with the same policy as service containers: options and host mounts that
191
+ * would reach this machine are refused (undefined: the job fails), options
192
+ * the CLI sets itself are dropped with a warning.
193
+ */
194
+ function jobContainerExtras(spec, ctx, options, workspace) {
195
+ const label = `jobs.${ctx.github.job ?? '<id>'}.container`;
196
+ const args = spec.options
197
+ ? (0, shell_1.splitArgs)((0, expression_1.substituteExpressions)(spec.options, ctx))
198
+ : [];
199
+ const unsafe = (0, container_policy_1.unsafeServiceOptions)(args);
200
+ if (unsafe.length > 0) {
201
+ logger_1.logger.error(`${label}: refusing unsafe options ${[...new Set(unsafe)].join(', ')}: they would give the container access to this machine.`);
202
+ return undefined;
203
+ }
204
+ const reserved = new Set([
205
+ ...JOB_CONTAINER_RESERVED,
206
+ ...(options.containerMemoryLimit ? MEMORY_OPTIONS : [])
207
+ ]);
208
+ const { kept, dropped } = (0, container_policy_1.splitReservedOptions)(args, reserved);
209
+ if (dropped.length > 0) {
210
+ logger_1.logger.warn(`${label}: ignoring options the CLI sets itself: ${[...new Set(dropped)].join(', ')}.`);
211
+ }
212
+ const volumes = (spec.volumes ?? []).map((v) => (0, expression_1.substituteExpressions)(v, ctx));
213
+ for (const volume of volumes) {
214
+ const reason = (0, container_policy_1.unsafeServiceVolumeReason)(volume, workspace);
215
+ if (reason) {
216
+ logger_1.logger.error(`${label}: ${reason}.`);
217
+ return undefined;
218
+ }
219
+ }
220
+ return {
221
+ extraArgs: kept,
222
+ ports: (spec.ports ?? []).map((port) => (0, expression_1.substituteExpressions)(String(port), ctx)),
223
+ volumes
224
+ };
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
+ }
176
256
  /**
177
257
  * The image for a job's `runs-on`: the first label with a mapping in
178
258
  * `runner-images` (or the built-in one), else the default image, with a
@@ -204,6 +284,59 @@ function runnerImage(runsOn, ctx, options) {
204
284
  }
205
285
  return fallback;
206
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
+ }
207
340
  async function stopJobContainer(runtime) {
208
341
  const container = runtime.container;
209
342
  if (container) {
@@ -267,16 +400,16 @@ async function startJobServices(job, ctx, options, runtime) {
267
400
  return false;
268
401
  }
269
402
  for (const volume of volumes) {
270
- const reason = (0, container_policy_1.unsafeServiceVolumeReason)(volume, process.cwd());
403
+ const reason = (0, container_policy_1.unsafeServiceVolumeReason)(volume, runtime.workspace);
271
404
  if (reason) {
272
405
  logger_1.logger.error(`services.${serviceId}: ${reason}.`);
273
406
  return false;
274
407
  }
275
408
  }
276
- if (service.credentials) {
277
- logger_1.logger.warn(`services.${serviceId}: credentials are ignored; log in with \`docker login\` instead.`);
278
- }
279
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
+ }
280
413
  logger_1.logger.info(`Starting service ${serviceId}: ${image}`);
281
414
  const started = await containerRuntime.start({
282
415
  image,
@@ -107,7 +107,7 @@ function runnerContext(runtime) {
107
107
  return {
108
108
  name: 'secureci',
109
109
  os: runtime.runnerEnv.RUNNER_OS,
110
- arch: process.arch === 'arm64' ? 'ARM64' : 'X64',
110
+ arch: runtime.runnerEnv.RUNNER_ARCH,
111
111
  temp: runtime.runnerEnv.RUNNER_TEMP,
112
112
  tool_cache: runtime.runnerEnv.RUNNER_TOOL_CACHE
113
113
  };
@@ -126,12 +126,13 @@ function hostHasBash() {
126
126
  }
127
127
  return hostBash;
128
128
  }
129
- function createJobRuntime(github, runDir, defaults = {}) {
129
+ function createJobRuntime(github, runDir, defaults = {}, workspace = process.cwd()) {
130
130
  const homeDir = process.env.HOME || process.env.USERPROFILE || os.tmpdir();
131
131
  const eventPath = path.join(runDir, 'event.json');
132
132
  return {
133
+ workspace,
133
134
  runnerEnv: (0, runner_env_1.buildRunnerEnv)(github, {
134
- workspace: process.cwd(),
135
+ workspace,
135
136
  eventPath,
136
137
  // Per job, so concurrent jobs don't share $RUNNER_TEMP.
137
138
  tempDir: fs.mkdtempSync(path.join(runDir, 'tmp-')),
@@ -144,7 +144,7 @@ async function executeNodeAction(action, actionPath, step, actionInputs, callerC
144
144
  }
145
145
  try {
146
146
  const { stdout, stderr } = await execFileAsync(node.path, [path.join(actionDir, script)], {
147
- cwd: process.cwd(),
147
+ cwd: runtime.workspace,
148
148
  maxBuffer: 64 * 1024 * 1024,
149
149
  timeout: limit.ms,
150
150
  killSignal: 'SIGKILL',
@@ -38,6 +38,7 @@ exports.createStepFiles = createStepFiles;
38
38
  exports.readStepFiles = readStepFiles;
39
39
  exports.cleanupStepFiles = cleanupStepFiles;
40
40
  exports.processWorkflowCommands = processWorkflowCommands;
41
+ exports.runnerArch = runnerArch;
41
42
  exports.buildRunnerEnv = buildRunnerEnv;
42
43
  exports.prependPath = prependPath;
43
44
  exports.hostBaseEnv = hostBaseEnv;
@@ -248,6 +249,24 @@ function runnerOs() {
248
249
  return 'Linux';
249
250
  }
250
251
  }
252
+ /**
253
+ * `RUNNER_ARCH` / `runner.arch` for a Node.js architecture, using GitHub's
254
+ * values (X86, X64, ARM, ARM64). Jobs run natively (a job container has the
255
+ * host's architecture), so the host's is the runner's. Anything else reports
256
+ * X64, as before this was mapped.
257
+ */
258
+ function runnerArch(arch = process.arch) {
259
+ switch (arch) {
260
+ case 'arm64':
261
+ return 'ARM64';
262
+ case 'arm':
263
+ return 'ARM';
264
+ case 'ia32':
265
+ return 'X86';
266
+ default:
267
+ return 'X64';
268
+ }
269
+ }
251
270
  /**
252
271
  * The default environment variables GitHub's runner exports to every step,
253
272
  * derived from the (local, best-effort) `github` context.
@@ -274,6 +293,7 @@ function buildRunnerEnv(github, options) {
274
293
  GITHUB_WORKSPACE: options.workspace,
275
294
  GITHUB_EVENT_PATH: options.eventPath,
276
295
  RUNNER_OS: runnerOs(),
296
+ RUNNER_ARCH: runnerArch(),
277
297
  RUNNER_TEMP: options.tempDir,
278
298
  RUNNER_TOOL_CACHE: options.toolCache
279
299
  };
@@ -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);
@@ -143,8 +143,8 @@ async function executeStep(step, options, runtime, ctx) {
143
143
  timeout: limit.ms,
144
144
  killSignal: 'SIGKILL',
145
145
  cwd: workingDirectory
146
- ? path.resolve(process.cwd(), workingDirectory)
147
- : process.cwd(),
146
+ ? path.resolve(runtime.workspace, workingDirectory)
147
+ : runtime.workspace,
148
148
  env: {
149
149
  ...(0, runner_env_1.hostBaseEnv)(),
150
150
  ...runtime.runnerEnv,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@secureci/cli",
3
- "version": "1.0.1",
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": {