@invarn/cibuild 1.5.0 → 1.5.2

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;YA4JzF,iBAAiB;CA0GhC"}
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;CA2KhC;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;CA2HhC"}
@@ -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,49 @@ 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
+ // Age cap: a fallback older than 30d likely carries obsolete .konan
292
+ // installs or Gradle entries from a pre-wrapper-bump world. Go cold
293
+ // rather than seed from ancient state. Exact-key hits are unaffected.
294
+ commands.push(' __ci_fb_age_days=$(( ($(date +%s) - $(stat -f %m "$__ci_fb")) / 86400 ))');
295
+ commands.push(' if [ "$__ci_fb_age_days" -le 30 ]; then');
296
+ commands.push(' echo "Cache fallback ($__ci_fb_src): using prior tarball $__ci_fb_key (${__ci_fb_age_days}d old)"');
297
+ if (isDebugMode) {
298
+ commands.push(' echo "Fallback size: $(du -h "$__ci_fb" | cut -f1)"');
299
+ }
300
+ commands.push(' zstd -dc "$__ci_fb" | tar -xf - -C /');
301
+ commands.push(' echo "CACHE_SOURCE=fallback_$__ci_fb_src CACHE_KEY=$CACHE_KEY FALLBACK_KEY=$__ci_fb_key"');
302
+ commands.push(' else');
303
+ commands.push(' echo "Cache fallback candidate $__ci_fb_key is ${__ci_fb_age_days}d old, exceeds 30d cap — going cold"');
304
+ commands.push(' echo "CACHE_SOURCE=cold CACHE_KEY=$CACHE_KEY FALLBACK_REJECTED=$__ci_fb_key"');
305
+ commands.push(' fi');
306
+ commands.push(' else');
307
+ // 4. Cold miss — no exact key, no fallback candidate
308
+ commands.push(' echo "No cache found for key: $CACHE_KEY"');
309
+ commands.push(' echo "CACHE_SOURCE=cold CACHE_KEY=$CACHE_KEY"');
310
+ commands.push(' fi');
254
311
  commands.push('fi');
