@invarn/cibuild 1.4.9 → 1.5.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.
@@ -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;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;YAuKzF,iBAAiB;CAqHhC"}
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;AA8DF;;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;CAkKhC;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;CA+GhC"}
@@ -28,9 +28,22 @@ export const CACHE_PRESETS = {
28
28
  yarn: { lockfile: 'yarn.lock', keyPrefix: 'yarn', paths: ['node_modules'] },
29
29
  dart: { lockfile: 'pubspec.lock', keyPrefix: 'dart', paths: ['.dart_tool', '.pub-cache'] },
30
30
  };
31
+ /**
32
+ * Emits bash that derives a stable short project id from the git remote URL
33
+ * (with embedded credentials stripped, so token rotations don't churn the key).
34
+ * Sets $__ci_proj to an 8-char hex. Falls back to "no-remote" if no git remote.
35
+ */
36
+ function emitProjectScopeCommands() {
37
+ return [
38
+ '__ci_git_remote=$(git config --get remote.origin.url 2>/dev/null | sed -E "s#(://)[^@/]+@#\\1#" || echo "no-remote")',
39
+ '__ci_proj=$(printf "%s" "${__ci_git_remote:-no-remote}" | shasum -a 256 | cut -c1-8)',
40
+ ];
41
+ }
31
42
  /**
32
43
  * Emits bash that discovers fingerprint filenames across the repo, sorts them,
33
44
  * and hashes their concatenated names+contents into CACHE_KEY.
45
+ * Key format: <keyPrefix>-<projectId>-<fingerprint>, so tarballs are scoped
46
+ * per-repo and fallback globbing can't cross-contaminate between projects.
34
47
  * Used by presets (Gradle, KMM) where no single lockfile captures the build graph.
35
48
  */
36
49
  function emitFingerprintKeyCommands(preset) {
@@ -48,10 +61,10 @@ function emitFingerprintKeyCommands(preset) {
48
61
  ` 2>/dev/null | LC_ALL=C sort)`,
49
62
  `if [ -n "$__ci_fp_files" ]; then`,
50
63
  ` 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}"`,
64
+ ` CACHE_KEY="${escape(preset.keyPrefix)}-\${__ci_proj}-\${CHECKSUM}"`,
52
65
  `else`,
53
66
  ` echo "Warning: no fingerprint files found, using fallback cache key"`,
54
- ` CACHE_KEY="${escape(preset.keyPrefix)}-no-fingerprint"`,
67
+ ` CACHE_KEY="${escape(preset.keyPrefix)}-\${__ci_proj}-no-fingerprint"`,
55
68
  `fi`,
56
69
  ];
57
70
  }
