@invarn/cibuild 2.4.0 → 2.4.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.
@@ -71,7 +71,7 @@ export const DEFAULT_RENDER_SIZE = '393x852';
71
71
  /** Default display scale (@2x), matching how references are typically exported. */
72
72
  export const DEFAULT_SCALE = 2;
73
73
  /** Valid values for the package_source input. */
74
- export const PACKAGE_SOURCES = ['repo', 'inputs'];
74
+ export const PACKAGE_SOURCES = ['repo', 'inputs', 'url'];
75
75
  /** Default package source: the package lives in the repo checkout. */
76
76
  export const DEFAULT_PACKAGE_SOURCE = 'repo';
77
77
  /** Valid values for the reference_source input. */
@@ -1264,6 +1264,154 @@ function buildPackageSourceMain() {
1264
1264
  return main;
1265
1265
  }
1266
1266
  const RENDER_SCRIPT_MAIN_WITH_PACKAGE_SOURCE = buildPackageSourceMain();
1267
+ /**
1268
+ * Builds the runtime main for package_source "url" (fidelity-linux 03) by
1269
+ * patching the package_source variant. Three departures, all forced by the
1270
+ * same fact — a clone-free worker has no checkout:
1271
+ *
1272
+ * 1. The package is DOWNLOADED (curl, a runner requirement in this mode) to
1273
+ * `.ci/inputs/package.tar.gz` and extracted BEFORE the per-screen
1274
+ * reference stage, because committed references travel inside it. A failed
1275
+ * download is one structured per-build error, never per-screen noise.
1276
+ * 2. Each screen's reference is the committed repo-relative path resolved
1277
+ * INSIDE the extracted package: it must sit under packagePath, and the
1278
+ * remainder resolves against the extracted package root.
1279
+ * 3. The harness stage reuses the already-extracted root (no second extract);
1280
+ * cleanup mirrors the inputs path. Mirrors the Android step's url mode so
1281
+ * both platform kinds accept the same dispatched inputs.
1282
+ */
1283
+ function buildPackageUrlMain() {
1284
+ let main = RENDER_SCRIPT_MAIN_WITH_PACKAGE_SOURCE;
1285
+ // Download + extract FIRST — the reference stage below reads from inside
1286
+ // the extracted package.
1287
+ main = replaceOnce(main, ' // 2. Per-screen validation + reference copies. Each screen gets its OWN\n' +
1288
+ ' // copy named by screen, even when two screens share a reference\n' +
1289
+ ' // basename.\n' +
1290
+ ' currentEntries.forEach(function (entry) {', ' // 2a. Download + extract the shipped package FIRST (URL mode): committed\n' +
1291
+ ' // references live inside it, so the per-screen reference copies below\n' +
1292
+ ' // need the extracted package root. A failed download or an invalid\n' +
1293
+ ' // archive is ONE structured per-build error; per-screen reference\n' +
1294
+ ' // work then stops cleanly (no bogus REFERENCE_MISSING noise).\n' +
1295
+ ' var urlPackageRoot = null;\n' +
1296
+ ' (function () {\n' +
1297
+ ' try {\n' +
1298
+ ' fs.mkdirSync(inputsDir, { recursive: true });\n' +
1299
+ ' } catch (mkdirError) {\n' +
1300
+ ' // the download below reports the real failure\n' +
1301
+ ' }\n' +
1302
+ ' var dest = path.join(inputsDir, PACKAGE_ARCHIVE_NAME);\n' +
1303
+ " log('downloading render package');\n" +
1304
+ ' var download = cp.spawnSync(\n' +
1305
+ " 'curl',\n" +
1306
+ " ['-fsSL', '--retry', '2', '--max-time', '300', '-o', dest, CONFIG.packageUrl],\n" +
1307
+ " { encoding: 'utf-8', maxBuffer: 16 * 1024 * 1024 }\n" +
1308
+ ' );\n' +
1309
+ ' if (download.error) {\n' +
1310
+ ' download.status = download.status == null ? 127 : download.status;\n' +
1311
+ " download.stderr = (download.stderr || '') + String(download.error.message || download.error);\n" +
1312
+ ' }\n' +
1313
+ ' if (download.status !== 0) {\n' +
1314
+ ' setBuildError(\n' +
1315
+ " 'PACKAGE_DOWNLOAD_FAILED',\n" +
1316
+ " 'render package download failed (curl exit ' + download.status + '):\\n' + outputTail(download)\n" +
1317
+ ' );\n' +
1318
+ ' return;\n' +
1319
+ ' }\n' +
1320
+ ' urlPackageRoot = prepareShippedPackage();\n' +
1321
+ ' })();\n' +
1322
+ '\n' +
1323
+ ' // 2. Per-screen validation + reference copies. Each screen gets its OWN\n' +
1324
+ ' // copy named by screen, even when two screens share a reference\n' +
1325
+ ' // basename.\n' +
1326
+ ' currentEntries.forEach(function (entry) {');
1327
+ // References resolve INSIDE the extracted package: repo-relative path minus
1328
+ // the packagePath prefix, against the extracted package root.
1329
+ main = replaceOnce(main, 'var basename = params.screens[entry.screen];\n' +
1330
+ ' if (\n' +
1331
+ " typeof basename !== 'string' || basename === '' ||\n" +
1332
+ " basename === '.' || basename === '..' ||\n" +
1333
+ ' basename !== path.basename(basename)\n' +
1334
+ ' ) {\n' +
1335
+ ' setError(\n' +
1336
+ ' entry,\n' +
1337
+ " 'REFERENCE_MISSING',\n" +
1338
+ " 'reference for ' + entry.screen + ' must be a plain file basename inside ' +\n" +
1339
+ " inputsDir + ', got: ' + JSON.stringify(basename)\n" +
1340
+ ' );\n' +
1341
+ ' return;\n' +
1342
+ ' }\n' +
1343
+ ' var source = path.join(inputsDir, basename);', 'var refPath = params.screens[entry.screen];\n' +
1344
+ " if (typeof refPath !== 'string' || refPath === '') {\n" +
1345
+ ' setError(\n' +
1346
+ ' entry,\n' +
1347
+ " 'REFERENCE_MISSING',\n" +
1348
+ " 'reference for ' + entry.screen + ' must be a non-empty repo-relative path, got: ' +\n" +
1349
+ ' JSON.stringify(refPath)\n' +
1350
+ ' );\n' +
1351
+ ' return;\n' +
1352
+ ' }\n' +
1353
+ ' if (urlPackageRoot === null) {\n' +
1354
+ ' // Package unavailable: the per-build error above carries the cause.\n' +
1355
+ ' return;\n' +
1356
+ ' }\n' +
1357
+ " var packagePrefix = CONFIG.packagePath.replace(/^\\.\\//, '').replace(/\\/+$/, '');\n" +
1358
+ " var normalizedRef = refPath.replace(/^\\.\\//, '');\n" +
1359
+ " if (normalizedRef.indexOf(packagePrefix + '/') !== 0) {\n" +
1360
+ ' setError(\n' +
1361
+ ' entry,\n' +
1362
+ " 'REFERENCE_MISSING',\n" +
1363
+ " 'reference for ' + entry.screen + ' must live inside the shipped package (' +\n" +
1364
+ " packagePrefix + '), got: ' + JSON.stringify(refPath)\n" +
1365
+ ' );\n' +
1366
+ ' return;\n' +
1367
+ ' }\n' +
1368
+ ' var source = path.join(urlPackageRoot, normalizedRef.slice(packagePrefix.length + 1));');
1369
+ // Harness stage: build from the package extracted in stage 2a — no second
1370
+ // extract. This variant only ever runs with packageSource "url".
1371
+ main = replaceOnce(main, ' // 3. Resolve the package to build from, then render the remaining\n' +
1372
+ ' // screens through the synthesized harness.\n' +
1373
+ ' var renderable = currentEntries.filter(function (entry) { return entry.error === null; });\n' +
1374
+ " if (CONFIG.packageSource === 'inputs') {\n" +
1375
+ ' try {\n' +
1376
+ ' var shippedRoot = prepareShippedPackage();\n' +
1377
+ ' if (shippedRoot !== null) {\n' +
1378
+ ' if (renderable.length > 0) {\n' +
1379
+ ' renderWithHarness(renderable, shippedRoot);\n' +
1380
+ ' } else if (currentEntries.length > 0) {\n' +
1381
+ " log('no renderable screens, skipping harness build');\n" +
1382
+ ' }\n' +
1383
+ ' }\n' +
1384
+ ' } finally {\n' +
1385
+ ' cleanupShippedPackage();\n' +
1386
+ ' }\n' +
1387
+ ' } else if (renderable.length > 0) {\n' +
1388
+ ' if (!fs.existsSync(resolvedPackagePath)) {\n' +
1389
+ ' currentEntries.forEach(function (entry) {\n' +
1390
+ " setError(entry, 'RENDER_UNSUPPORTED', 'package_path does not exist: ' + resolvedPackagePath);\n" +
1391
+ ' });\n' +
1392
+ ' } else {\n' +
1393
+ ' renderWithHarness(renderable, resolvedPackagePath);\n' +
1394
+ ' }\n' +
1395
+ ' } else if (currentEntries.length > 0) {\n' +
1396
+ " log('no renderable screens, skipping harness build');\n" +
1397
+ ' }', ' // 3. Render from the package downloaded + extracted in stage 2a. The\n' +
1398
+ ' // extraction already reported packaging mistakes as the per-build\n' +
1399
+ ' // error, so a null root here just skips the harness build.\n' +
1400
+ ' var renderable = currentEntries.filter(function (entry) { return entry.error === null; });\n' +
1401
+ ' try {\n' +
1402
+ ' if (urlPackageRoot !== null) {\n' +
1403
+ ' if (renderable.length > 0) {\n' +
1404
+ ' renderWithHarness(renderable, urlPackageRoot);\n' +
1405
+ ' } else if (currentEntries.length > 0) {\n' +
1406
+ " log('no renderable screens, skipping harness build');\n" +
1407
+ ' }\n' +
1408
+ ' }\n' +
1409
+ ' } finally {\n' +
1410
+ ' cleanupShippedPackage();\n' +
1411
+ ' }');
1412
+ return main;
1413
+ }
1414
+ const RENDER_SCRIPT_MAIN_URL = buildPackageUrlMain();
1267
1415
  /**
1268
1416
  * Patches the reference-sourcing block to read each screen's reference from a
1269
1417
  * repo-relative path (resolved against the checkout) instead of a plain
@@ -1322,7 +1470,9 @@ export function getRenderScriptInternals() {
1322
1470
  export function generateRenderScript(config) {
1323
1471
  let main = config.packageSource === undefined
1324
1472
  ? RENDER_SCRIPT_MAIN
1325
- : RENDER_SCRIPT_MAIN_WITH_PACKAGE_SOURCE;
1473
+ : config.packageSource === 'url'
1474
+ ? RENDER_SCRIPT_MAIN_URL
1475
+ : RENDER_SCRIPT_MAIN_WITH_PACKAGE_SOURCE;
1326
1476
  // Reference-from-repo is an independent dimension layered on top of whichever
1327
1477
  // package base applies. Absent → byte-identical reference block (v1).
1328
1478
  if (config.referenceSource === 'repo') {
@@ -1344,13 +1494,18 @@ export function generateRenderScript(config) {
1344
1494
  */
