@invarn/cibuild 1.4.8 → 1.5.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.
@@ -5,11 +5,16 @@ import { BaseStepExecutor } from './base.js';
5
5
  import type { StepDef, CIConfig } from '../../types.js';
6
6
  /**
7
7
  * Built-in cache presets per technology.
8
- * When `technology` is set on a cache step, the lockfile is checksummed
9
- * at runtime to produce the cache key, and the listed paths are cached/restored.
8
+ * When `technology` is set on a cache step, either the single `lockfile` is
9
+ * checksummed, or the list of `fingerprint` filenames is discovered and
10
+ * content-hashed, to produce the cache key. The listed `paths` are then
11
+ * cached/restored under that key.
10
12
  */
11
13
  export interface CachePreset {
12
- lockfile: string;
14
+ /** Single lockfile path to checksum. Use for toolchains with authoritative lockfiles (Podfile.lock, package-lock.json…). */
15
+ lockfile?: string;
16
+ /** Filenames to discover+hash across the repo. Use when no single file captures the full build graph (Gradle/KMM). */
17
+ fingerprint?: string[];
13
18
  keyPrefix: string;
14
19
  paths: string[];
15
20
  /** Optional fallback preset name: if primary lockfile not found, use this preset's lockfile/key/paths */
@@ -1 +1 @@
1
- {"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/cache.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAExD;;;;GAIG;AACH,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,yGAAyG;IACzG,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,eAAO,MAAM,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAUrD,CAAC;AAoBF;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,qBAAqB,CAAC,EAAE,OAAO,CAAC;CACjC;AAED;;;GAGG;AACH,qBAAa,qBAAsB,SAAQ,gBAAgB;IACnD,OAAO,CAAC,MAAM,EAAE,eAAe,EAAE,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,EAAE,QAAQ,GAAG,OAAO,CAAC,OAAO,CAAC;YAsGzF,iBAAiB;CA8HhC;AAED;;;GAGG;AACH,qBAAa,qBAAsB,SAAQ,gBAAgB;IACnD,OAAO,CAAC,MAAM,EAAE,eAAe,EAAE,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,EAAE,QAAQ,GAAG,OAAO,CAAC,OAAO,CAAC;YAuKzF,iBAAiB;CAoHhC"}
1
+ {"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/cache.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAkBxD;;;;;;GAMG;AACH,MAAM,WAAW,WAAW;IAC1B,4HAA4H;IAC5H,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,sHAAsH;IACtH,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,yGAAyG;IACzG,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,eAAO,MAAM,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAUrD,CAAC;AAgDF;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,qBAAqB,CAAC,EAAE,OAAO,CAAC;CACjC;AAED;;;GAGG;AACH,qBAAa,qBAAsB,SAAQ,gBAAgB;IACnD,OAAO,CAAC,MAAM,EAAE,eAAe,EAAE,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,EAAE,QAAQ,GAAG,OAAO,CAAC,OAAO,CAAC;YAsGzF,iBAAiB;CA+HhC;AAED;;;GAGG;AACH,qBAAa,qBAAsB,SAAQ,gBAAgB;IACnD,OAAO,CAAC,MAAM,EAAE,eAAe,EAAE,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,EAAE,QAAQ,GAAG,OAAO,CAAC,OAAO,CAAC;YA4JzF,iBAAiB;CA0GhC"}
@@ -2,17 +2,59 @@
2
2
  * Cache step implementations (cache-pull and cache-push)
3
3
  */
4
4
  import { BaseStepExecutor } from './base.js';
5
+ /**
6
+ * Files that together define a Gradle/KMM build graph. Hashing these catches
7
+ * module additions/removals, plugin changes, and version-catalog bumps — things
8
+ * that invalidate reusability of the `~/.gradle/caches` contents across branches.
9
+ * Source file edits do NOT affect this hash; Gradle's task cache inside the
10
+ * tarball handles source-level reuse.
11
+ */
12
+ const GRADLE_FINGERPRINT = [
13
+ 'settings.gradle',
14
+ 'settings.gradle.kts',
15
+ 'build.gradle',
16
+ 'build.gradle.kts',
17
+ 'libs.versions.toml',
18
+ 'gradle-wrapper.properties',
19
+ ];
5
20
  export const CACHE_PRESETS = {
6
21
  ios: { lockfile: 'Podfile.lock', keyPrefix: 'pods', paths: ['Pods'], fallback: 'spm' },
7
22
  cocoapods: { lockfile: 'Podfile.lock', keyPrefix: 'pods', paths: ['Pods'] },
8
23
  carthage: { lockfile: 'Cartfile.resolved', keyPrefix: 'carthage', paths: ['Carthage'] },
9
24
  spm: { lockfile: 'Package.resolved', keyPrefix: 'spm', paths: ['~/Library/Developer/Xcode/DerivedData/*/SourcePackages'] },
10
- gradle: { lockfile: 'gradle/wrapper/gradle-wrapper.properties', keyPrefix: 'gradle', paths: ['~/.gradle/caches', '~/.gradle/wrapper/dists'] },
11
- kmm: { lockfile: 'gradle/wrapper/gradle-wrapper.properties', keyPrefix: 'kmm', paths: ['~/.gradle/caches', '~/.gradle/wrapper/dists', '~/.konan'] },
25
+ gradle: { fingerprint: GRADLE_FINGERPRINT, keyPrefix: 'gradle', paths: ['~/.gradle/caches', '~/.gradle/wrapper/dists'] },
26
+ kmm: { fingerprint: GRADLE_FINGERPRINT, keyPrefix: 'kmm', paths: ['~/.gradle/caches', '~/.gradle/wrapper/dists', '~/.konan'] },
12
27
  npm: { lockfile: 'package-lock.json', keyPrefix: 'npm', paths: ['node_modules'] },
13
28
  yarn: { lockfile: 'yarn.lock', keyPrefix: 'yarn', paths: ['node_modules'] },
14
29
  dart: { lockfile: 'pubspec.lock', keyPrefix: 'dart', paths: ['.dart_tool', '.pub-cache'] },
15
30
  };
31
+ /**
32
+ * Emits bash that discovers fingerprint filenames across the repo, sorts them,
33
+ * and hashes their concatenated names+contents into CACHE_KEY.
34
+ * Used by presets (Gradle, KMM) where no single lockfile captures the build graph.
35
+ */
36
+ function emitFingerprintKeyCommands(preset) {
37
+ const fp = preset.fingerprint ?? [];
38
+ const escape = (s) => s.replace(/'/g, "'\\''");
39
+ const nameArgs = fp
40
+ .map((n, i) => `${i === 0 ? '' : '-o '}-name '${escape(n)}'`)
41
+ .join(' ');
42
+ return [
43
+ `__ci_fp_files=$(find . -type f \\( ${nameArgs} \\) \\`,
44
+ ` -not -path '*/build/*' \\`,
45
+ ` -not -path '*/.gradle/*' \\`,
46
+ ` -not -path '*/node_modules/*' \\`,
47
+ ` -not -path '*/DerivedData/*' \\`,
48
+ ` 2>/dev/null | LC_ALL=C sort)`,
49
+ `if [ -n "$__ci_fp_files" ]; then`,
50
+ ` CHECKSUM=$(echo "$__ci_fp_files" | xargs shasum -a 256 2>/dev/null | shasum -a 256 | cut -c1-16)`,
51
+ ` CACHE_KEY="${escape(preset.keyPrefix)}-\${CHECKSUM}"`,
52
+ `else`,
53
+ ` echo "Warning: no fingerprint files found, using fallback cache key"`,
54
+ ` CACHE_KEY="${escape(preset.keyPrefix)}-no-fingerprint"`,
55
+ `fi`,
56
+ ];
57
+ }
16
58
  /**
17
59
  * Resolves a preset with its fallback chain into a flat list of
18
60
  * {lockfile, keyPrefix, paths} entries to try in order at runtime.
@@ -139,7 +181,10 @@ export class CachePullStepExecutor extends BaseStepExecutor {
139
181
  commands.push(' find . -name "$(basename "$1")" -not -path "*/DerivedData/*" -not -path "*/.build/*" 2>/dev/null | head -1');
140
182
  commands.push('}');
141
183
  commands.push('');
142
- if (chain.length === 1) {
184
+ if (chain.length === 1 && chain[0].fingerprint && chain[0].fingerprint.length > 0) {
185
+ commands.push(...emitFingerprintKeyCommands(chain[0]));
186
+ }
187
+ else if (chain.length === 1) {
143
188
  const preset = chain[0];
144
189
  commands.push(`LOCKFILE=$(__ci_find_lockfile "${this.escapeBash(preset.lockfile)}")`);
145
190
  commands.push('if [ -n "$LOCKFILE" ] && [ -f "$LOCKFILE" ]; then');
@@ -171,7 +216,6 @@ export class CachePullStepExecutor extends BaseStepExecutor {
171
216
  }
172
217
  commands.push('');
173
218
  if (isDebugMode) {
174
- commands.push('echo "Lockfile: $LOCKFILE"');
175
219
  commands.push('echo "Cache key: $CACHE_KEY"');
176
220
  }
177
221
  commands.push('CACHE_FILE="$CACHE_DIR/$CACHE_KEY.tar.zst"');
@@ -342,20 +386,9 @@ export class CachePushStepExecutor extends BaseStepExecutor {
342
386
  commands.push(' echo "Paths to cache:"');
343
387
  commands.push(' printf " %s\\n" "${PATHS_TO_CACHE[@]}"');
344
388
  }
345
- // Acquire lock with shlock
346
- commands.push(' LOCK_FILE="$CACHE_FILE.lock"');
347
- commands.push(' __ci_lock_tries=0');
348
- commands.push(' while ! shlock -f "$LOCK_FILE" -p $$; do');
349
- commands.push(' __ci_lock_tries=$((__ci_lock_tries + 1))');
350
- commands.push(' if [ "$__ci_lock_tries" -ge 30 ]; then');
351
- commands.push(' echo "Warning: cache push lock timeout, skipping"');
352
- commands.push(' rm -f "$LOCK_FILE"');
353
- commands.push(' exit 0');
354
- commands.push(' fi');
355
- commands.push(' sleep 1');
356
- commands.push(' done');
389
+ // Atomic write: .tmp + rename. No lock needed — runs are serialized per runner,
390
+ // and cross-writer races resolve to last-writer-wins without corruption.
357
391
  commands.push(' tar -cf - "${PATHS_TO_CACHE[@]}" 2>/dev/null | zstd -3 > "$CACHE_FILE.tmp" && mv "$CACHE_FILE.tmp" "$CACHE_FILE" || true');
358
- commands.push(' rm -f "$LOCK_FILE"');
359
392
  commands.push(' if [ -f "$CACHE_FILE" ]; then');
360
393
  commands.push(' echo "Cache created successfully"');
361
394
  if (isDebugMode) {
@@ -391,7 +424,10 @@ export class CachePushStepExecutor extends BaseStepExecutor {
391
424
  commands.push(' find . -name "$(basename "$1")" -not -path "*/DerivedData/*" -not -path "*/.build/*" 2>/dev/null | head -1');
392
425
  commands.push('}');
393
426
  commands.push('');
394
- if (chain.length === 1) {
427
+ if (chain.length === 1 && chain[0].fingerprint && chain[0].fingerprint.length > 0) {
428
+ commands.push(...emitFingerprintKeyCommands(chain[0]));
429
+ }
430
+ else if (chain.length === 1) {
395
431
  const preset = chain[0];
396
432
  commands.push(`LOCKFILE=$(__ci_find_lockfile "${this.escapeBash(preset.lockfile)}")`);
397
433
  commands.push('if [ -n "$LOCKFILE" ] && [ -f "$LOCKFILE" ]; then');
@@ -419,7 +455,6 @@ export class CachePushStepExecutor extends BaseStepExecutor {
419
455
  }
420
456
  commands.push('');
421
457
  if (isDebugMode) {
422
- commands.push('echo "Lockfile: $LOCKFILE"');
423
458
  commands.push('echo "Cache key: $CACHE_KEY"');
424
459
  }
425
460
  commands.push('CACHE_FILE="$CACHE_DIR/$CACHE_KEY.tar.zst"');
@@ -447,21 +482,10 @@ export class CachePushStepExecutor extends BaseStepExecutor {
447
482
  commands.push(' echo "Cache already up to date for key: $CACHE_KEY"');
448
483
  commands.push('elif [ ${#PATHS_TO_CACHE[@]} -gt 0 ]; then');
449
484
  commands.push(' echo "Caching ${#PATHS_TO_CACHE[@]} path(s)..."');
450
- // Acquire lock with shlock (macOS-native, stale-lock safe via PID check)
451
- commands.push(' LOCK_FILE="$CACHE_FILE.lock"');
452
- commands.push(' __ci_lock_tries=0');
453
- commands.push(' while ! shlock -f "$LOCK_FILE" -p $$; do');
454
- commands.push(' __ci_lock_tries=$((__ci_lock_tries + 1))');
455
- commands.push(' if [ "$__ci_lock_tries" -ge 30 ]; then');
456
- commands.push(' echo "Warning: cache push lock timeout, skipping"');
457
- commands.push(' rm -f "$LOCK_FILE"');
458
- commands.push(' exit 0');
459
- commands.push(' fi');
460
- commands.push(' sleep 1');
461
- commands.push(' done');
462
- // Atomic write: compress to .tmp, then mv (peers never see half-written files)
485
+ // Atomic write: compress to .tmp, then mv. No lock needed runs are
486
+ // serialized per runner and cross-writer races resolve to last-writer-wins
487
+ // without corruption. shlock was flaky on the virtiofs cache mount.
463
488
  commands.push(' tar -cf - "${PATHS_TO_CACHE[@]}" 2>/dev/null | zstd -3 > "$CACHE_FILE.tmp" && mv "$CACHE_FILE.tmp" "$CACHE_FILE" || true');
464
- commands.push(' rm -f "$LOCK_FILE"');
465
489
  commands.push(' if [ -f "$CACHE_FILE" ]; then');
466
490
  commands.push(' echo "Cache created successfully"');
467
491
  if (isDebugMode) {
@@ -138,7 +138,8 @@ describe('Step Implementations', () => {
138
138
  test('should auto-configure for gradle technology', async () => {
139
139
  const executor = new CachePullStepExecutor();
140
140
  const result = await executor.execute({ technology: 'gradle' }, {}, testConfig);
141
- expect(result.script).toContain('gradle/wrapper/gradle-wrapper.properties');
141
+ expect(result.script).toContain("-name 'gradle-wrapper.properties'");
142
+ expect(result.script).toContain("-name 'build.gradle.kts'");
142
143
  expect(result.script).toContain('gradle-');
143
144
  });
144
145
  test('should auto-configure for npm technology', async () => {
@@ -174,7 +175,10 @@ describe('Step Implementations', () => {
174
175
  test('should auto-configure for kmm technology', async () => {
175
176
  const executor = new CachePullStepExecutor();
176
177
  const result = await executor.execute({ technology: 'kmm' }, {}, testConfig);
177
- expect(result.script).toContain('gradle/wrapper/gradle-wrapper.properties');
178
+ expect(result.script).toContain("-name 'gradle-wrapper.properties'");
179
+ expect(result.script).toContain("-name 'settings.gradle'");
180
+ expect(result.script).toContain("-name 'build.gradle.kts'");
181
+ expect(result.script).toContain("-name 'libs.versions.toml'");
178
182
  expect(result.script).toContain('kmm-');
179
183
  });
180
184
  test('should use zstd compression format', async () => {
@@ -266,7 +270,8 @@ describe('Step Implementations', () => {
266
270
  test('should auto-configure for gradle technology', async () => {
267
271
  const executor = new CachePushStepExecutor();
268
272
  const result = await executor.execute({ technology: 'gradle' }, {}, testConfig);
269
- expect(result.script).toContain('gradle/wrapper/gradle-wrapper.properties');
273
+ expect(result.script).toContain("-name 'gradle-wrapper.properties'");
274
+ expect(result.script).toContain("-name 'build.gradle.kts'");
270
275
  expect(result.script).toContain('~/.gradle/caches');
271
276
  });
272
277
  test('should auto-configure for npm technology', async () => {
@@ -285,7 +290,9 @@ describe('Step Implementations', () => {
285
290
  test('should auto-configure for kmm technology', async () => {
286
291
  const executor = new CachePushStepExecutor();
287
292
  const result = await executor.execute({ technology: 'kmm' }, {}, testConfig);
288
- expect(result.script).toContain('gradle/wrapper/gradle-wrapper.properties');
293
+ expect(result.script).toContain("-name 'gradle-wrapper.properties'");
294
+ expect(result.script).toContain("-name 'settings.gradle.kts'");
295
+ expect(result.script).toContain("-name 'libs.versions.toml'");
289
296
  expect(result.script).toContain('kmm-');
290
297
  expect(result.script).toContain('~/.gradle/caches');
291
298
  expect(result.script).toContain('~/.gradle/wrapper/dists');
@@ -303,21 +310,18 @@ describe('Step Implementations', () => {
303
310
  const executor = new CachePushStepExecutor();
304
311
  await expect(executor.execute({ technology: 'unknown' }, {}, testConfig)).rejects.toThrow("Unknown cache technology 'unknown'");
305
312
  });
306
- test('should use shlock locking for preset push', async () => {
313
+ test('should not use shlock (flaky on virtiofs cache mount) for preset push', async () => {
307
314
  const executor = new CachePushStepExecutor();
308
315
  const result = await executor.execute({ technology: 'cocoapods' }, {}, testConfig);
309
- expect(result.script).toContain('shlock -f "$LOCK_FILE" -p $$');
310
- expect(result.script).toContain('LOCK_FILE="$CACHE_FILE.lock"');
311
- expect(result.script).toContain('rm -f "$LOCK_FILE"');
312
- // Lock timeout after 30 retries
313
- expect(result.script).toContain('__ci_lock_tries');
314
- expect(result.script).toContain('-ge 30');
315
- });
316
- test('should use shlock locking for manual cache_key push', async () => {
316
+ expect(result.script).not.toContain('shlock');
317
+ expect(result.script).not.toContain('LOCK_FILE');
318
+ expect(result.script).not.toContain('__ci_lock_tries');
319
+ });
320
+ test('should not use shlock for manual cache_key push', async () => {
317
321
  const executor = new CachePushStepExecutor();
318
322
  const result = await executor.execute({ cache_key: 'my-key', cache_paths: ['build'] }, {}, testConfig);
319
- expect(result.script).toContain('shlock -f "$LOCK_FILE" -p $$');
320
- expect(result.script).toContain('rm -f "$LOCK_FILE"');
323
+ expect(result.script).not.toContain('shlock');
324
+ expect(result.script).not.toContain('LOCK_FILE');
321
325
  });
322
326
  test('should use atomic write (tmp + mv) for preset push', async () => {
323
327
  const executor = new CachePushStepExecutor();
@@ -325,6 +329,12 @@ describe('Step Implementations', () => {
325
329
  expect(result.script).toContain('> "$CACHE_FILE.tmp"');
326
330
  expect(result.script).toContain('mv "$CACHE_FILE.tmp" "$CACHE_FILE"');
327
331
  });
332
+ test('should use atomic write (tmp + mv) for manual cache_key push', async () => {
333
+ const executor = new CachePushStepExecutor();
334
+ const result = await executor.execute({ cache_key: 'my-key', cache_paths: ['build'] }, {}, testConfig);
335
+ expect(result.script).toContain('> "$CACHE_FILE.tmp"');
336
+ expect(result.script).toContain('mv "$CACHE_FILE.tmp" "$CACHE_FILE"');
337
+ });
328
338
  });
329
339
  describe('XcodeBuildStepExecutor', () => {
330
340
  test('should generate xcodebuild script', async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invarn/cibuild",
3
- "version": "1.4.8",
3
+ "version": "1.5.0",
4
4
  "description": "CI Build CLI — local pipeline orchestration and validation",
5
5
  "type": "module",
6
6
  "main": "dist/cli.cjs",