@invarn/cibuild 1.9.2 → 1.9.4

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;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"}
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;AAmFF;;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;YA0GzF,iBAAiB;CA8LhC;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;CAoHhC"}
@@ -86,6 +86,26 @@ function resolvePresetChain(technology) {
86
86
  }
87
87
  return chain;
88
88
  }
89
+ /**
90
+ * Emits the daemon exact-key pull branch (ADR 0001, Phase 3b). On a local
91
+ * miss, GET `<key>.tar.zst` from this runner's cache daemon ($CIBUILD_CACHE_DAEMON,
92
+ * injected by the runner). The daemon fans out to tailnet peers, persists the
93
+ * hit to ~/cache, and streams it back — so we pipe straight to extraction
94
+ * without re-writing it on the guest. The optional per-build bearer token
95
+ * ($CIBUILD_CACHE_TOKEN) is sent only when set. Inert when the env var is
96
+ * empty, so the same script runs with or without a daemon.
97
+ */
98
+ function emitDaemonPullBranch() {
99
+ return [
100
+ 'elif [ -n "$CIBUILD_CACHE_DAEMON" ] && curl -fsS ${CIBUILD_CACHE_TOKEN:+-H "Authorization: Bearer $CIBUILD_CACHE_TOKEN"} "$CIBUILD_CACHE_DAEMON/cache/$CACHE_KEY.tar.zst" | zstd -dc | tar -xf - -C /; then',
101
+ ' echo "Cache found (daemon), extracting..."',
102
+ // LRU bump the tarball the daemon just persisted to ~/cache (same dir as
103
+ // the --dir mount); tolerate its absence in case the daemon served it
104
+ // without a local copy.
105
+ ' touch "$CACHE_FILE" 2>/dev/null || true',
106
+ ' echo "CACHE_SOURCE=daemon CACHE_KEY=$CACHE_KEY"',
107
+ ];
108
+ }
89
109
  /**
90
110
  * Cache pull step executor
91
111
  * Restores cached files from cache directory
@@ -133,7 +153,8 @@ export class CachePullStepExecutor extends BaseStepExecutor {
133
153
  // Generate cache file name based on cache key
134
154
  commands.push('');
135
155
  commands.push('# Generate cache file path');
136
- commands.push(`CACHE_FILE="$CACHE_DIR/${this.escapeBash(cacheKey)}.tar.zst"`);
156
+ commands.push(`CACHE_KEY="${this.escapeBash(cacheKey)}"`);
157
+ commands.push('CACHE_FILE="$CACHE_DIR/$CACHE_KEY.tar.zst"');
137
158
  if (isDebugMode) {
138
159
  commands.push('echo "Debug mode enabled"');
139
160
  commands.push('echo "Cache file: $CACHE_FILE"');
@@ -155,6 +176,8 @@ export class CachePullStepExecutor extends BaseStepExecutor {
155
176
  }
156
177
  commands.push(' zstd -dc "$CACHE_FILE" | tar -xf - -C /');
157
178
  commands.push(' echo "CACHE_SOURCE=local"');
179
+ // Daemon hit (ADR 0001) — see emitDaemonPullBranch.
180
+ commands.push(...emitDaemonPullBranch());
158
181
  // Peer fallback
159
182
  if (peerCacheDir) {
160
183
  commands.push('elif [ -f "$PEER_CACHE_FILE" ] 2>/dev/null; then');
@@ -257,6 +280,13 @@ export class CachePullStepExecutor extends BaseStepExecutor {
257
280
  // hit daily would still be reaped on its 31st day from creation.
258
281
  commands.push(' touch "$CACHE_FILE"');
259
282
  commands.push(' echo "CACHE_SOURCE=local CACHE_KEY=$CACHE_KEY"');
283
+ // 1b. Daemon hit (ADR 0001) — on a local miss, ask this runner's cache
284
+ // daemon for the exact key. The daemon resolves it from a tailnet peer
285
+ // and atomically persists it to ~/cache (the same dir as the --dir mount),
286
+ // so we stream the response straight to extraction with no redundant
287
+ // guest-side write. Gated at runtime on the env var the runner injects, so
288
+ // the generated script is inert when no daemon is configured.
289
+ commands.push(...emitDaemonPullBranch());
260
290
  // 2. Peer hit — tee to warm local cache while extracting in one pass
261
291
  if (peerCacheDir) {
262
292
  commands.push('elif [ -f "$PEER_CACHE_FILE" ] 2>/dev/null; then');
@@ -563,34 +593,11 @@ export class CachePushStepExecutor extends BaseStepExecutor {
563
593
  if (isDebugMode) {
564
594
  commands.push(' echo "Cache size: $(du -h "$CACHE_FILE" | cut -f1)"');
565
595
  }
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.
572
- commands.push(' __ci_ret_scope="${CACHE_KEY%-*}"');
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)');
596
+ // Retention is no longer applied here (ADR 0002 / CBR-6). It moved into the
597
+ // long-lived peer cache daemon, which owns ~/cache and — unlike the guest —
598
+ // knows (via the manager) which scopes this runner OWNS and must never evict.
599
+ // A guest-side prune would have to run blind to ownership and could reap the
600
+ // fleet's single durable copy, so the daemon owns the whole policy now.
594
601
  commands.push(' else');
595
602
  commands.push(' echo "Warning: Failed to create cache file"');
596
603
  commands.push(' fi');
@@ -268,6 +268,42 @@ describe('Step Implementations', () => {
268
268
  expect(result.script).toContain('exceeds 30d cap — going cold');
269
269
  expect(result.script).toContain('FALLBACK_REJECTED=');
270
270
  });
271
+ test('routes a preset pull miss through the cache daemon when CIBUILD_CACHE_DAEMON is set', async () => {
272
+ const executor = new CachePullStepExecutor();
273
+ const result = await executor.execute({ technology: 'kmm' }, {}, testConfig);
274
+ // Daemon branch is gated at runtime on the injected env var (so the same
275
+ // generated script is a no-op when the runner doesn't inject a daemon).
276
+ expect(result.script).toContain('[ -n "$CIBUILD_CACHE_DAEMON" ]');
277
+ // Exact-key GET against the per-runner daemon.
278
+ expect(result.script).toContain('"$CIBUILD_CACHE_DAEMON/cache/$CACHE_KEY.tar.zst"');
279
+ // Stream-extract: the daemon already persisted the tarball to ~/cache via
280
+ // resolveMiss, so we pipe straight to extraction with no redundant write.
281
+ expect(result.script).toContain('CACHE_SOURCE=daemon CACHE_KEY=$CACHE_KEY');
282
+ });
283
+ test('sends the per-build bearer token only when CIBUILD_CACHE_TOKEN is set', async () => {
284
+ const executor = new CachePullStepExecutor();
285
+ const result = await executor.execute({ technology: 'kmm' }, {}, testConfig);
286
+ // ${VAR:+...} expands the -H flag only when the token is non-empty, so an
287
+ // unauthenticated daemon isn't sent a bogus "Bearer " header.
288
+ expect(result.script).toContain('${CIBUILD_CACHE_TOKEN:+-H "Authorization: Bearer $CIBUILD_CACHE_TOKEN"}');
289
+ });
290
+ test('daemon branch is an elif after the local hit (exact-key, daemon, fallback order)', async () => {
291
+ const executor = new CachePullStepExecutor();
292
+ const result = await executor.execute({ technology: 'kmm' }, {}, testConfig);
293
+ const localAt = result.script.indexOf('CACHE_SOURCE=local');
294
+ const daemonAt = result.script.indexOf('CACHE_SOURCE=daemon');
295
+ const fallbackAt = result.script.indexOf('__ci_fb_scope=');
296
+ expect(localAt).toBeGreaterThanOrEqual(0);
297
+ expect(daemonAt).toBeGreaterThan(localAt);
298
+ expect(fallbackAt).toBeGreaterThan(daemonAt);
299
+ });
300
+ test('routes a manual cache_key pull miss through the cache daemon', async () => {
301
+ const executor = new CachePullStepExecutor();
302
+ const result = await executor.execute({ cache_key: 'my-key' }, {}, testConfig);
303
+ expect(result.script).toContain('[ -n "$CIBUILD_CACHE_DAEMON" ]');
304
+ expect(result.script).toContain('"$CIBUILD_CACHE_DAEMON/cache/$CACHE_KEY.tar.zst"');
305
+ expect(result.script).toContain('CACHE_SOURCE=daemon CACHE_KEY=$CACHE_KEY');
306
+ });
271
307
  });
272
308
  describe('CachePushStepExecutor', () => {
273
309
  test('should generate cache push script', async () => {
@@ -283,6 +319,15 @@ describe('Step Implementations', () => {
283
319
  expect(result.script).toContain('node-modules-v1');
284
320
  expect(result.script).toContain('.ci-cache');
285
321
  });
322
+ test('stays a local write — never PUTs to the cache daemon (ADR 0001 v1 scope)', async () => {
323
+ const executor = new CachePushStepExecutor();
324
+ const manual = await executor.execute({ cache_key: 'my-key', cache_paths: ['build'] }, {}, testConfig);
325
+ const preset = await executor.execute({ technology: 'kmm' }, {}, testConfig);
326
+ for (const result of [manual, preset]) {
327
+ expect(result.script).not.toContain('CIBUILD_CACHE_DAEMON');
328
+ expect(result.script).not.toContain('CIBUILD_CACHE_TOKEN');
329
+ }
330
+ });
286
331
  test('should skip gracefully when cache_key is missing', async () => {
287
332
  const executor = new CachePushStepExecutor();
288
333
  const inputs = {
@@ -379,17 +424,21 @@ describe('Step Implementations', () => {
379
424
  expect(result.script).toContain('> "$CACHE_FILE.tmp"');
380
425
  expect(result.script).toContain('mv "$CACHE_FILE.tmp" "$CACHE_FILE"');
381
426
  });
382
- test('should sweep retention (count + age + size budget) after successful preset push', async () => {
427
+ test('should NOT emit guest-side retention (the peer cache daemon owns it, CBR-6)', async () => {
428
+ // ADR 0002 / CBR-6: retention moved into the long-lived peer cache daemon,
429
+ // which knows (via the manager) which scopes this runner owns and must never
430
+ // evict. The guest cache-push no longer prunes — it could only reap an owner's
431
+ // durable copy, having no ownership knowledge of its own.
383
432
  const executor = new CachePushStepExecutor();
384
433
  const result = await executor.execute({ technology: 'kmm' }, {}, testConfig);
385
- expect(result.script).toContain('__ci_ret_scope="${CACHE_KEY%-*}"');
386
- expect(result.script).toContain('ls -t "$CACHE_DIR"/"$__ci_ret_scope"-*.tar.zst');
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');
390
- expect(result.script).toContain('rm -f "$__ci_ret_f"');
391
- });
392
- test('should not run retention for manual cache_key push (no scope)', async () => {
434
+ expect(result.script).not.toContain('__ci_ret_scope');
435
+ expect(result.script).not.toContain('__ci_ret_count_cap');
436
+ expect(result.script).not.toContain('__ci_ret_age_cap');
437
+ expect(result.script).not.toContain('CIBUILD_CACHE_SCOPE_BUDGET_KB');
438
+ // The successful-push path itself is unchanged.
439
+ expect(result.script).toContain('Cache created successfully');
440
+ });
441
+ test('should not emit retention for a manual cache_key push either', async () => {
393
442
  const executor = new CachePushStepExecutor();
394
443
  const result = await executor.execute({ cache_key: 'my-key', cache_paths: ['build'] }, {}, testConfig);
395
444
  expect(result.script).not.toContain('__ci_ret_scope');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invarn/cibuild",
3
- "version": "1.9.2",
3
+ "version": "1.9.4",
4
4
  "description": "CI Build CLI — local pipeline orchestration and validation",
5
5
  "type": "module",
6
6
  "main": "dist/cli.cjs",