1345
1495
  export class UiFidelityRenderStepExecutor extends BaseStepExecutor {
1346
1496
  getValidationRequirements(inputs, _env, _config) {
1347
- if (inputs && inputs.package_source === 'inputs') {
1348
- // The package ships as .ci/inputs/package.tar.gz at run time, so
1497
+ if (inputs && (inputs.package_source === 'inputs' || inputs.package_source === 'url')) {
1498
+ // The package ships as .ci/inputs/package.tar.gz at run time (uploaded
1499
+ // run input, or downloaded from the signed package_url in url mode), so
1349
1500
  // neither package_path nor target is required up front.
1350
- return [
1501
+ const requirements = [
1351
1502
  this.requireCommand('swift', 'Swift toolchain used to build and run the render harness', 'Install Xcode (or the Swift toolchain) on the runner'),
1352
1503
  this.requireCommand('tar', 'Archive tool used to extract the shipped package', 'Install tar on the runner'),
1353
1504
  ];
1505
+ if (inputs.package_source === 'url') {
1506
+ requirements.push(this.requireCommand('curl', 'Downloader used to fetch the render package by signed URL', 'Install curl on the runner'));
1507
+ }
1508
+ return requirements;
1354
1509
  }
1355
1510
  return [
1356
1511
  this.requireInput('package_path', inputs, 'Path to the SwiftPM package containing the screens'),
@@ -1375,10 +1530,21 @@ export class UiFidelityRenderStepExecutor extends BaseStepExecutor {
1375
1530
  }
1376
1531
  let packagePath = null;
1377
1532
  let target = null;
1378
- if (packageSource === 'inputs') {
1379
- // package_path is ignored: the package arrives as a run input.
1380
- // target is optional and discovered from the shipped manifest when
1381
- // the package declares exactly one library product.
1533
+ let packageUrl;
1534
+ if (packageSource === 'inputs' || packageSource === 'url') {
1535
+ // package_path names no local directory here: inputs mode ignores it
1536
+ // (the package arrives as a run input); url mode requires it as the
1537
+ // repo-relative prefix committed reference paths sit under, inside the
1538
+ // downloaded package. target is optional and discovered from the
1539
+ // shipped manifest when the package declares exactly one library
1540
+ // product.
1541
+ if (packageSource === 'url') {
1542
+ packageUrl = String(this.getRequiredInput(inputs, 'package_url', stepName));
1543
+ if (!/^https?:\/\//.test(packageUrl)) {
1544
+ throw new Error(`Invalid package_url for step '${stepName}': expected an http(s) URL`);
1545
+ }
1546
+ packagePath = String(this.getRequiredInput(inputs, 'package_path', stepName));
1547
+ }
1382
1548
  const configuredTarget = this.getInput(inputs, 'target', undefined);
1383
1549
  target = configuredTarget === undefined ? null : String(configuredTarget);
1384
1550
  }
@@ -1401,6 +1567,13 @@ export class UiFidelityRenderStepExecutor extends BaseStepExecutor {
1401
1567
  throw new Error(`Invalid reference_source '${value}' for step '${stepName}': ` +
1402
1568
  `expected one of: ${REFERENCE_SOURCES.join(', ')}`);
1403
1569
  }
1570
+ // URL mode owns reference sourcing (inside the extracted package) —
1571
+ // an explicit reference_source alongside it is a contradiction.
1572
+ if (packageSource === 'url') {
1573
+ throw new Error(`Invalid reference_source for step '${stepName}': ` +
1574
+ "package_source 'url' resolves references inside the downloaded " +
1575
+ 'package; omit reference_source');
1576
+ }
1404
1577
  if (value === 'repo') {
1405
1578
  referenceSource = 'repo';
1406
1579
  }
@@ -1411,6 +1584,9 @@ export class UiFidelityRenderStepExecutor extends BaseStepExecutor {
1411
1584
  if (packageSource !== undefined) {
1412
1585
  config.packageSource = packageSource;
1413
1586
  }
1587
+ if (packageUrl !== undefined) {
1588
+ config.packageUrl = packageUrl;
1589
+ }
1414
1590
  if (referenceSource !== undefined) {
1415
1591
  config.referenceSource = referenceSource;
1416
1592
  }
@@ -15,7 +15,7 @@
15
15
  * Swift toolchain (macOS with Xcode required, slow).
16
16
  */
17
17
  import { describe, test, expect, afterAll } from '@jest/globals';
18
- import { spawnSync } from 'node:child_process';
18
+ import { spawn, spawnSync } from 'node:child_process';
19
19
  import { gzipSync } from 'node:zlib';
20
20
  import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync, } from 'node:fs';
21
21
  import { tmpdir } from 'node:os';
@@ -242,7 +242,9 @@ function makeTarGz(entries) {
242
242
  const blocks = [];
243
243
  for (const entry of entries) {
244
244
  const isDir = entry.name.endsWith('/');
245
- const content = Buffer.from(entry.content ?? '', 'utf-8');
245
+ const content = Buffer.isBuffer(entry.content)
246
+ ? entry.content
247
+ : Buffer.from(entry.content ?? '', 'utf-8');
246
248
  blocks.push(tarHeader(entry.name, isDir ? 0 : content.length, isDir ? '5' : '0'));
247
249
  if (!isDir && content.length > 0) {
248
250
  const padded = Buffer.alloc(Math.ceil(content.length / 512) * 512);
@@ -450,6 +452,157 @@ describe('package_source input', () => {
450
452
  expect(inputsNames).toEqual(['swift', 'tar']);
451
453
  });
452
454
  });
455
+ // Clone-free package delivery (fidelity-linux 03): the dispatched YAML carries
456
+ // a signed package_url; the runtime downloads it to .ci/inputs/package.tar.gz
457
+ // and proceeds like inputs mode, resolving each screen's committed reference
458
+ // INSIDE the extracted package (repo-relative path minus the package_path
459
+ // prefix) — there is no checkout on a clone-free worker. Mirrors the Android
460
+ // step's url mode so both platform kinds accept the same dispatched inputs.
461
+ describe('package_source: url', () => {
462
+ /**
463
+ * Serve one tar.gz (or a 404) from a loopback HTTP server IN A CHILD
464
+ * PROCESS. runScript executes the generated script via spawnSync, which
465
+ * blocks this process's event loop — an in-process server would deadlock
466
+ * against the script's curl.
467
+ */
468
+ const SERVE_PACKAGE_SRC = [
469
+ "var http = require('http');",
470
+ "var fs = require('fs');",
471
+ 'var filePath = process.argv[1];',
472
+ 'var server = http.createServer(function (req, res) {',
473
+ " if (filePath === '-') { res.statusCode = 404; res.end('gone'); return; }",
474
+ " res.setHeader('content-type', 'application/gzip');",
475
+ ' res.end(fs.readFileSync(filePath));',
476
+ '});',
477
+ "server.listen(0, '127.0.0.1', function () {",
478
+ " console.log('PORT ' + server.address().port);",
479
+ '});',
480
+ ].join('\n');
481
+ async function servePackage(archive) {
482
+ let filePath = '-';
483
+ if (archive !== null) {
484
+ const dir = mkdtempSync(join(tmpdir(), 'ui-fidelity-url-serve-'));
485
+ tempDirs.push(dir);
486
+ filePath = join(dir, 'package.tar.gz');
487
+ writeFileSync(filePath, archive);
488
+ }
489
+ const child = spawn('node', ['-e', SERVE_PACKAGE_SRC, filePath], {
490
+ stdio: ['ignore', 'pipe', 'inherit'],
491
+ });
492
+ const port = await new Promise((resolvePort, rejectPort) => {
493
+ let buffered = '';
494
+ const timer = setTimeout(() => rejectPort(new Error('package server did not start')), 10_000);
495
+ child.stdout.on('data', (chunk) => {
496
+ buffered += chunk.toString();
497
+ const match = /PORT (\d+)/.exec(buffered);
498
+ if (match) {
499
+ clearTimeout(timer);
500
+ resolvePort(Number(match[1]));
501
+ }
502
+ });
503
+ child.on('exit', () => rejectPort(new Error('package server exited early')));
504
+ });
505
+ return {
506
+ url: `http://127.0.0.1:${port}/api/fidelity/packages/blob1?exp=1&sig=ab`,
507
+ close: () => new Promise((resolveClose) => {
508
+ child.once('exit', () => resolveClose(undefined));
509
+ child.kill('SIGKILL');
510
+ }),
511
+ };
512
+ }
513
+ /** Archive shaped like the dashboard's assembly: package-dir contents at the
514
+ * archive root, committed references under references/. */
515
+ function packagedProjectWithReference() {
516
+ return makeTarGz([
517
+ ...shippedPackageEntries(''),
518
+ {
519
+ name: 'references/home.png',
520
+ content: Buffer.concat([PNG_MAGIC, Buffer.from('ref:url-home')]),
521
+ },
522
+ ]);
523
+ }
524
+ test('package_source url requires package_url and package_path', async () => {
525
+ const executor = new UiFidelityRenderStepExecutor();
526
+ await expect(executor.execute({ package_source: 'url', package_path: 'ios-pkg' }, {}, testConfig)).rejects.toThrow(/package_url/);
527
+ await expect(executor.execute({ package_source: 'url', package_url: 'https://x.test/p' }, {}, testConfig)).rejects.toThrow(/package_path/);
528
+ });
529
+ test('rejects reference_source alongside package_source url', async () => {
530
+ const executor = new UiFidelityRenderStepExecutor();
531
+ await expect(executor.execute({
532
+ package_source: 'url',
533
+ package_url: 'https://x.test/p',
534
+ package_path: 'ios-pkg',
535
+ reference_source: 'repo',
536
+ }, {}, testConfig)).rejects.toThrow(/reference_source/);
537
+ });
538
+ test('url mode requires swift, tar, and curl', () => {
539
+ const executor = new UiFidelityRenderStepExecutor();
540
+ const names = executor
541
+ .getValidationRequirements({ package_source: 'url' }, {}, testConfig)
542
+ .map((requirement) => requirement.name);
543
+ expect(names).toEqual(['swift', 'tar', 'curl']);
544
+ });
545
+ test('downloads the package, renders, and resolves references inside it', async () => {
546
+ const project = makeProject({ screens: { HomeView: 'ios-pkg/references/home.png' } });
547
+ const served = await servePackage(packagedProjectWithReference());
548
+ try {
549
+ const executor = new UiFidelityRenderStepExecutor();
550
+ const step = await executor.execute({ package_source: 'url', package_url: served.url, package_path: 'ios-pkg' }, {}, testConfig);
551
+ const run = runScript(project, step.script);
552
+ expect(run.status).toBe(0);
553
+ const doc = readResult(project);
554
+ expect(doc.package_source).toBe('url');
555
+ const screen = doc.screens.find((s) => s.screen === 'HomeView');
556
+ expect(screen?.status).toBe('rendered');
557
+ expect(screen?.reference_image_path).toBe('ui-fidelity/references/HomeView.png');
558
+ expect(isPng(artifact(project, 'ui-fidelity/references/HomeView.png'))).toBe(true);
559
+ }
560
+ finally {
561
+ await served.close();
562
+ }
563
+ });
564
+ test('a failed download is one structured build error, not per-screen noise', async () => {
565
+ const project = makeProject({ screens: { HomeView: 'ios-pkg/references/home.png' } });
566
+ const served = await servePackage(null); // 404
567
+ try {
568
+ const executor = new UiFidelityRenderStepExecutor();
569
+ const step = await executor.execute({ package_source: 'url', package_url: served.url, package_path: 'ios-pkg' }, {}, testConfig);
570
+ const run = runScript(project, step.script);
571
+ expect(run.status).not.toBe(0);
572
+ const doc = readResult(project);
573
+ expect(doc.error?.code).toBe('PACKAGE_DOWNLOAD_FAILED');
574
+ const screen = doc.screens.find((s) => s.screen === 'HomeView');
575
+ expect(screen?.error).toBeNull();
576
+ expectNoAbsolutePathLeaks(project, doc);
577
+ }
578
+ finally {
579
+ await served.close();
580
+ }
581
+ });
582
+ test('a reference outside the package directory fails that screen only', async () => {
583
+ const project = makeProject({
584
+ screens: {
585
+ HomeView: 'ios-pkg/references/home.png',
586
+ Stray: 'design/elsewhere/stray.png',
587
+ },
588
+ });
589
+ const served = await servePackage(packagedProjectWithReference());
590
+ try {
591
+ const executor = new UiFidelityRenderStepExecutor();
592
+ const step = await executor.execute({ package_source: 'url', package_url: served.url, package_path: 'ios-pkg' }, {}, testConfig);
593
+ const run = runScript(project, step.script, { FAKE_SWIFT_RUN_SILENT: 'Stray' });
594
+ expect(run.status).not.toBe(0);
595
+ const doc = readResult(project);
596
+ expect(doc.screens.find((s) => s.screen === 'HomeView')?.status).toBe('rendered');
597
+ const stray = doc.screens.find((s) => s.screen === 'Stray');
598
+ expect(stray?.status).toBe('render_failed');
599
+ expect(stray?.error?.code).toBe('REFERENCE_MISSING');
600
+ }
601
+ finally {
602
+ await served.close();
603
+ }
604
+ });
605
+ });
453
606
  describe('render size and scale parsing', () => {
454
607
  test('default render size is 393x852 device points', () => {
455
608
  expect(DEFAULT_RENDER_SIZE).toBe('393x852');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invarn/cibuild",
3
- "version": "2.4.0",
3
+ "version": "2.4.1",
4
4
  "description": "CI Build CLI — local pipeline orchestration and validation",
5
5
  "type": "module",
6
6
  "main": "dist/cli.cjs",