@invarn/cibuild 1.4.6 → 1.4.8

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;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;YAoGzF,iBAAiB;CAoGhC;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;YAwJzF,iBAAiB;CAoGhC"}
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"}
@@ -83,33 +83,34 @@ export class CachePullStepExecutor extends BaseStepExecutor {
83
83
  commands.push('echo "Debug mode enabled"');
84
84
  commands.push('echo "Cache file: $CACHE_FILE"');
85
85
  }
86
+ // Peer cache directory
87
+ const peerCacheDir = config.paths.peerCacheDir || '';
88
+ if (peerCacheDir) {
89
+ commands.push('');
90
+ commands.push(`PEER_CACHE_DIR="${this.escapeBash(peerCacheDir)}"`);
91
+ commands.push(`PEER_CACHE_FILE="$PEER_CACHE_DIR/${this.escapeBash(cacheKey)}.tar.zst"`);
92
+ }
86
93
  // Check if cache file exists
87
94
  commands.push('');
88
95
  commands.push('# Check if cache exists');
89
96
  commands.push('if [ -f "$CACHE_FILE" ]; then');
90
- commands.push(' echo "Cache found, extracting..."');
97
+ commands.push(' echo "Cache found (local), extracting..."');
91
98
  if (isDebugMode) {
92
99
  commands.push(' echo "Cache file size: $(du -h "$CACHE_FILE" | cut -f1)"');
93
100
  }
94
- // Extract cache
95
101
  commands.push(' zstd -dc "$CACHE_FILE" | tar -xf - -C /');
