@invarn/cibuild 1.5.0 → 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;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;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:"');
@@ -424,6 +472,10 @@ export class CachePushStepExecutor extends BaseStepExecutor {
424
472
  commands.push(' find . -name "$(basename "$1")" -not -path "*/DerivedData/*" -not -path "*/.build/*" 2>/dev/null | head -1');
425
473
  commands.push('}');
426
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('');
427
479
  if (chain.length === 1 && chain[0].fingerprint && chain[0].fingerprint.length > 0) {
428
480
  commands.push(...emitFingerprintKeyCommands(chain[0]));
429
481
  }
@@ -432,10 +484,10 @@ export class CachePushStepExecutor extends BaseStepExecutor {
432
484
  commands.push(`LOCKFILE=$(__ci_find_lockfile "${this.escapeBash(preset.lockfile)}")`);
433
485
  commands.push('if [ -n "$LOCKFILE" ] && [ -f "$LOCKFILE" ]; then');
434
486
  commands.push(` CHECKSUM=$(shasum -a 256 "$LOCKFILE" | cut -d ' ' -f1 | head -c 16)`);
435
- commands.push(` CACHE_KEY="${this.escapeBash(preset.keyPrefix)}-\${CHECKSUM}"`);
487
+ commands.push(` CACHE_KEY="${this.escapeBash(preset.keyPrefix)}-\${__ci_proj}-\${CHECKSUM}"`);
436
488
  commands.push('else');
437
489
  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"`);
490
+ commands.push(` CACHE_KEY="${this.escapeBash(preset.keyPrefix)}-\${__ci_proj}-no-lockfile"`);
439
491
  commands.push('fi');
440
492
  }
441
493
  else {
@@ -446,11 +498,11 @@ export class CachePushStepExecutor extends BaseStepExecutor {
446
498
  commands.push(`${cond} [ -n "$__ci_lockfile_candidate" ] && [ -f "$__ci_lockfile_candidate" ]; then`);
447
499
  commands.push(' LOCKFILE="$__ci_lockfile_candidate"');
448
500
  commands.push(` CHECKSUM=$(shasum -a 256 "$LOCKFILE" | cut -d ' ' -f1 | head -c 16)`);
449
- commands.push(` CACHE_KEY="${this.escapeBash(preset.keyPrefix)}-\${CHECKSUM}"`);
501
+ commands.push(` CACHE_KEY="${this.escapeBash(preset.keyPrefix)}-\${__ci_proj}-\${CHECKSUM}"`);
450
502
  }
451
503
  commands.push('else');
452
504
  commands.push(` echo "Warning: no lockfile found, using fallback cache key"`);
453
- 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"`);
454
506
  commands.push('fi');
455
507
  }
456
508
  commands.push('');
@@ -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 () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invarn/cibuild",
3
- "version": "1.5.0",
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",