@invarn/cibuild 1.5.4 → 1.5.6

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,11 +1,15 @@
1
1
  import type { YAMLPipeline } from "../yaml/types.js";
2
2
  import type { CIConfig } from "../types.js";
3
3
  /**
4
- * True only when stdin AND stdout are real TTYs. Sandboxed runs
5
- * (Tart guest via `tart exec`, CI pipelines, redirected shells) all
6
- * fail this check and therefore must never reach an interactive
7
- * prompt hanging on an unanswerable question turns a clear error
8
- * into a silent timeout.
4
+ * True only when stdin AND stdout are real TTYs AND the caller has not
5
+ * explicitly opted out of interactivity.
6
+ *
7
+ * `process.stdin.isTTY` is unreliable as a sole signal: Tart guest
8
+ * sessions opened via `tart exec` attach a pseudo-TTY, so the check
9
+ * passes and the prompt runs — only for nobody to ever answer it. The
10
+ * runner sets `CIBUILD_NON_INTERACTIVE=1` (and the generic `CI=1` env
11
+ * is also honored) to make the override unambiguous regardless of
12
+ * whether the shell looks interactive.
9
13
  */
10
14
  export declare function isInteractiveTTY(): boolean;
11
15
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"prompts.d.ts","sourceRoot":"","sources":["../../../src/shared/prompts.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AACrD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAE5C;;;;;;GAMG;AACH,wBAAgB,gBAAgB,IAAI,OAAO,CAE1C;AAED;;;;GAIG;AACH,wBAAsB,iBAAiB,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CA0BxF;AAED;;;;;;;GAOG;AACH,wBAAsB,yBAAyB,CAC7C,YAAY,EAAE,YAAY,EAC1B,YAAY,EAAE,MAAM,EACpB,MAAM,EAAE,QAAQ,EAChB,YAAY,EAAE,MAAM,GACnB,OAAO,CAAC,OAAO,CAAC,CAwFlB"}
1
+ {"version":3,"file":"prompts.d.ts","sourceRoot":"","sources":["../../../src/shared/prompts.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AACrD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAE5C;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,IAAI,OAAO,CAU1C;AAED;;;;GAIG;AACH,wBAAsB,iBAAiB,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CA0BxF;AAED;;;;;;;GAOG;AACH,wBAAsB,yBAAyB,CAC7C,YAAY,EAAE,YAAY,EAC1B,YAAY,EAAE,MAAM,EACpB,MAAM,EAAE,QAAQ,EAChB,YAAY,EAAE,MAAM,GACnB,OAAO,CAAC,OAAO,CAAC,CAwFlB"}
@@ -3,13 +3,23 @@ import { StepValidator, formatValidationResult } from "../yaml/step-validator.js
3
3
  import { MissingEnvHandler } from "../yaml/missing-env-handler.js";
4
4
  import { MissingEnvironmentVariableError } from "../yaml/env-resolver.js";
5
5
  /**
6
- * True only when stdin AND stdout are real TTYs. Sandboxed runs
7
- * (Tart guest via `tart exec`, CI pipelines, redirected shells) all
8
- * fail this check and therefore must never reach an interactive
9
- * prompt hanging on an unanswerable question turns a clear error
10
- * into a silent timeout.
6
+ * True only when stdin AND stdout are real TTYs AND the caller has not
7
+ * explicitly opted out of interactivity.
8
+ *
9
+ * `process.stdin.isTTY` is unreliable as a sole signal: Tart guest
10
+ * sessions opened via `tart exec` attach a pseudo-TTY, so the check
11
+ * passes and the prompt runs — only for nobody to ever answer it. The
12
+ * runner sets `CIBUILD_NON_INTERACTIVE=1` (and the generic `CI=1` env
13
+ * is also honored) to make the override unambiguous regardless of
14
+ * whether the shell looks interactive.
11
15
  */
12
16
  export function isInteractiveTTY() {
17
+ if (process.env.CIBUILD_NON_INTERACTIVE === '1' ||
18
+ process.env.CIBUILD_NON_INTERACTIVE === 'true' ||
19
+ process.env.CI === '1' ||
20
+ process.env.CI === 'true') {
21
+ return false;
22
+ }
13
23
  return Boolean(process.stdin.isTTY && process.stdout.isTTY);
14
24
  }
15
25
  /**
@@ -34,12 +34,24 @@ export declare class StepValidator {
34
34
  private validateRequirement;
35
35
  /**
36
36
  * Extracts all $VAR and ${VAR} variable names referenced in an object recursively.
37
+ *
38
+ * `${{ secrets.X }}` / `${{ vars.X }}` references are deliberately ignored:
39
+ * those are resolved by the runner-side interpolator before cibuild sees
40
+ * the YAML (or by the dashboard's secrets-resolver on dispatch). Without
41
+ * this filter the inner regex would capture `{ secrets.X ` as if it were
42
+ * a `${VAR}` reference, producing garbled variable names in the
43
+ * missing-env error output.
37
44
  */
38
45
  private extractVariableReferences;
39
46
  /**
40
47
  * Replaces all unresolved $VAR and ${VAR} references with empty string.
41
48
  * Used as a lenient fallback when full interpolation fails, so that
42
49
  * getValidationRequirements can detect truly-missing variables correctly.
50
+ *
51
+ * `${{ ... }}` refs are left intact — they belong to the runner-side
52
+ * interpolator / dashboard secrets-resolver layer, not to shell-level
53
+ * variable substitution. Stripping them here would destroy the YAML for
54
+ * any downstream consumer that still expects to see them.
43
55
  */
44
56
  private stripUnresolvedVars;
45
57
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"step-validator.d.ts","sourceRoot":"","sources":["../../../src/yaml/step-validator.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAMH,OAAO,KAAK,EAAE,YAAY,EAA4B,MAAM,YAAY,CAAC;AACzE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,KAAK,EAGV,wBAAwB,EAGzB,MAAM,uBAAuB,CAAC;AAwB/B;;;;GAIG;AACH,qBAAa,aAAa;IACxB,OAAO,CAAC,QAAQ,CAAe;IAC/B,OAAO,CAAC,QAAQ,CAAe;IAC/B,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,MAAM,CAAW;IACzB,OAAO,CAAC,WAAW,CAAc;IACjC,OAAO,CAAC,YAAY,CAAC,CAAS;IAG9B,OAAO,CAAC,gBAAgB,CAAoE;IAG5F,OAAO,CAAC,mBAAmB,CAA6B;gBAGtD,QAAQ,EAAE,YAAY,EACtB,YAAY,EAAE,MAAM,EACpB,MAAM,EAAE,QAAQ,EAChB,YAAY,CAAC,EAAE,MAAM;IAwBvB;;;OAGG;IACG,gBAAgB,IAAI,OAAO,CAAC,wBAAwB,CAAC;IA8M3D;;OAEG;YACW,mBAAmB;IAsDjC;;OAEG;IACH,OAAO,CAAC,yBAAyB;IAoBjC;;;;OAIG;IACH,OAAO,CAAC,mBAAmB;IAiB3B;;OAEG;IACH,OAAO,CAAC,SAAS;CA8BlB;AAED;;GAEG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,wBAAwB,GAAG,MAAM,CA4D/E"}
1
+ {"version":3,"file":"step-validator.d.ts","sourceRoot":"","sources":["../../../src/yaml/step-validator.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAMH,OAAO,KAAK,EAAE,YAAY,EAA4B,MAAM,YAAY,CAAC;AACzE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,KAAK,EAGV,wBAAwB,EAGzB,MAAM,uBAAuB,CAAC;AAwB/B;;;;GAIG;AACH,qBAAa,aAAa;IACxB,OAAO,CAAC,QAAQ,CAAe;IAC/B,OAAO,CAAC,QAAQ,CAAe;IAC/B,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,MAAM,CAAW;IACzB,OAAO,CAAC,WAAW,CAAc;IACjC,OAAO,CAAC,YAAY,CAAC,CAAS;IAG9B,OAAO,CAAC,gBAAgB,CAAoE;IAG5F,OAAO,CAAC,mBAAmB,CAA6B;gBAGtD,QAAQ,EAAE,YAAY,EACtB,YAAY,EAAE,MAAM,EACpB,MAAM,EAAE,QAAQ,EAChB,YAAY,CAAC,EAAE,MAAM;IAwBvB;;;OAGG;IACG,gBAAgB,IAAI,OAAO,CAAC,wBAAwB,CAAC;IA8M3D;;OAEG;YACW,mBAAmB;IAsDjC;;;;;;;;;OASG;IACH,OAAO,CAAC,yBAAyB;IAsBjC;;;;;;;;;OASG;IACH,OAAO,CAAC,mBAAmB;IAoB3B;;OAEG;IACH,OAAO,CAAC,SAAS;CA8BlB;AAED;;GAEG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,wBAAwB,GAAG,MAAM,CA4D/E"}
@@ -285,13 +285,22 @@ export class StepValidator {
285
285
  }
286
286
  /**
287
287
  * Extracts all $VAR and ${VAR} variable names referenced in an object recursively.
288
+ *
289
+ * `${{ secrets.X }}` / `${{ vars.X }}` references are deliberately ignored:
290
+ * those are resolved by the runner-side interpolator before cibuild sees
291
+ * the YAML (or by the dashboard's secrets-resolver on dispatch). Without
292
+ * this filter the inner regex would capture `{ secrets.X ` as if it were
293
+ * a `${VAR}` reference, producing garbled variable names in the
294
+ * missing-env error output.
288
295
  */
289
296
  extractVariableReferences(obj) {
290
297
  const vars = new Set();
291
298
  if (typeof obj === 'string') {
299
+ // Strip `${{ ... }}` refs first so they can't be mis-matched as `${VAR}`.
300
+ const sanitized = obj.replace(/\$\{\{[^}]*\}\}/g, '');
292
301
  const pattern = /\$\{([^}]+)\}|\$([A-Z_][A-Z_0-9]*)/g;
293
302
  let match;
294
- while ((match = pattern.exec(obj)) !== null) {
303
+ while ((match = pattern.exec(sanitized)) !== null) {
295
304
  vars.add(match[1] || match[2]);
296
305
  }
297
306
  }
@@ -313,10 +322,18 @@ export class StepValidator {
313
322
  * Replaces all unresolved $VAR and ${VAR} references with empty string.
314
323
  * Used as a lenient fallback when full interpolation fails, so that
315
324
  * getValidationRequirements can detect truly-missing variables correctly.
325
+ *
326
+ * `${{ ... }}` refs are left intact — they belong to the runner-side
327
+ * interpolator / dashboard secrets-resolver layer, not to shell-level
328
+ * variable substitution. Stripping them here would destroy the YAML for
329
+ * any downstream consumer that still expects to see them.
316
330
  */
317
331
  stripUnresolvedVars(obj) {
318
332
  if (typeof obj === 'string') {
319
- return obj.replace(/\$\{[^}]+\}|\$[A-Z_][A-Z_0-9]*/g, '');
333
+ // `(?!\{)` prevents `\$\{[^}]+\}` from matching a leading `${` of
334
+ // `${{ ... }}` and stripping the inner content. Plain `$VAR` and
335
+ // `${VAR}` are still stripped as before.
336
+ return obj.replace(/\$\{(?!\{)[^}]+\}|\$[A-Z_][A-Z_0-9]*/g, '');
320
337
  }
321
338
  if (Array.isArray(obj)) {
322
339
  return obj.map((item) => this.stripUnresolvedVars(item));
@@ -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;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;CA8KhC;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;CA6HhC"}
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;CAsLhC;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;CA2IhC"}
@@ -252,6 +252,10 @@ export class CachePullStepExecutor extends BaseStepExecutor {
252
252
  commands.push(' echo "Cache file size: $(du -h "$CACHE_FILE" | cut -f1)"');
253
253
  }
254
254
  commands.push(' zstd -dc "$CACHE_FILE" | tar -xf - -C /');
255
+ // LRU bump: lift this tarball to the top of `ls -t` so retention's age
256
+ // cap counts last-used time, not last-written. Without this, a tarball
257
+ // hit daily would still be reaped on its 31st day from creation.
258
+ commands.push(' touch "$CACHE_FILE"');
255
259
  commands.push(' echo "CACHE_SOURCE=local CACHE_KEY=$CACHE_KEY"');
256
260
  // 2. Peer hit — tee to warm local cache while extracting in one pass
257
261
  if (peerCacheDir) {
@@ -301,6 +305,10 @@ export class CachePullStepExecutor extends BaseStepExecutor {
301
305
  commands.push(' echo "Fallback size: $(du -h "$__ci_fb" | cut -f1)"');
302
306
  }
303
307
  commands.push(' zstd -dc "$__ci_fb" | tar -xf - -C /');
308
+ // LRU bump for the fallback tarball — we restored from it, so it earns
309
+ // its place at the top of the retention sort even if the exact-key
310
+ // tarball is the one that gets created in this run's cache-push.
311
+ commands.push(' touch "$__ci_fb"');
304
312
  commands.push(' echo "CACHE_SOURCE=fallback_$__ci_fb_src CACHE_KEY=$CACHE_KEY FALLBACK_KEY=$__ci_fb_key"');
305
313
  commands.push(' else');
306
314
  commands.push(' echo "Cache fallback candidate $__ci_fb_key is ${__ci_fb_age_days}d old, exceeds 30d cap — going cold"');
@@ -541,34 +549,48 @@ export class CachePushStepExecutor extends BaseStepExecutor {
541
549
  commands.push('fi');
542
550
  }
543
551
  commands.push('');
544
- // Skip if cache already exists for this key
545
- commands.push('if [ -f "$CACHE_FILE" ]; then');
546
- commands.push(' echo "Cache already up to date for key: $CACHE_KEY"');
547
- commands.push('elif [ ${#PATHS_TO_CACHE[@]} -gt 0 ]; then');
552
+ // Always re-archive on success: ~/.gradle/caches and friends are
553
+ // write-through accumulators same fingerprint key does NOT imply
554
+ // unchanged contents (Gradle's build-cache-1 keeps appending task
555
+ // outputs as sources change). Skipping here would freeze the tarball
556
+ // at first-mint and let it drift further from current state on every
557
+ // build. Atomic .tmp + mv overwrites any prior tarball under this key.
558
+ commands.push('if [ ${#PATHS_TO_CACHE[@]} -gt 0 ]; then');
548
559
  commands.push(' echo "Caching ${#PATHS_TO_CACHE[@]} path(s)..."');
549
- // Atomic write: compress to .tmp, then mv. No lock needed — runs are
550
- // serialized per runner and cross-writer races resolve to last-writer-wins
551
- // without corruption. shlock was flaky on the virtiofs cache mount.
552
560
  commands.push(' tar -cf - "${PATHS_TO_CACHE[@]}" 2>/dev/null | zstd -3 > "$CACHE_FILE.tmp" && mv "$CACHE_FILE.tmp" "$CACHE_FILE" || true');
553
561
  commands.push(' if [ -f "$CACHE_FILE" ]; then');
554
562
  commands.push(' echo "Cache created successfully"');
555
563
  if (isDebugMode) {
556
564
  commands.push(' echo "Cache size: $(du -h "$CACHE_FILE" | cut -f1)"');
557
565
  }
558
- // Retention: keep 5 newest tarballs per <keyPrefix>-<projectId> scope,
559
- // delete older ones to bound disk usage. Runs inline after every push;
560
- // no cron required.
566
+ // Retention per-scope: (1) keep N newest by mtime touched on hit in
567
+ // cache-pull so this is LRU, not strictly creation-order; (2) delete
568
+ // anything past the age cap regardless of position; (3) enforce a size
569
+ // budget, oldest-out. Two passes are required: the size pass needs the
570
+ // post-cleanup set, otherwise its running sum counts tarballs that the
571
+ // count/age pass is about to delete and over-evicts.
561
572
  commands.push(' __ci_ret_scope="${CACHE_KEY%-*}"');
562
- // `|| true` guards against pipefail when ls matches nothing (first push
563
- // for a new scope) — otherwise the step would silently exit non-zero.
564
- commands.push(' __ci_ret_old=$(ls -t "$CACHE_DIR"/"$__ci_ret_scope"-*.tar.zst 2>/dev/null | tail -n +6 || true)');
565
- commands.push(' if [ -n "$__ci_ret_old" ]; then');
566
- commands.push(' __ci_ret_count=$(printf "%s\\n" "$__ci_ret_old" | wc -l | tr -d " ")');
567
- commands.push(' echo "Retention: removing $__ci_ret_count older tarball(s) from scope $__ci_ret_scope"');
568
- commands.push(' printf "%s\\n" "$__ci_ret_old" | while IFS= read -r __ci_ret_f; do');
569
- commands.push(' [ -f "$__ci_ret_f" ] && rm -f "$__ci_ret_f"');
570
- commands.push(' done');
571
- commands.push(' fi');
573
+ commands.push(' __ci_ret_count_cap=5');
574
+ commands.push(' __ci_ret_age_cap=$((30 * 86400))');
575
+ commands.push(' __ci_ret_size_cap_kb="${CIBUILD_CACHE_SCOPE_BUDGET_KB:-10485760}"');
576
+ commands.push(' __ci_ret_now=$(date +%s)');
577
+ commands.push(' __ci_ret_idx=0');
578
+ commands.push(' while IFS= read -r __ci_ret_f; do');
579
+ commands.push(' __ci_ret_idx=$((__ci_ret_idx + 1))');
580
+ commands.push(' __ci_ret_age=$(( __ci_ret_now - $(stat -f %m "$__ci_ret_f" 2>/dev/null || echo "$__ci_ret_now") ))');
581
+ commands.push(' if [ "$__ci_ret_idx" -gt "$__ci_ret_count_cap" ] || [ "$__ci_ret_age" -gt "$__ci_ret_age_cap" ]; then');
582
+ commands.push(' rm -f "$__ci_ret_f"');
583
+ commands.push(' fi');
584
+ commands.push(' done < <(ls -t "$CACHE_DIR"/"$__ci_ret_scope"-*.tar.zst 2>/dev/null)');
585
+ commands.push(' __ci_ret_running_kb=0');
586
+ commands.push(' while IFS= read -r __ci_ret_f; do');
587
+ commands.push(' __ci_ret_size=$(du -k "$__ci_ret_f" 2>/dev/null | cut -f1)');
588
+ commands.push(' __ci_ret_size=${__ci_ret_size:-0}');
589
+ commands.push(' __ci_ret_running_kb=$((__ci_ret_running_kb + __ci_ret_size))');
590
+ commands.push(' if [ "$__ci_ret_running_kb" -gt "$__ci_ret_size_cap_kb" ]; then');
591
+ commands.push(' rm -f "$__ci_ret_f"');
592
+ commands.push(' fi');
593
+ commands.push(' done < <(ls -t "$CACHE_DIR"/"$__ci_ret_scope"-*.tar.zst 2>/dev/null)');
572
594
  commands.push(' else');
573
595
  commands.push(' echo "Warning: Failed to create cache file"');
574
596
  commands.push(' fi');
@@ -379,20 +379,21 @@ describe('Step Implementations', () => {
379
379
  expect(result.script).toContain('> "$CACHE_FILE.tmp"');
380
380
  expect(result.script).toContain('mv "$CACHE_FILE.tmp" "$CACHE_FILE"');
381
381
  });
382
- test('should sweep retention (keep 5 newest per scope) after successful push', async () => {
382
+ test('should sweep retention (count + age + size budget) after successful preset push', async () => {
383
383
  const executor = new CachePushStepExecutor();
384
384
  const result = await executor.execute({ technology: 'kmm' }, {}, testConfig);
385
- // Scope = prefix + projectId; retention globs that scope and keeps 5 newest
386
385
  expect(result.script).toContain('__ci_ret_scope="${CACHE_KEY%-*}"');
387
386
  expect(result.script).toContain('ls -t "$CACHE_DIR"/"$__ci_ret_scope"-*.tar.zst');
388
- expect(result.script).toContain('tail -n +6');
387
+ expect(result.script).toContain('__ci_ret_count_cap=5');
388
+ expect(result.script).toContain('__ci_ret_age_cap=$((30 * 86400))');
389
+ expect(result.script).toContain('CIBUILD_CACHE_SCOPE_BUDGET_KB');
389
390
  expect(result.script).toContain('rm -f "$__ci_ret_f"');
390
391
  });
391
392
  test('should not run retention for manual cache_key push (no scope)', async () => {
392
393
  const executor = new CachePushStepExecutor();
393
394
  const result = await executor.execute({ cache_key: 'my-key', cache_paths: ['build'] }, {}, testConfig);
394
395
  expect(result.script).not.toContain('__ci_ret_scope');
395
- expect(result.script).not.toContain('tail -n +6');
396
+ expect(result.script).not.toContain('__ci_ret_count_cap');
396
397
  });
397
398
  test('should use atomic write (tmp + mv) for manual cache_key push', async () => {
398
399
  const executor = new CachePushStepExecutor();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invarn/cibuild",
3
- "version": "1.5.4",
3
+ "version": "1.5.6",
4
4
  "description": "CI Build CLI — local pipeline orchestration and validation",
5
5
  "type": "module",
6
6
  "main": "dist/cli.cjs",