96
- commands.push(' echo "Cache restored successfully"');
97
- // List restored paths if debug mode
98
- if (isDebugMode && Array.isArray(cachePaths) && cachePaths.length > 0) {
99
- commands.push(' echo "Restored paths:"');
100
- for (const path of cachePaths) {
101
- commands.push(` EXPANDED_PATH="${this.escapeBash(path)}"`);
102
- commands.push(' EXPANDED_PATH="${EXPANDED_PATH/#~/${CIBUILD_USER_HOME:-$HOME}}"');
103
- commands.push(' if [ -e "$EXPANDED_PATH" ]; then');
104
- commands.push(' echo " ✓ $EXPANDED_PATH"');
105
- commands.push(' else');
106
- commands.push(' echo " ✗ $EXPANDED_PATH (not found)"');
107
- commands.push(' fi');
108
- }
102
+ commands.push(' echo "CACHE_SOURCE=local"');
103
+ // Peer fallback
104
+ if (peerCacheDir) {
105
+ commands.push('elif [ -f "$PEER_CACHE_FILE" ] 2>/dev/null; then');
106
+ commands.push(' echo "Cache found (peer), extracting..."');
107
+ commands.push(' tee "$CACHE_FILE.tmp" < "$PEER_CACHE_FILE" | zstd -dc | tar -xf - -C /');
108
+ commands.push(' mv "$CACHE_FILE.tmp" "$CACHE_FILE"');
109
+ commands.push(' echo "CACHE_SOURCE=peer"');
109
110
  }
110
111
  commands.push('else');
111
112
  commands.push(` echo "No cache found for key: ${this.escapeBash(cacheKey)}"`);
112
- commands.push(' echo "This is normal for the first build or after cache expiration"');
113
+ commands.push(' echo "CACHE_SOURCE=cold"');
113
114
  commands.push('fi');
114
115
  const script = this.createBashScriptFromCommands(commands, stepName);
115
116
  return this.createScriptStep(script, stepName);
@@ -175,30 +176,51 @@ export class CachePullStepExecutor extends BaseStepExecutor {
175
176
  }
176
177
  commands.push('CACHE_FILE="$CACHE_DIR/$CACHE_KEY.tar.zst"');
177
178
  commands.push('');
179
+ // Peer cache directory — set from config, empty string disables peer lookup
180
+ const peerCacheDir = config.paths.peerCacheDir || '';
181
+ if (peerCacheDir) {
182
+ commands.push(`PEER_CACHE_DIR="${this.escapeBash(peerCacheDir)}"`);
183
+ commands.push('PEER_CACHE_FILE="$PEER_CACHE_DIR/$CACHE_KEY.tar.zst"');
184
+ commands.push('');
185
+ }
186
+ // 1. Local hit
178
187
  commands.push('if [ -f "$CACHE_FILE" ]; then');
179
- commands.push(' echo "Cache found, extracting..."');
188
+ commands.push(' echo "Cache found (local), extracting..."');
180
189
  if (isDebugMode) {
181
190
  commands.push(' echo "Cache file size: $(du -h "$CACHE_FILE" | cut -f1)"');
182
191
  }
183
192
  commands.push(' zstd -dc "$CACHE_FILE" | tar -xf - -C /');
184
- commands.push(' echo "Cache restored successfully"');
185
- if (isDebugMode) {
186
- commands.push(' echo "Restored paths:"');
187
- const allPaths = [...new Set(chain.flatMap(p => p.paths))];
188
- for (const p of allPaths) {
189
- commands.push(` EXPANDED="${this.escapeBash(p)}"`);
190
- commands.push(' EXPANDED="${EXPANDED/#~/${CIBUILD_USER_HOME:-$HOME}}"');
191
- commands.push(' if [ -e "$EXPANDED" ]; then');
192
- commands.push(' echo " ✓ $EXPANDED"');
193
- commands.push(' else');
194
- commands.push(' echo " ✗ $EXPANDED (not found)"');
195
- commands.push(' fi');
193
+ commands.push(' echo "CACHE_SOURCE=local CACHE_KEY=$CACHE_KEY"');
194
+ // 2. Peer hit — tee to warm local cache while extracting in one pass
195
+ if (peerCacheDir) {
196
+ commands.push('elif [ -f "$PEER_CACHE_FILE" ] 2>/dev/null; then');
197
+ commands.push(' echo "Cache found (peer), extracting..."');
198
+ if (isDebugMode) {
199
+ commands.push(' echo "Peer cache file size: $(du -h "$PEER_CACHE_FILE" | cut -f1)"');
196
200
  }
201
+ // Single NFS read: tee forks the stream to local cache file AND decompression
202
+ commands.push(' tee "$CACHE_FILE.tmp" < "$PEER_CACHE_FILE" | zstd -dc | tar -xf - -C /');
203
+ commands.push(' mv "$CACHE_FILE.tmp" "$CACHE_FILE"');
204
+ commands.push(' echo "CACHE_SOURCE=peer CACHE_KEY=$CACHE_KEY"');
197
205
  }
206
+ // 3. Cold miss
198
207
  commands.push('else');
199
208
  commands.push(' echo "No cache found for key: $CACHE_KEY"');
200
- commands.push(' echo "This is normal for the first build or after cache expiration"');
209
+ commands.push(' echo "CACHE_SOURCE=cold CACHE_KEY=$CACHE_KEY"');
201
210
  commands.push('fi');
211
+ if (isDebugMode) {
212
+ commands.push('echo "Restored paths:"');
213
+ const allPaths = [...new Set(chain.flatMap(p => p.paths))];
214
+ for (const p of allPaths) {
215
+ commands.push(`EXPANDED="${this.escapeBash(p)}"`);
216
+ commands.push('EXPANDED="${EXPANDED/#~/${CIBUILD_USER_HOME:-$HOME}}"');
217
+ commands.push('if [ -e "$EXPANDED" ]; then');
218
+ commands.push(' echo " ✓ $EXPANDED"');
219
+ commands.push('else');
220
+ commands.push(' echo " ✗ $EXPANDED (not found)"');
221
+ commands.push('fi');
222
+ }
223
+ }
202
224
  const script = this.createBashScriptFromCommands(commands, stepName);
203
225
  return this.createScriptStep(script, stepName);
204
226
  }
@@ -311,14 +333,29 @@ export class CachePushStepExecutor extends BaseStepExecutor {
311
333
  }
312
334
  // Create cache if we have paths
313
335
  commands.push('');
314
- commands.push('# Create cache archive');
315
- commands.push('if [ ${#PATHS_TO_CACHE[@]} -gt 0 ]; then');
336
+ commands.push('# Skip if cache already exists for this key');
337
+ commands.push('if [ -f "$CACHE_FILE" ]; then');
338
+ commands.push(' echo "Cache already up to date for key: $CACHE_KEY"');
339
+ commands.push('elif [ ${#PATHS_TO_CACHE[@]} -gt 0 ]; then');
316
340
  commands.push(' echo "Caching ${#PATHS_TO_CACHE[@]} path(s)..."');
317
341
  if (isDebugMode) {
318
342
  commands.push(' echo "Paths to cache:"');
319
343
  commands.push(' printf " %s\\n" "${PATHS_TO_CACHE[@]}"');
320
344
  }
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');
321
357
  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"');
322
359
  commands.push(' if [ -f "$CACHE_FILE" ]; then');
323
360
  commands.push(' echo "Cache created successfully"');
324
361
  if (isDebugMode) {
@@ -405,10 +442,26 @@ export class CachePushStepExecutor extends BaseStepExecutor {
405
442
  commands.push('fi');
406
443
  }
407
444
  commands.push('');
408
- // Create cache archive
409
- commands.push('if [ ${#PATHS_TO_CACHE[@]} -gt 0 ]; then');
445
+ // Skip if cache already exists for this key
446
+ commands.push('if [ -f "$CACHE_FILE" ]; then');
447
+ commands.push(' echo "Cache already up to date for key: $CACHE_KEY"');
448
+ commands.push('elif [ ${#PATHS_TO_CACHE[@]} -gt 0 ]; then');
410
449
  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)
411
463
  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"');
412
465
  commands.push(' if [ -f "$CACHE_FILE" ]; then');
413
466
  commands.push(' echo "Cache created successfully"');
414
467
  if (isDebugMode) {
@@ -6,7 +6,7 @@ import { describe, test, expect } from '@jest/globals';
6
6
  import { GitCloneStepExecutor } from './git-clone.js';
7
7
  import { ScriptStepExecutor } from './script.js';
8
8
  import { CachePullStepExecutor, CachePushStepExecutor } from './cache.js';
9
- import { testConfig } from './test-config.js';
9
+ import { testConfig, testConfigNoPeer } from './test-config.js';
10
10
  import { XcodeBuildStepExecutor, XcodeTestStepExecutor } from './xcode.js';
11
11
  import { SetJavaVersionStepExecutor, GradleBuildStepExecutor, AndroidLintStepExecutor, AndroidUnitTestStepExecutor, } from './android.js';
12
12
  describe('Step Implementations', () => {
@@ -189,6 +189,31 @@ describe('Step Implementations', () => {
189
189
  const executor = new CachePullStepExecutor();
190
190
  await expect(executor.execute({ technology: 'unknown' }, {}, testConfig)).rejects.toThrow("Unknown cache technology 'unknown'");
191
191
  });
192
+ test('should include peer cache fallback when peerCacheDir is set', async () => {
193
+ const executor = new CachePullStepExecutor();
194
+ const result = await executor.execute({ technology: 'cocoapods' }, {}, testConfig);
195
+ expect(result.script).toContain('PEER_CACHE_DIR=');
196
+ expect(result.script).toContain('PEER_CACHE_FILE=');
197
+ expect(result.script).toContain('CACHE_SOURCE=local');
198
+ expect(result.script).toContain('CACHE_SOURCE=peer');
199
+ expect(result.script).toContain('CACHE_SOURCE=cold');
200
+ // tee pattern: single read from peer, simultaneous extract + local warm
201
+ expect(result.script).toContain('tee "$CACHE_FILE.tmp" < "$PEER_CACHE_FILE"');
202
+ });
203
+ test('should skip peer fallback when peerCacheDir is not set', async () => {
204
+ const executor = new CachePullStepExecutor();
205
+ const result = await executor.execute({ technology: 'cocoapods' }, {}, testConfigNoPeer);
206
+ expect(result.script).not.toContain('PEER_CACHE_DIR=');
207
+ expect(result.script).not.toContain('PEER_CACHE_FILE=');
208
+ expect(result.script).not.toContain('CACHE_SOURCE=peer');
209
+ });
210
+ test('should include peer fallback for manual cache_key pull', async () => {
211
+ const executor = new CachePullStepExecutor();
212
+ const result = await executor.execute({ cache_key: 'my-key' }, {}, testConfig);
213
+ expect(result.script).toContain('PEER_CACHE_DIR=');
214
+ expect(result.script).toContain('CACHE_SOURCE=peer');
215
+ expect(result.script).toContain('tee "$CACHE_FILE.tmp" < "$PEER_CACHE_FILE"');
216
+ });
192
217
  });
193
218
  describe('CachePushStepExecutor', () => {
194
219
  test('should generate cache push script', async () => {
@@ -278,6 +303,28 @@ describe('Step Implementations', () => {
278
303
  const executor = new CachePushStepExecutor();
279
304
  await expect(executor.execute({ technology: 'unknown' }, {}, testConfig)).rejects.toThrow("Unknown cache technology 'unknown'");
280
305
  });
306
+ test('should use shlock locking for preset push', async () => {
307
+ const executor = new CachePushStepExecutor();
308
+ 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 () => {
317
+ const executor = new CachePushStepExecutor();
318
+ 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"');
321
+ });
322
+ test('should use atomic write (tmp + mv) for preset push', async () => {
323
+ const executor = new CachePushStepExecutor();
324
+ const result = await executor.execute({ technology: 'gradle' }, {}, testConfig);
325
+ expect(result.script).toContain('> "$CACHE_FILE.tmp"');
326
+ expect(result.script).toContain('mv "$CACHE_FILE.tmp" "$CACHE_FILE"');
327
+ });
281
328
  });
282
329
  describe('XcodeBuildStepExecutor', () => {
283
330
  test('should generate xcodebuild script', async () => {
@@ -1,3 +1,5 @@
1
1
  import type { CIConfig } from '../../types.js';
2
2
  export declare const testConfig: CIConfig;
3
+ /** Config without peer cache (no NFS peer runner) */
4
+ export declare const testConfigNoPeer: CIConfig;
3
5
  //# sourceMappingURL=test-config.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"test-config.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/test-config.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE/C,eAAO,MAAM,UAAU,EAAE,QAexB,CAAC"}
1
+ {"version":3,"file":"test-config.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/test-config.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE/C,eAAO,MAAM,UAAU,EAAE,QAexB,CAAC;AAEF,qDAAqD;AACrD,eAAO,MAAM,gBAAgB,EAAE,QAM9B,CAAC"}
@@ -10,8 +10,16 @@ export const testConfig = {
10
10
  paths: {
11
11
  buildsDir: '.ci-builds',
12
12
  cacheDir: '.ci-cache',
13
- peerCacheDir: 'peer',
13
+ peerCacheDir: '/Volumes/My Shared Files/cibuild-peer-cache',
14
14
  derivedDataDir: '~/Library/Developer/Xcode/DerivedData',
15
15
  },
16
16
  };
17
+ /** Config without peer cache (no NFS peer runner) */
18
+ export const testConfigNoPeer = {
19
+ ...testConfig,
20
+ paths: {
21
+ ...testConfig.paths,
22
+ peerCacheDir: undefined,
23
+ },
24
+ };
17
25
  //# sourceMappingURL=test-config.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invarn/cibuild",
3
- "version": "1.4.6",
3
+ "version": "1.4.8",
4
4
  "description": "CI Build CLI — local pipeline orchestration and validation",
5
5
  "type": "module",
6
6
  "main": "dist/cli.cjs",