@secureci/cli 0.10.0 → 1.0.1

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
@@ -9,6 +9,12 @@ them, and checks them against your organization's pipeline standards — without
9
9
  committing, pushing, or waiting on a CI runner. It is the free, open-source part
10
10
  of [SecureCI](https://secure-ci.dev).
11
11
 
12
+ <a href="https://secure-ci.dev/assets/video/secureci-walkthrough-16x9.mp4"><img src="https://secure-ci.dev/assets/readme-demo.svg" width="848" alt="Terminal demo of the secureci CLI on a demo repository: run executes a workflow locally and its tests pass; validate fails a deploy workflow against a pipeline contract (security scan below the minimum version, wrong step order, actions not pinned to a commit SHA); pin pins actions/checkout@v4 to its full commit SHA, keeping # v4 for Dependabot; inventory --check fails because the v4 tag now resolves to a different commit than the one locked."></a>
13
+
14
+ <sub>Real output from the CLI on a demo repository.</sub>
15
+
16
+ ▶️ **[Watch the 60-second walkthrough](https://secure-ci.dev/assets/video/secureci-walkthrough-16x9.mp4)**
17
+
12
18
  > 📖 This README is the quick tour. Every flag, context and edge case lives in
13
19
  > the **[full reference → DOCS.md](DOCS.md)**.
14
20
 
@@ -67,10 +73,13 @@ less install.sh
67
73
  sh install.sh
68
74
  ```
69
75
 
70
- Set `SECURECI_VERSION=0.10.0` to install a specific release,
76
+ Set `SECURECI_VERSION=1.0.1` to install a specific release,
71
77
  `SECURECI_INSTALL_DIR` to install somewhere else, and
72
78
  `SECURECI_REQUIRE_ATTESTATION=1` to fail unless `gh` verifies build provenance
73
- (e.g. `curl -fsSL https://secure-ci.dev/install.sh | SECURECI_VERSION=0.10.0 sh`).
79
+ (e.g. `curl -fsSL https://secure-ci.dev/install.sh | SECURECI_VERSION=1.0.1 sh`).
80
+ If the release's download link fails (GitHub has returned `504` for one while
81
+ its API still served the files), the script fetches the same files through the
82
+ GitHub API instead; the `SHA256SUMS` and provenance checks are unchanged.
74
83
  `secure-ci.dev/install.sh` redirects to [`install.sh`](install.sh) on this
75
84
  repository's `main` branch.
76
85
 
@@ -149,10 +158,13 @@ secureci pin
149
158
 
150
159
  ### Agent skill
151
160
 
152
- [`skills/secureci`](skills/secureci/SKILL.md) walks a coding agent (Claude Code,
153
- Codex, Cursor, …) through setting SecureCI up in a repo: pin actions, commit
154
- `secureci.lock`, and add the `secure-ci/cli` action as a step in an existing job
155
- (replacing `actionlint` if present).
161
+ [`skills/secureci`](skills/secureci/SKILL.md) lets a coding agent (Claude Code,
162
+ Codex, Cursor, …) check every workflow it creates or edits (`secureci validate`,
163
+ `secureci pin --check`), run workflows locally (`secureci run`), and set
164
+ SecureCI up in a repo: pin actions, commit `secureci.lock`, and add the
165
+ `secure-ci/cli` action as a step in an existing job (replacing `actionlint` if
166
+ present). It also picks up requests that name actionlint, zizmor, nektos/act,
167
+ pinact and similar tools.
156
168
 
157
169
  ```bash
158
170
  npx skills add secure-ci/cli --skill secureci
package/dist/cli.js CHANGED
@@ -292,6 +292,36 @@ function defaultWorkflowFiles() {
292
292
  .filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'))
293
293
  .map((f) => path.join('.github', 'workflows', f));
294
294
  }
295
+ /** The workflows in .github/workflows that aren't among `targets`. */
296
+ function otherWorkflowFiles(targets) {
297
+ const given = new Set(targets.map((file) => path.resolve(file)));
298
+ return defaultWorkflowFiles().filter((file) => !given.has(path.resolve(file)));
299
+ }
300
+ /**
301
+ * The remote references these workflows use, read locally (no network), or
302
+ * undefined, with a warning, when one of them can't be read.
303
+ */
304
+ async function workflowUses(files, lockName) {
305
+ const uses = new Set();
306
+ for (const file of files) {
307
+ try {
308
+ const parsed = (0, workflow_parser_1.parseFile)(file);
309
+ if (parsed.type !== 'workflow')
310
+ continue;
311
+ const inv = await (0, inventory_1.buildInventory)({
312
+ type: 'workflow',
313
+ path: file,
314
+ file: parsed.file
315
+ });
316
+ inv.actions.forEach((action) => uses.add(action.uses));
317
+ }
318
+ catch (error) {
319
+ logger_1.logger.warn(`Could not read ${file} (${error instanceof Error ? error.message : 'Unknown error'}), so every entry in ${lockName} is kept.`);
320
+ return undefined;
321
+ }
322
+ }
323
+ return uses;
324
+ }
295
325
  async function runInventory(files, options) {
296
326
  const update = options.updateLock || options.updateLockfile;
297
327
  const check = options.check || options.compare;
@@ -479,9 +509,33 @@ async function runInventory(files, options) {
479
509
  if (online) {
480
510
  unresolved.forEach((a) => logger_1.logger.warn(`Not locked: could not resolve ${a.uses} (repository deleted, renamed or private, or the network is unavailable?)`));
481
511
  }
482
- fs.writeFileSync(lockPath, (0, github_lockfile_1.serializeSecureciLockfile)(current));
483
- const transitive = Object.values(current).filter((e) => e.via).length;
484
- logger_1.logger.success(`Wrote ${Object.keys(current).length} action(s) to ${LOCKFILE_NAME}` +
512
+ // Given some of the workflows, lock what they use and keep the other
513
+ // workflows' entries as locked, instead of dropping them.
514
+ let entries = current;
515
+ const others = source ? otherWorkflowFiles(targets) : [];
516
+ if (source && others.length > 0) {
517
+ let previous;
518
+ try {
519
+ previous = (0, github_lockfile_1.readLockfile)(source);
520
+ }
521
+ catch (error) {
522
+ logger_1.logger.error(`Could not read ${lockName}: ${error instanceof Error ? error.message : 'Unknown error'}. Run without file arguments to rewrite it from every workflow.`);
523
+ process.exit(1);
524
+ }
525
+ // actions.lock records no `via`, so its transitive entries can't be
526
+ // told apart from unused ones: keep them all.
527
+ const otherUses = github
528
+ ? undefined
529
+ : await workflowUses(others, lockName);
530
+ const merged = (0, inventory_1.mergeLock)(previous.actions, current, otherUses);
531
+ entries = merged.actions;
532
+ if (merged.kept > 0) {
533
+ logger_1.logger.info(`Kept ${merged.kept} action(s) from ${lockName} for the other workflows.`);
534
+ }
535
+ }
536
+ fs.writeFileSync(lockPath, (0, github_lockfile_1.serializeSecureciLockfile)(entries));
537
+ const transitive = Object.values(entries).filter((e) => e.via).length;
538
+ logger_1.logger.success(`Wrote ${Object.keys(entries).length} action(s) to ${LOCKFILE_NAME}` +
485
539
  (transitive > 0 ? ` (${transitive} used by other actions)` : '') +
486
540
  '. Commit it to track supply-chain drift.');
487
541
  return;
package/dist/inventory.js CHANGED
@@ -37,6 +37,7 @@ exports.collectReferences = collectReferences;
37
37
  exports.buildInventory = buildInventory;
38
38
  exports.addTransitive = addTransitive;
39
39
  exports.diffLock = diffLock;
40
+ exports.mergeLock = mergeLock;
40
41
  const fs = __importStar(require("fs"));
41
42
  const path = __importStar(require("path"));
42
43
  const workflow_parser_1 = require("./parser/workflow-parser");
@@ -293,4 +294,55 @@ function diffLock(current, lock) {
293
294
  identity
294
295
  };
295
296
  }
297
+ /**
298
+ * Merge entries resolved for some workflow files into the previous lock, so
299
+ * locking those files doesn't drop the other workflows' entries.
300
+ *
301
+ * `updated` wins. From `previous`, keep what the other workflows still use
302
+ * (`otherUses`, their direct references) and, following `via`, what those
303
+ * pull in; drop the rest. A reference used directly by another workflow has
304
+ * no `via`, and a dependency both sides pull in lists every reference that
305
+ * pulls it in. When `otherUses` is unknown (GitHub's actions.lock records no
306
+ * `via`, or a workflow couldn't be read), keep every previous entry.
307
+ */
308
+ function mergeLock(previous, updated, otherUses) {
309
+ if (!otherUses) {
310
+ const kept = Object.keys(previous).filter((uses) => !(uses in updated));
311
+ return { actions: { ...previous, ...updated }, kept: kept.length };
312
+ }
313
+ // Previous entries the other workflows reach, taken as locked.
314
+ const kept = new Set([...otherUses].filter((uses) => uses in previous && !(uses in updated)));
315
+ for (let grew = true; grew;) {
316
+ grew = false;
317
+ for (const [uses, entry] of Object.entries(previous)) {
318
+ if (kept.has(uses) || uses in updated)
319
+ continue;
320
+ if (entry.via?.some((v) => kept.has(v))) {
321
+ kept.add(uses);
322
+ grew = true;
323
+ }
324
+ }
325
+ }
326
+ const withVia = (entry, via) => {
327
+ const out = { ...entry };
328
+ delete out.via;
329
+ if (via.length > 0)
330
+ out.via = [...new Set(via)].sort();
331
+ return out;
332
+ };
333
+ const stillLocked = (via) => (via ?? []).filter((v) => kept.has(v));
334
+ const actions = {};
335
+ for (const [uses, entry] of Object.entries(updated)) {
336
+ actions[uses] = otherUses.has(uses)
337
+ ? withVia(entry, [])
338
+ : entry.via
339
+ ? withVia(entry, [...entry.via, ...stillLocked(previous[uses]?.via)])
340
+ : entry;
341
+ }
342
+ for (const uses of kept) {
343
+ const entry = previous[uses];
344
+ actions[uses] = entry.via ? withVia(entry, stillLocked(entry.via)) : entry;
345
+ }
346
+ return { actions, kept: kept.size };
347
+ }
296
348
  //# sourceMappingURL=inventory.js.map
@@ -391,7 +391,7 @@ async function uploadArtifact(fsys, inputs, runId) {
391
391
  copyFile(file.host, path.join(target, ...rel.split('/')));
392
392
  digest.update(rel).update(fs.readFileSync(file.host));
393
393
  }
394
- const id = String(parseInt(crypto.createHash('sha1').update(target).digest('hex').slice(0, 8), 16));
394
+ const id = String(parseInt(crypto.createHash('sha256').update(target).digest('hex').slice(0, 8), 16));
395
395
  return ok(`Uploaded ${files.length} file(s) as artifact "${name}" to ${target}`, {
396
396
  'artifact-id': id,
397
397
  'artifact-url': '',
@@ -90,7 +90,7 @@ async function executeDockerAction(action, actionPath, step, actionInputs, calle
90
90
  duration: Date.now() - startTime
91
91
  };
92
92
  }
93
- const tag = `secureci-action-${crypto.createHash('sha1').update(actionDir).digest('hex').slice(0, 12)}`;
93
+ const tag = `secureci-action-${crypto.createHash('sha256').update(actionDir).digest('hex').slice(0, 12)}`;
94
94
  logger_1.logger.info(`Building Docker action image: ${action.name} (${tag})`);
95
95
  const buildResult = await containerRuntime.build(actionDir, tag, dockerfileRelative);
96
96
  if (!buildResult.success) {
@@ -285,7 +285,7 @@ function actionDirInContainer(container, actionDir) {
285
285
  let containerDir = container.actionDirs.get(actionDir);
286
286
  if (!containerDir) {
287
287
  const hash = crypto
288
- .createHash('sha1')
288
+ .createHash('sha256')
289
289
  .update(actionDir)
290
290
  .digest('hex')
291
291
  .slice(0, 12);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@secureci/cli",
3
- "version": "0.10.0",
3
+ "version": "1.0.1",
4
4
  "description": "SecureCI: validate, check against pipeline standards, and run GitHub Actions workflows locally",
5
5
  "main": "dist/index.js",
6
6
  "bin": {