255
312
  if (isDebugMode) {
256
313
  commands.push('echo "Restored paths:"');
@@ -424,6 +481,10 @@ export class CachePushStepExecutor extends BaseStepExecutor {
424
481
  commands.push(' find . -name "$(basename "$1")" -not -path "*/DerivedData/*" -not -path "*/.build/*" 2>/dev/null | head -1');
425
482
  commands.push('}');
426
483
  commands.push('');
484
+ // Project scope: include a stable short hash of the git remote in every
485
+ // cache key so different repos on the same runner don't share tarballs.
486
+ commands.push(...emitProjectScopeCommands());
487
+ commands.push('');
427
488
  if (chain.length === 1 && chain[0].fingerprint && chain[0].fingerprint.length > 0) {
428
489
  commands.push(...emitFingerprintKeyCommands(chain[0]));
429
490
  }
@@ -432,10 +493,10 @@ export class CachePushStepExecutor extends BaseStepExecutor {
432
493
  commands.push(`LOCKFILE=$(__ci_find_lockfile "${this.escapeBash(preset.lockfile)}")`);
433
494
  commands.push('if [ -n "$LOCKFILE" ] && [ -f "$LOCKFILE" ]; then');
434
495
  commands.push(` CHECKSUM=$(shasum -a 256 "$LOCKFILE" | cut -d ' ' -f1 | head -c 16)`);
435
- commands.push(` CACHE_KEY="${this.escapeBash(preset.keyPrefix)}-\${CHECKSUM}"`);
496
+ commands.push(` CACHE_KEY="${this.escapeBash(preset.keyPrefix)}-\${__ci_proj}-\${CHECKSUM}"`);
436
497
  commands.push('else');
437
498
  commands.push(` echo "Warning: ${this.escapeBash(preset.lockfile)} not found, using fallback cache key"`);
438
- commands.push(` CACHE_KEY="${this.escapeBash(preset.keyPrefix)}-no-lockfile"`);
499
+ commands.push(` CACHE_KEY="${this.escapeBash(preset.keyPrefix)}-\${__ci_proj}-no-lockfile"`);
439
500
  commands.push('fi');
440
501
  }
441
502
  else {
@@ -446,11 +507,11 @@ export class CachePushStepExecutor extends BaseStepExecutor {
446
507
  commands.push(`${cond} [ -n "$__ci_lockfile_candidate" ] && [ -f "$__ci_lockfile_candidate" ]; then`);
447
508
  commands.push(' LOCKFILE="$__ci_lockfile_candidate"');
448
509
  commands.push(` CHECKSUM=$(shasum -a 256 "$LOCKFILE" | cut -d ' ' -f1 | head -c 16)`);
449
- commands.push(` CACHE_KEY="${this.escapeBash(preset.keyPrefix)}-\${CHECKSUM}"`);
510
+ commands.push(` CACHE_KEY="${this.escapeBash(preset.keyPrefix)}-\${__ci_proj}-\${CHECKSUM}"`);
450
511
  }
451
512
  commands.push('else');
452
513
  commands.push(` echo "Warning: no lockfile found, using fallback cache key"`);
453
- commands.push(` CACHE_KEY="${this.escapeBash(chain[0].keyPrefix)}-no-lockfile"`);
514
+ commands.push(` CACHE_KEY="${this.escapeBash(chain[0].keyPrefix)}-\${__ci_proj}-no-lockfile"`);
454
515
  commands.push('fi');
455
516
  }
456
517
  commands.push('');
@@ -491,6 +552,18 @@ export class CachePushStepExecutor extends BaseStepExecutor {
491
552
  if (isDebugMode) {
492
553
  commands.push(' echo "Cache size: $(du -h "$CACHE_FILE" | cut -f1)"');
493
554
  }
555
+ // Retention: keep 5 newest tarballs per <keyPrefix>-<projectId> scope,
556
+ // delete older ones to bound disk usage. Runs inline after every push;
557
+ // no cron required.
558
+ commands.push(' __ci_ret_scope="${CACHE_KEY%-*}"');
559
+ commands.push(' __ci_ret_old=$(ls -t "$CACHE_DIR"/"$__ci_ret_scope"-*.tar.zst 2>/dev/null | tail -n +6)');
560
+ commands.push(' if [ -n "$__ci_ret_old" ]; then');
561
+ commands.push(' __ci_ret_count=$(printf "%s\\n" "$__ci_ret_old" | wc -l | tr -d " ")');
562
+ commands.push(' echo "Retention: removing $__ci_ret_count older tarball(s) from scope $__ci_ret_scope"');
563
+ commands.push(' printf "%s\\n" "$__ci_ret_old" | while IFS= read -r __ci_ret_f; do');
564
+ commands.push(' [ -f "$__ci_ret_f" ] && rm -f "$__ci_ret_f"');
565
+ commands.push(' done');
566
+ commands.push(' fi');
494
567
  commands.push(' else');
495
568
  commands.push(' echo "Warning: Failed to create cache file"');
496
569
  commands.push(' fi');
@@ -218,6 +218,56 @@ 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
+ });
262
+ test('should enforce 30-day age cap on fallback, going cold when older', async () => {
263
+ const executor = new CachePullStepExecutor();
264
+ const result = await executor.execute({ technology: 'kmm' }, {}, testConfig);
265
+ // Computes age in days from stat mtime, rejects if over 30
266
+ expect(result.script).toContain('__ci_fb_age_days=$(( ($(date +%s) - $(stat -f %m "$__ci_fb")) / 86400 ))');
267
+ expect(result.script).toContain('if [ "$__ci_fb_age_days" -le 30 ]');
268
+ expect(result.script).toContain('exceeds 30d cap — going cold');
269
+ expect(result.script).toContain('FALLBACK_REJECTED=');
270
+ });
221
271
  });
222
272
  describe('CachePushStepExecutor', () => {
223
273
  test('should generate cache push script', async () => {
@@ -329,6 +379,21 @@ describe('Step Implementations', () => {
329
379
  expect(result.script).toContain('> "$CACHE_FILE.tmp"');
330
380
  expect(result.script).toContain('mv "$CACHE_FILE.tmp" "$CACHE_FILE"');
331
381
  });
382
+ test('should sweep retention (keep 5 newest per scope) after successful push', async () => {
383
+ const executor = new CachePushStepExecutor();
384
+ const result = await executor.execute({ technology: 'kmm' }, {}, testConfig);
385
+ // Scope = prefix + projectId; retention globs that scope and keeps 5 newest
386
+ expect(result.script).toContain('__ci_ret_scope="${CACHE_KEY%-*}"');
387
+ expect(result.script).toContain('ls -t "$CACHE_DIR"/"$__ci_ret_scope"-*.tar.zst');
388
+ expect(result.script).toContain('tail -n +6');
389
+ expect(result.script).toContain('rm -f "$__ci_ret_f"');
390
+ });
391
+ test('should not run retention for manual cache_key push (no scope)', async () => {
392
+ const executor = new CachePushStepExecutor();
393
+ const result = await executor.execute({ cache_key: 'my-key', cache_paths: ['build'] }, {}, testConfig);
394
+ expect(result.script).not.toContain('__ci_ret_scope');
395
+ expect(result.script).not.toContain('tail -n +6');
396
+ });
332
397
  test('should use atomic write (tmp + mv) for manual cache_key push', async () => {
333
398
  const executor = new CachePushStepExecutor();
334
399
  const result = await executor.execute({ cache_key: 'my-key', cache_paths: ['build'] }, {}, testConfig);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invarn/cibuild",
3
- "version": "1.5.0",
3
+ "version": "1.5.2",
4
4
  "description": "CI Build CLI — local pipeline orchestration and validation",
5
5
  "type": "module",
6
6
  "main": "dist/cli.cjs",