@@ -181,6 +194,11 @@ export class CachePullStepExecutor extends BaseStepExecutor {
181
194
  commands.push(' find . -name "$(basename "$1")" -not -path "*/DerivedData/*" -not -path "*/.build/*" 2>/dev/null | head -1');
182
195
  commands.push('}');
183
196
  commands.push('');
197
+ // Project scope: include a stable short hash of the git remote in every
198
+ // cache key so different repos on the same runner don't share tarballs
199
+ // (prevents fallback cross-contamination across projects).
200
+ commands.push(...emitProjectScopeCommands());
201
+ commands.push('');
184
202
  if (chain.length === 1 && chain[0].fingerprint && chain[0].fingerprint.length > 0) {
185
203
  commands.push(...emitFingerprintKeyCommands(chain[0]));
186
204
  }
@@ -189,10 +207,10 @@ export class CachePullStepExecutor extends BaseStepExecutor {
189
207
  commands.push(`LOCKFILE=$(__ci_find_lockfile "${this.escapeBash(preset.lockfile)}")`);
190
208
  commands.push('if [ -n "$LOCKFILE" ] && [ -f "$LOCKFILE" ]; then');
191
209
  commands.push(` CHECKSUM=$(shasum -a 256 "$LOCKFILE" | cut -d ' ' -f1 | head -c 16)`);
192
- commands.push(` CACHE_KEY="${this.escapeBash(preset.keyPrefix)}-\${CHECKSUM}"`);
210
+ commands.push(` CACHE_KEY="${this.escapeBash(preset.keyPrefix)}-\${__ci_proj}-\${CHECKSUM}"`);
193
211
  commands.push('else');
194
212
  commands.push(` echo "Warning: ${this.escapeBash(preset.lockfile)} not found, using fallback cache key"`);
195
- commands.push(` CACHE_KEY="${this.escapeBash(preset.keyPrefix)}-no-lockfile"`);
213
+ commands.push(` CACHE_KEY="${this.escapeBash(preset.keyPrefix)}-\${__ci_proj}-no-lockfile"`);
196
214
  commands.push('fi');
197
215
  }
198
216
  else {
@@ -204,14 +222,14 @@ export class CachePullStepExecutor extends BaseStepExecutor {
204
222
  commands.push(`${cond} [ -n "$__ci_lockfile_candidate" ] && [ -f "$__ci_lockfile_candidate" ]; then`);
205
223
  commands.push(' LOCKFILE="$__ci_lockfile_candidate"');
206
224
  commands.push(` CHECKSUM=$(shasum -a 256 "$LOCKFILE" | cut -d ' ' -f1 | head -c 16)`);
207
- commands.push(` CACHE_KEY="${this.escapeBash(preset.keyPrefix)}-\${CHECKSUM}"`);
225
+ commands.push(` CACHE_KEY="${this.escapeBash(preset.keyPrefix)}-\${__ci_proj}-\${CHECKSUM}"`);
208
226
  if (isDebugMode) {
209
227
  commands.push(` echo "Detected: ${this.escapeBash(preset.keyPrefix)} (lockfile: $LOCKFILE)"`);
210
228
  }
211
229
  }
212
230
  commands.push('else');
213
231
  commands.push(` echo "Warning: no lockfile found, using fallback cache key"`);
214
- commands.push(` CACHE_KEY="${this.escapeBash(chain[0].keyPrefix)}-no-lockfile"`);
232
+ commands.push(` CACHE_KEY="${this.escapeBash(chain[0].keyPrefix)}-\${__ci_proj}-no-lockfile"`);
215
233
  commands.push('fi');
216
234
  }
217
235
  commands.push('');
@@ -247,10 +265,40 @@ export class CachePullStepExecutor extends BaseStepExecutor {
247
265
  commands.push(' mv "$CACHE_FILE.tmp" "$CACHE_FILE"');
248
266
  commands.push(' echo "CACHE_SOURCE=peer CACHE_KEY=$CACHE_KEY"');
249
267
  }
250
- // 3. Cold miss
268
+ // 3. Fallback — no exact-key tarball, try the newest prior tarball scoped
269
+ // to the same <keyPrefix>-<projectId> (so fallback stays within this repo
270
+ // and doesn't pick up a different KMM project's cache). Gradle will
271
+ // re-hash task inputs on top of the warm ~/.gradle/caches directory,
272
+ // hitting the unchanged tasks and recompiling only what actually changed.
273
+ // Deliberately does NOT warm $CACHE_FILE — that way cache-push still runs
274
+ // and creates a fresh tarball under the new key for future exact hits.
251
275
  commands.push('else');
252
- commands.push(' echo "No cache found for key: $CACHE_KEY"');
253
- commands.push(' echo "CACHE_SOURCE=cold CACHE_KEY=$CACHE_KEY"');
276
+ commands.push(' __ci_fb_scope="${CACHE_KEY%-*}"');
277
+ commands.push(' __ci_fb=$(ls -t "$CACHE_DIR"/"$__ci_fb_scope"-*.tar.zst 2>/dev/null | head -1)');
278
+ if (peerCacheDir) {
279
+ commands.push(' if [ -z "$__ci_fb" ] || [ ! -f "$__ci_fb" ]; then');
280
+ commands.push(' __ci_fb=$(ls -t "$PEER_CACHE_DIR"/"$__ci_fb_scope"-*.tar.zst 2>/dev/null | head -1)');
281
+ commands.push(' __ci_fb_src=peer');
282
+ commands.push(' else');
283
+ commands.push(' __ci_fb_src=local');
284
+ commands.push(' fi');
285
+ }
286
+ else {
287
+ commands.push(' __ci_fb_src=local');
288
+ }
289
+ commands.push(' if [ -n "$__ci_fb" ] && [ -f "$__ci_fb" ]; then');
290
+ commands.push(' __ci_fb_key=$(basename "$__ci_fb" .tar.zst)');
291
+ commands.push(' echo "Cache fallback ($__ci_fb_src): using prior tarball $__ci_fb_key"');
292
+ if (isDebugMode) {
293
+ commands.push(' echo "Fallback size: $(du -h "$__ci_fb" | cut -f1)"');
294
+ }
295
+ commands.push(' zstd -dc "$__ci_fb" | tar -xf - -C /');
296
+ commands.push(' echo "CACHE_SOURCE=fallback_$__ci_fb_src CACHE_KEY=$CACHE_KEY FALLBACK_KEY=$__ci_fb_key"');
297
+ commands.push(' else');
298
+ // 4. Cold miss — no exact key, no fallback
299
+ commands.push(' echo "No cache found for key: $CACHE_KEY"');
300
+ commands.push(' echo "CACHE_SOURCE=cold CACHE_KEY=$CACHE_KEY"');
301
+ commands.push(' fi');
254
302
  commands.push('fi');
255
303
  if (isDebugMode) {
256
304
  commands.push('echo "Restored paths:"');
@@ -386,20 +434,9 @@ export class CachePushStepExecutor extends BaseStepExecutor {
386
434
  commands.push(' echo "Paths to cache:"');
387
435
  commands.push(' printf " %s\\n" "${PATHS_TO_CACHE[@]}"');
388
436
  }
389
- // Acquire lock with shlock
390
- commands.push(' LOCK_FILE="$CACHE_FILE.lock"');
391
- commands.push(' __ci_lock_tries=0');
392
- commands.push(' while ! shlock -f "$LOCK_FILE" -p $$; do');
393
- commands.push(' __ci_lock_tries=$((__ci_lock_tries + 1))');
394
- commands.push(' if [ "$__ci_lock_tries" -ge 30 ]; then');
395
- commands.push(' echo "Warning: cache push lock timeout, skipping"');
396
- commands.push(' rm -f "$LOCK_FILE"');
397
- commands.push(' exit 0');
398
- commands.push(' fi');
399
- commands.push(' sleep 1');
400
- commands.push(' done');
437
+ // Atomic write: .tmp + rename. No lock needed — runs are serialized per runner,
438
+ // and cross-writer races resolve to last-writer-wins without corruption.
401
439
  commands.push(' tar -cf - "${PATHS_TO_CACHE[@]}" 2>/dev/null | zstd -3 > "$CACHE_FILE.tmp" && mv "$CACHE_FILE.tmp" "$CACHE_FILE" || true');
402
- commands.push(' rm -f "$LOCK_FILE"');
403
440
  commands.push(' if [ -f "$CACHE_FILE" ]; then');
404
441
  commands.push(' echo "Cache created successfully"');
405
442
  if (isDebugMode) {
@@ -435,6 +472,10 @@ export class CachePushStepExecutor extends BaseStepExecutor {
435
472
  commands.push(' find . -name "$(basename "$1")" -not -path "*/DerivedData/*" -not -path "*/.build/*" 2>/dev/null | head -1');
436
473
  commands.push('}');
437
474
  commands.push('');
475
+ // Project scope: include a stable short hash of the git remote in every
476
+ // cache key so different repos on the same runner don't share tarballs.
477
+ commands.push(...emitProjectScopeCommands());
478
+ commands.push('');
438
479
  if (chain.length === 1 && chain[0].fingerprint && chain[0].fingerprint.length > 0) {
439
480
  commands.push(...emitFingerprintKeyCommands(chain[0]));
440
481
  }
@@ -443,10 +484,10 @@ export class CachePushStepExecutor extends BaseStepExecutor {
443
484
  commands.push(`LOCKFILE=$(__ci_find_lockfile "${this.escapeBash(preset.lockfile)}")`);
444
485
  commands.push('if [ -n "$LOCKFILE" ] && [ -f "$LOCKFILE" ]; then');
445
486
  commands.push(` CHECKSUM=$(shasum -a 256 "$LOCKFILE" | cut -d ' ' -f1 | head -c 16)`);
446
- commands.push(` CACHE_KEY="${this.escapeBash(preset.keyPrefix)}-\${CHECKSUM}"`);
487
+ commands.push(` CACHE_KEY="${this.escapeBash(preset.keyPrefix)}-\${__ci_proj}-\${CHECKSUM}"`);
447
488
  commands.push('else');
448
489
  commands.push(` echo "Warning: ${this.escapeBash(preset.lockfile)} not found, using fallback cache key"`);
449
- commands.push(` CACHE_KEY="${this.escapeBash(preset.keyPrefix)}-no-lockfile"`);
490
+ commands.push(` CACHE_KEY="${this.escapeBash(preset.keyPrefix)}-\${__ci_proj}-no-lockfile"`);
450
491
  commands.push('fi');
451
492
  }
452
493
  else {
@@ -457,11 +498,11 @@ export class CachePushStepExecutor extends BaseStepExecutor {
457
498
  commands.push(`${cond} [ -n "$__ci_lockfile_candidate" ] && [ -f "$__ci_lockfile_candidate" ]; then`);
458
499
  commands.push(' LOCKFILE="$__ci_lockfile_candidate"');
459
500
  commands.push(` CHECKSUM=$(shasum -a 256 "$LOCKFILE" | cut -d ' ' -f1 | head -c 16)`);
460
- commands.push(` CACHE_KEY="${this.escapeBash(preset.keyPrefix)}-\${CHECKSUM}"`);
501
+ commands.push(` CACHE_KEY="${this.escapeBash(preset.keyPrefix)}-\${__ci_proj}-\${CHECKSUM}"`);
461
502
  }
462
503
  commands.push('else');
463
504
  commands.push(` echo "Warning: no lockfile found, using fallback cache key"`);
464
- commands.push(` CACHE_KEY="${this.escapeBash(chain[0].keyPrefix)}-no-lockfile"`);
505
+ commands.push(` CACHE_KEY="${this.escapeBash(chain[0].keyPrefix)}-\${__ci_proj}-no-lockfile"`);
465
506
  commands.push('fi');
466
507
  }
467
508
  commands.push('');
@@ -493,21 +534,10 @@ export class CachePushStepExecutor extends BaseStepExecutor {
493
534
  commands.push(' echo "Cache already up to date for key: $CACHE_KEY"');
494
535
  commands.push('elif [ ${#PATHS_TO_CACHE[@]} -gt 0 ]; then');
495
536
  commands.push(' echo "Caching ${#PATHS_TO_CACHE[@]} path(s)..."');
496
- // Acquire lock with shlock (macOS-native, stale-lock safe via PID check)
497
- commands.push(' LOCK_FILE="$CACHE_FILE.lock"');
498
- commands.push(' __ci_lock_tries=0');
499
- commands.push(' while ! shlock -f "$LOCK_FILE" -p $$; do');
500
- commands.push(' __ci_lock_tries=$((__ci_lock_tries + 1))');
501
- commands.push(' if [ "$__ci_lock_tries" -ge 30 ]; then');
502
- commands.push(' echo "Warning: cache push lock timeout, skipping"');
503
- commands.push(' rm -f "$LOCK_FILE"');
504
- commands.push(' exit 0');
505
- commands.push(' fi');
506
- commands.push(' sleep 1');
507
- commands.push(' done');
508
- // Atomic write: compress to .tmp, then mv (peers never see half-written files)
537
+ // Atomic write: compress to .tmp, then mv. No lock needed runs are
538
+ // serialized per runner and cross-writer races resolve to last-writer-wins
539
+ // without corruption. shlock was flaky on the virtiofs cache mount.
509
540
  commands.push(' tar -cf - "${PATHS_TO_CACHE[@]}" 2>/dev/null | zstd -3 > "$CACHE_FILE.tmp" && mv "$CACHE_FILE.tmp" "$CACHE_FILE" || true');
510
- commands.push(' rm -f "$LOCK_FILE"');
511
541
  commands.push(' if [ -f "$CACHE_FILE" ]; then');
512
542
  commands.push(' echo "Cache created successfully"');
513
543
  if (isDebugMode) {
@@ -218,6 +218,47 @@ describe('Step Implementations', () => {
218
218
  expect(result.script).toContain('CACHE_SOURCE=peer');
219
219
  expect(result.script).toContain('tee "$CACHE_FILE.tmp" < "$PEER_CACHE_FILE"');
220
220
  });
221
+ test('should fall back to newest prior tarball scoped to <keyPrefix>-<projectId> on exact-key miss', async () => {
222
+ const executor = new CachePullStepExecutor();
223
+ const result = await executor.execute({ technology: 'kmm' }, {}, testConfig);
224
+ // Scope = everything before the last dash (prefix+projectId, not just prefix),
225
+ // so fallback cannot cross-contaminate between different projects
226
+ expect(result.script).toContain('__ci_fb_scope="${CACHE_KEY%-*}"');
227
+ expect(result.script).toContain('ls -t "$CACHE_DIR"/"$__ci_fb_scope"-*.tar.zst');
228
+ expect(result.script).toContain('CACHE_SOURCE=fallback_');
229
+ expect(result.script).toContain('FALLBACK_KEY=');
230
+ // Fallback must NOT warm $CACHE_FILE (otherwise cache-push would skip)
231
+ const fallbackSection = result.script.split('__ci_fb_scope=')[1] ?? '';
232
+ expect(fallbackSection).not.toContain('mv "$CACHE_FILE.tmp" "$CACHE_FILE"');
233
+ });
234
+ test('should try peer dir for fallback when local has no prior tarball', async () => {
235
+ const executor = new CachePullStepExecutor();
236
+ const result = await executor.execute({ technology: 'kmm' }, {}, testConfig);
237
+ expect(result.script).toContain('ls -t "$PEER_CACHE_DIR"/"$__ci_fb_scope"-*.tar.zst');
238
+ expect(result.script).toContain('__ci_fb_src=peer');
239
+ });
240
+ test('should skip peer fallback branch when peerCacheDir is not set', async () => {
241
+ const executor = new CachePullStepExecutor();
242
+ const result = await executor.execute({ technology: 'kmm' }, {}, testConfigNoPeer);
243
+ expect(result.script).not.toContain('ls -t "$PEER_CACHE_DIR"');
244
+ // Local fallback still available
245
+ expect(result.script).toContain('ls -t "$CACHE_DIR"/"$__ci_fb_scope"-*.tar.zst');
246
+ });
247
+ test('should include project-scope id ($__ci_proj) in cache key', async () => {
248
+ const executor = new CachePullStepExecutor();
249
+ const result = await executor.execute({ technology: 'kmm' }, {}, testConfig);
250
+ // Project id derived from stripped git remote URL, 8-char hash
251
+ expect(result.script).toContain('git config --get remote.origin.url');
252
+ expect(result.script).toContain('__ci_proj=$');
253
+ // Key format: <prefix>-<projid>-<fp>
254
+ expect(result.script).toContain('CACHE_KEY="kmm-${__ci_proj}-${CHECKSUM}"');
255
+ });
256
+ test('should include project-scope id in lockfile-based preset keys', async () => {
257
+ const executor = new CachePullStepExecutor();
258
+ const result = await executor.execute({ technology: 'cocoapods' }, {}, testConfig);
259
+ expect(result.script).toContain('__ci_proj=$');
260
+ expect(result.script).toContain('CACHE_KEY="pods-${__ci_proj}-${CHECKSUM}"');
261
+ });
221
262
  });
222
263
  describe('CachePushStepExecutor', () => {
223
264
  test('should generate cache push script', async () => {
@@ -310,21 +351,18 @@ describe('Step Implementations', () => {
310
351
  const executor = new CachePushStepExecutor();
311
352
  await expect(executor.execute({ technology: 'unknown' }, {}, testConfig)).rejects.toThrow("Unknown cache technology 'unknown'");
312
353
  });
313
- test('should use shlock locking for preset push', async () => {
354
+ test('should not use shlock (flaky on virtiofs cache mount) for preset push', async () => {
314
355
  const executor = new CachePushStepExecutor();
315
356
  const result = await executor.execute({ technology: 'cocoapods' }, {}, testConfig);
316
- expect(result.script).toContain('shlock -f "$LOCK_FILE" -p $$');
317
- expect(result.script).toContain('LOCK_FILE="$CACHE_FILE.lock"');
318
- expect(result.script).toContain('rm -f "$LOCK_FILE"');
319
- // Lock timeout after 30 retries
320
- expect(result.script).toContain('__ci_lock_tries');
321
- expect(result.script).toContain('-ge 30');
322
- });
323
- test('should use shlock locking for manual cache_key push', async () => {
357
+ expect(result.script).not.toContain('shlock');
358
+ expect(result.script).not.toContain('LOCK_FILE');
359
+ expect(result.script).not.toContain('__ci_lock_tries');
360
+ });
361
+ test('should not use shlock for manual cache_key push', async () => {
324
362
  const executor = new CachePushStepExecutor();
325
363
  const result = await executor.execute({ cache_key: 'my-key', cache_paths: ['build'] }, {}, testConfig);
326
- expect(result.script).toContain('shlock -f "$LOCK_FILE" -p $$');
327
- expect(result.script).toContain('rm -f "$LOCK_FILE"');
364
+ expect(result.script).not.toContain('shlock');
365
+ expect(result.script).not.toContain('LOCK_FILE');
328
366
  });
329
367
  test('should use atomic write (tmp + mv) for preset push', async () => {
330
368
  const executor = new CachePushStepExecutor();
@@ -332,6 +370,12 @@ describe('Step Implementations', () => {
332
370
  expect(result.script).toContain('> "$CACHE_FILE.tmp"');
333
371
  expect(result.script).toContain('mv "$CACHE_FILE.tmp" "$CACHE_FILE"');
334
372
  });
373
+ test('should use atomic write (tmp + mv) for manual cache_key push', async () => {
374
+ const executor = new CachePushStepExecutor();
375
+ const result = await executor.execute({ cache_key: 'my-key', cache_paths: ['build'] }, {}, testConfig);
376
+ expect(result.script).toContain('> "$CACHE_FILE.tmp"');
377
+ expect(result.script).toContain('mv "$CACHE_FILE.tmp" "$CACHE_FILE"');
378
+ });
335
379
  });
336
380
  describe('XcodeBuildStepExecutor', () => {
337
381
  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.9",
3
+ "version": "1.5.1",
4
4
  "description": "CI Build CLI — local pipeline orchestration and validation",
5
5
  "type": "module",
6
6
  "main": "dist/cli.cjs",