@invarn/cibuild 2.5.9 → 2.6.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.
@@ -86,13 +86,193 @@ function resolvePresetChain(technology) {
86
86
  }
87
87
  return chain;
88
88
  }
89
+ /**
90
+ * Resolves the `identifier:pattern` shorthand a `cache_paths` entry may use
91
+ * (`derived_data:MyApp-*`, `builds:nightly`) into a real path. Anything else is
92
+ * returned unchanged.
93
+ *
94
+ * Shared because the two sides used to disagree: `cache-push` resolved these
95
+ * and `cache-pull` did not, so the classification below would have put a
96
+ * `derived_data:` entry in the project-relative class on the way out and the
97
+ * absolute class on the way in.
98
+ */
99
+ function resolveCachePathIdentifier(path, config) {
100
+ if (!path.includes(':'))
101
+ return path;
102
+ const [identifier, pattern] = path.split(':', 2);
103
+ switch (identifier) {
104
+ case 'derived_data':
105
+ return `${config.paths.derivedDataDir}/${pattern}`;
106
+ case 'builds':
107
+ return `${config.paths.buildsDir}/${pattern}`;
108
+ default:
109
+ return path;
110
+ }
111
+ }
112
+ /**
113
+ * Splits `cache_paths` into the two classes that decide where a member has to
114
+ * be extracted to.
115
+ *
116
+ * `cache-push` archives whatever `PATHS_TO_CACHE` holds. A `~`- or `/`-rooted
117
+ * entry is expanded to an absolute path and tar stores it with the leading `/`
118
+ * stripped (`/home/builder/.gradle/caches` -> `home/builder/.gradle/caches`),
119
+ * which round-trips through `-C /`. A project-relative entry (`Pods`,
120
+ * `.gradle`, `node_modules`) is stored as-is, relative to the step's working
121
+ * directory — and `-C /` then tries to create it at the root of the
122
+ * filesystem, which the build user cannot do.
123
+ *
124
+ * So the class is decided by the declared form, not by anything in the tarball,
125
+ * and both steps can compute it from the same input.
126
+ */
127
+ function classifyCachePaths(paths, config) {
128
+ const absolute = [];
129
+ const relative = [];
130
+ for (const raw of paths) {
131
+ const path = resolveCachePathIdentifier(raw, config);
132
+ if (path.startsWith('~') || path.startsWith('/')) {
133
+ absolute.push(path);
134
+ }
135
+ else {
136
+ relative.push(path);
137
+ }
138
+ }
139
+ return { absolute, relative };
140
+ }
141
+ /**
142
+ * Emits `__ci_cache_extract`, which every restore path pipes its tar stream
143
+ * into, plus the two member lists it reads.
144
+ *
145
+ * **The defect it closes.** Every restore used one `tar -xf - -C /`. For a
146
+ * `cache_paths` that mixes the two classes — which every Gradle/Android
147
+ * pipeline does, including the one the README ships — the absolute half landed
148
+ * (that *was* the warmth) and the relative half could not, so tar exited
149
+ * non-zero, the compound condition went false, and a build that had just been
150
+ * restored reported `CACHE_SOURCE=cold`. Worse, a pipeline whose paths are
151
+ * *entirely* project-relative (`Pods`, `Podfile.lock`, `node_modules`) had
152
+ * every member fail: those caches were written on every build and had never
153
+ * restored anything. Measured on GNU tar 1.35 and bsdtar 3.5.3 alike.
154
+ *
155
+ * On the filesystem transport the same failure was not a mis-report at all —
156
+ * the extraction is a bare command there, so under `set -e -o pipefail` it
157
+ * ended the step.
158
+ *
159
+ * **Cost.** A restore with no project-relative paths keeps today's single
160
+ * streaming pass and stages nothing, so the shape that already worked pays
161
+ * nothing. The mixed and all-relative shapes stage the tar and read it twice,
162
+ * which is the price of extracting two roots from one stream.
163
+ *
164
+ * Written for bash 3.2 — macOS ships it, and the generated script says
165
+ * `#!/bin/bash`.
166
+ */
167
+ function emitCacheExtractHelper(paths, config, indent = '') {
168
+ const { absolute, relative } = classifyCachePaths(paths, config);
169
+ const lit = (s) => `'${s.replace(/'/g, "'\\''")}'`;
170
+ const out = [
171
+ // Captured before anything can cd: project-relative members are recorded
172
+ // against the step's working directory, so that is where they go back.
173
+ `${indent}__ci_cache_root="$PWD"`,
174
+ `${indent}__ci_cache_abs=()`,
175
+ `${indent}__ci_cache_rel=()`,
176
+ ];
177
+ for (const p of absolute) {
178
+ // Expanded at runtime — `~` means ${CIBUILD_USER_HOME:-$HOME}, which the
179
+ // generator cannot know. The leading `/` is stripped to match how tar
180
+ // recorded the member.
181
+ out.push(`${indent}__ci_p=${lit(p)}`);
182
+ out.push(`${indent}__ci_p="\${__ci_p/#~/\${CIBUILD_USER_HOME:-$HOME}}"`);
183
+ out.push(`${indent}__ci_cache_abs+=("\${__ci_p#/}")`);
184
+ }
185
+ for (const p of relative) {
186
+ out.push(`${indent}__ci_cache_rel+=(${lit(p)})`);
187
+ }
188
+ out.push(`${indent}__ci_cache_extract() {`,
189
+ // Nothing project-relative: the archive is entirely absolute-derived and
190
+ // one streaming pass is both correct and cheapest.
191
+ `${indent} if [ \${#__ci_cache_rel[@]} -eq 0 ]; then`, `${indent} tar -xf - -C /`, `${indent} return`, `${indent} fi`, `${indent} local __t="\${TMPDIR:-/tmp}/cibuild-cache-extract.$$.tar"`, `${indent} local __rc=0 __top __c __is_rel __p`, `${indent} local __abs_ops __rel_ops`, `${indent} cat > "$__t" || { rm -f "$__t"; return 1; }`,
192
+ // The split is decided on the archive's own top-level names, not by
193
+ // `--exclude`. tar's exclude patterns are unanchored: `--exclude=.gradle`
194
+ // also matches `home/builder/.gradle`, so excluding the project-relative
195
+ // half from the pass that targets `/` silently dropped the absolute half
196
+ // too whenever the two shared a component name — which the canonical
197
+ // Gradle shape (`~/.gradle/caches` plus `.gradle`) always does.
198
+ `${indent} __top=$(tar -tf "$__t" 2>/dev/null | sed -e 's|^\\./||' -e 's|/.*||' | grep -v '^$' | sort -u) || __rc=$?`, `${indent} if [ "$__rc" -ne 0 ]; then rm -f "$__t"; return "$__rc"; fi`, `${indent} __abs_ops=()`, `${indent} __rel_ops=()`,
199
+ // Read rather than word-split: a cached path may contain spaces
200
+ // (`derived_data:My App-*`). Process substitution keeps the loop in this
201
+ // shell, so the arrays it fills survive it.
202
+ `${indent} while IFS= read -r __c; do`, `${indent} __is_rel=0`, `${indent} for __p in "\${__ci_cache_rel[@]}"; do`, `${indent} case "$__p" in "$__c"|"$__c"/*) __is_rel=1 ;; esac`, `${indent} done`,
203
+ // A name claimed by both classes resolves to `/`, which is what it did
204
+ // before this existed. It needs a project directory named after a
205
+ // filesystem root while also caching an absolute path under it.
206
+ `${indent} if [ "$__is_rel" -eq 1 ]; then __rel_ops+=("$__c"); else __abs_ops+=("$__c"); fi`, `${indent} done < <(printf '%s\\n' "$__top")`, `${indent} if [ \${#__abs_ops[@]} -gt 0 ]; then`, `${indent} tar -xf "$__t" -C / "\${__abs_ops[@]}" || __rc=$?`, `${indent} fi`, `${indent} if [ "$__rc" -eq 0 ] && [ \${#__rel_ops[@]} -gt 0 ]; then`, `${indent} tar -xf "$__t" -C "$__ci_cache_root" "\${__rel_ops[@]}" || __rc=$?`, `${indent} fi`, `${indent} rm -f "$__t"`, `${indent} return "$__rc"`, `${indent}}`);
207
+ return out;
208
+ }
209
+ /** The token `%{http_code}` is reported under; see `daemonCurl`. */
210
+ const DAEMON_STATUS_TOKEN = 'ci_http_status';
89
211
  /**
90
212
  * Emits the `curl` invocation the cache steps talk to the daemon with. The
91
213
  * bearer token is per-build and only present on the Invarn runner, so it is
92
214
  * expanded conditionally and the same script works without one.
215
+ *
216
+ * Every request also reports the HTTP status it got, on **stderr** — the
217
+ * response body is the tarball and goes to stdout, so a status written there
218
+ * would corrupt it. `%{stderr}` is curl 7.63+ (2018); an older curl prints the
219
+ * token literally instead of a number, which the reader below tolerates.
220
+ *
221
+ * `-f`, not `--fail-with-body`: on the pull side the error body would be piped
222
+ * straight into `zstd`. The status is what distinguishes the answers anyway,
223
+ * and it now arrives without the body.
93
224
  */
94
225
  function daemonCurl(args) {
95
- return `curl -fsS \${CIBUILD_CACHE_TOKEN:+-H "Authorization: Bearer $CIBUILD_CACHE_TOKEN"} ${args}`;
226
+ return `curl -fsS -w '%{stderr}${DAEMON_STATUS_TOKEN}=%{http_code}\\n' \${CIBUILD_CACHE_TOKEN:+-H "Authorization: Bearer $CIBUILD_CACHE_TOKEN"} ${args}`;
227
+ }
228
+ /**
229
+ * Emitted once inside a daemon pull branch, before the first fetch: a file for
230
+ * curl's stderr, and the reader that turns it into one line — or into nothing
231
+ * at all when the daemon simply had no such entry.
232
+ *
233
+ * `{ curl | zstd | tar; } 2>/dev/null` used to swallow curl's message
234
+ * entirely, so a 401, a 400, a 503, a 500 and a daemon that was not listening
235
+ * all printed the same `No cache found for key: …` as an empty cache. That is
236
+ * the same class as the push-side defect: the instrument reported the normal
237
+ * case for every abnormal one.
238
+ *
239
+ * **A miss stays quiet and a failure gets loud.** A cold cache is the normal
240
+ * case and must not start printing errors; the point is only that a broken
241
+ * daemon stops looking like one.
242
+ */
243
+ function emitDaemonPullDiagnostics(indent = '') {
244
+ return [
245
+ `${indent}__ci_cache_diag="\${TMPDIR:-/tmp}/cibuild-cache-pull.$$.diag"`,
246
+ `${indent}__ci_cache_daemon_said() {`,
247
+ `${indent} local __said=''`,
248
+ `${indent} if [ -s "$__ci_cache_diag" ]; then`,
249
+ `${indent} __said=$(tr '\\n' ' ' < "$__ci_cache_diag")`,
250
+ `${indent} fi`,
251
+ `${indent} case "$__said" in`,
252
+ // 404 is the daemon's answer for `cache_miss` and `no_cache_for_scope` —
253
+ // an empty cache, which is normal. The second pattern reads curl's own
254
+ // English for a curl too old to honour `%{stderr}`, so neither reader
255
+ // failing alone can turn a cold cache noisy.
256
+ `${indent} *${DAEMON_STATUS_TOKEN}=404*|*'error: 404'*) __said='' ;;`,
257
+ `${indent} esac`,
258
+ // Always exits 0. A non-zero return would be the assignment's status at
259
+ // every call site, and under errexit that ends the step on a cache miss.
260
+ `${indent} printf '%s' "\${__said:0:300}"`,
261
+ `${indent}}`,
262
+ ];
263
+ }
264
+ /**
265
+ * The `else` arm of a daemon fetch: says why it did not restore, unless the
266
+ * daemon simply had no such entry. Never fatal — a build that cannot read a
267
+ * cache still builds, it just builds cold.
268
+ */
269
+ function emitDaemonPullFailureDiagnosis(indent, what) {
270
+ return [
271
+ `${indent}__ci_cache_why=$(__ci_cache_daemon_said)`,
272
+ `${indent}if [ -n "$__ci_cache_why" ]; then`,
273
+ `${indent} echo "Warning: cache-pull could not restore ${what} from the daemon — $__ci_cache_why"`,
274
+ `${indent}fi`,
275
+ ];
96
276
  }
97
277
  /**
98
278
  * The same request, keeping the response body when the daemon refuses.
@@ -122,16 +302,53 @@ function daemonCurlKeepingErrorBody(args) {
122
302
  * reported success. Every curated Invarn template uses the explicit-key form,
123
303
  * so nothing was ever stored by any of them. One definition cannot diverge
124
304
  * from itself.
305
+ *
306
+ * The failure message names the stage that failed, which took a second fix.
307
+ * `$( … 2>&1 )` captured curl's stdout, and `--fail-with-body` writes the
308
+ * daemon's response body there on success as well as on refusal — so when the
309
+ * *middle* stage died (no `zstd` on the image), pipefail took the else branch
310
+ * and printed the daemon's **success** body as the reason the upload failed:
311
+ *
312
+ * Warning: failed to upload cache to the daemon — {"ok":true}
313
+ *
314
+ * No stream separates those two cases, because the body is on stdout either
315
+ * way; only the stage's exit status does. Hence `PIPESTATUS` rather than the
316
+ * pull side's diag-file split — the file is still here, but it is the daemon's
317
+ * answer, and it is quoted only when curl is what refused. A rejection's
318
+ * reason still reaches the log, which `cache-wire-test.sh` asserts.
125
319
  */
126
320
  function emitDaemonPushCommands() {
127
321
  return [
128
- ` if __ci_push_err=$(tar -cf - "\${PATHS_TO_CACHE[@]}" 2>/dev/null | zstd -3 | ${daemonCurlKeepingErrorBody('-T - "$CIBUILD_CACHE_DAEMON/cache/$CACHE_KEY.tar.zst"')} 2>&1); then`,
322
+ // curl's own stdout and stderr, kept out of the pipeline's status. Mirrors
323
+ // the pull side's `$__ci_cache_diag`.
324
+ ' __ci_push_out="${TMPDIR:-/tmp}/cibuild-cache-push.$$.out"',
325
+ ` if tar -cf - "\${PATHS_TO_CACHE[@]}" 2>/dev/null | zstd -3 | ${daemonCurlKeepingErrorBody('-T - "$CIBUILD_CACHE_DAEMON/cache/$CACHE_KEY.tar.zst"')} >"$__ci_push_out" 2>&1; then`,
129
326
  ' echo "Cache created successfully"',
130
327
  ' else',
328
+ // Read first, before anything else in this branch: PIPESTATUS holds the
329
+ // last pipeline's statuses and the next command replaces it.
330
+ ' __ci_push_rc=("${PIPESTATUS[@]}")',
131
331
  // Never fatal: a build that produced good output should not be failed by
132
332
  // a cache upload, and the next build simply misses. Saying *why* costs
133
333
  // nothing and is the difference between a warning and a diagnosis.
134
- " __ci_push_msg=$(printf '%s' \"$__ci_push_err\" | tr '\\n' ' ')",
334
+ ' __ci_push_msg=""',
335
+ ' if [ "${__ci_push_rc[2]}" != 0 ]; then',
336
+ // The daemon refused, so the daemon's own answer is the reason —
337
+ // `invalid_cache_key`, `cache_busy`, `empty_body`, an auth failure.
338
+ // `[ -s ]` guards the substitution: an assignment whose command
339
+ // substitution fails is a non-zero statement, and under errexit that ends
340
+ // the step.
341
+ ' if [ -s "$__ci_push_out" ]; then',
342
+ " __ci_push_msg=$(tr '\\n' ' ' < \"$__ci_push_out\")",
343
+ ' fi',
344
+ ' elif [ "${__ci_push_rc[1]}" = 127 ]; then',
345
+ // curl was happy — it uploaded whatever reached it, which was nothing.
346
+ // 127 is the shell failing to find the command, and zstd itself never
347
+ // exits 127, so this is unambiguous and worth saying outright.
348
+ ' __ci_push_msg="zstd is not installed on this image, so nothing was uploaded"',
349
+ ' else',
350
+ ' __ci_push_msg="nothing was uploaded — tar exited ${__ci_push_rc[0]}, zstd exited ${__ci_push_rc[1]}"',
351
+ ' fi',
135
352
  ' __ci_push_msg=${__ci_push_msg:0:300}',
136
353
  ' if [ -n "$__ci_push_msg" ]; then',
137
354
  ' echo "Warning: failed to upload cache to the daemon — $__ci_push_msg"',
@@ -139,6 +356,7 @@ function emitDaemonPushCommands() {
139
356
  ' echo "Warning: failed to upload cache to the daemon"',
140
357
  ' fi',
141
358
  ' fi',
359
+ ' rm -f "$__ci_push_out"',
142
360
  ];
143
361
  }
144
362
  /**
@@ -156,12 +374,18 @@ function emitDaemonPushCommands() {
156
374
  function emitDaemonPullCommands() {
157
375
  return [
158
376
  'echo "Cache daemon: $CIBUILD_CACHE_DAEMON"',
159
- // Exact key. stderr silenced — a miss is the normal case and curl/zstd
160
- // both editorialise about it; pipefail still carries the exit status.
161
- `if { ${daemonCurl('"$CIBUILD_CACHE_DAEMON/cache/$CACHE_KEY.tar.zst"')} | zstd -dc | tar -xf - -C /; } 2>/dev/null; then`,
377
+ ...emitDaemonPullDiagnostics(),
378
+ // Exact key. zstd's and tar's stderr is silenced — a miss is the normal
379
+ // case and they both editorialise about it; pipefail still carries the
380
+ // exit status. curl's own stderr is kept, in the file the diagnostics
381
+ // above read: it is the only thing that knows *why*.
382
+ `if { ${daemonCurl('"$CIBUILD_CACHE_DAEMON/cache/$CACHE_KEY.tar.zst" 2>"$__ci_cache_diag"')} | zstd -dc | __ci_cache_extract; } 2>/dev/null; then`,
162
383
  ' echo "Cache found (daemon), extracting..."',
163
384
  ' echo "CACHE_SOURCE=daemon CACHE_KEY=$CACHE_KEY"',
164
385
  'else',
386
+ // Said before the fallback is attempted, so the reason names the request
387
+ // it belongs to rather than whichever request happened to fail last.
388
+ ...emitDaemonPullFailureDiagnosis(' ', '$CACHE_KEY'),
165
389
  // Scope fallback: the newest tarball for this <keyPrefix>-<projectId>.
166
390
  // Gradle/Xcode re-hash their inputs on top of the warm directory, hitting
167
391
  // the unchanged work and rebuilding only what actually moved.
@@ -177,10 +401,11 @@ function emitDaemonPullCommands() {
177
401
  // than seed from ancient state. Exact-key hits are unaffected.
178
402
  ' if [ "$__ci_fb_age_days" -le 30 ]; then',
179
403
  ' echo "Cache fallback (daemon): using prior tarball $__ci_fb_key (${__ci_fb_age_days}d old)"',
180
- ` if { ${daemonCurl('"$CIBUILD_CACHE_DAEMON/cache/$__ci_fb_key.tar.zst"')} | zstd -dc | tar -xf - -C /; } 2>/dev/null; then`,
404
+ ` if { ${daemonCurl('"$CIBUILD_CACHE_DAEMON/cache/$__ci_fb_key.tar.zst" 2>"$__ci_cache_diag"')} | zstd -dc | __ci_cache_extract; } 2>/dev/null; then`,
181
405
  ' echo "CACHE_SOURCE=fallback_daemon CACHE_KEY=$CACHE_KEY FALLBACK_KEY=$__ci_fb_key"',
182
406
  ' else',
183
407
  ' echo "Cache fallback $__ci_fb_key could not be fetched — going cold"',
408
+ ...emitDaemonPullFailureDiagnosis(' ', '$__ci_fb_key'),
184
409
  ' echo "CACHE_SOURCE=cold CACHE_KEY=$CACHE_KEY"',
185
410
  ' fi',
186
411
  ' else',
@@ -192,6 +417,7 @@ function emitDaemonPullCommands() {
192
417
  ' echo "CACHE_SOURCE=cold CACHE_KEY=$CACHE_KEY"',
193
418
  ' fi',
194
419
  'fi',
420
+ 'rm -f "$__ci_cache_diag"',
195
421
  ];
196
422
  }
197
423
  /**
@@ -258,32 +484,40 @@ export class CachePullStepExecutor extends BaseStepExecutor {
258
484
  commands.push(`PEER_CACHE_DIR="${this.escapeBash(peerCacheDir)}"`);
259
485
  commands.push(`PEER_CACHE_FILE="$PEER_CACHE_DIR/${this.escapeBash(cacheKey)}.tar.zst"`);
260
486
  }
487
+ // Where each class of member is restored to. Emitted before every
488
+ // transport, because all of them extract.
489
+ commands.push('');
490
+ commands.push('# Restore roots');
491
+ commands.push(...emitCacheExtractHelper(cachePaths, config));
261
492
  // Check if cache file exists
262
493
  commands.push('');
263
494
  commands.push('# Check if cache exists');
264
495
  // Transport split — see the preset path. An explicit-key step has no scope
265
496
  // to fall back to, so the daemon side is the exact key or nothing.
266
497
  commands.push('if [ -n "$CIBUILD_CACHE_DAEMON" ]; then');
267
- commands.push(` if { ${daemonCurl('"$CIBUILD_CACHE_DAEMON/cache/$CACHE_KEY.tar.zst"')} | zstd -dc | tar -xf - -C /; } 2>/dev/null; then`);
498
+ commands.push(...emitDaemonPullDiagnostics(' '));
499
+ commands.push(` if { ${daemonCurl('"$CIBUILD_CACHE_DAEMON/cache/$CACHE_KEY.tar.zst" 2>"$__ci_cache_diag"')} | zstd -dc | __ci_cache_extract; } 2>/dev/null; then`);
268
500
  commands.push(' echo "Cache found (daemon), extracting..."');
269
501
  commands.push(' echo "CACHE_SOURCE=daemon"');
270
502
  commands.push(' else');
503
+ commands.push(...emitDaemonPullFailureDiagnosis(' ', '$CACHE_KEY'));
271
504
  commands.push(` echo "No cache found for key: ${this.escapeBash(cacheKey)}"`);
272
505
  commands.push(' echo "CACHE_SOURCE=cold"');
273
506
  commands.push(' fi');
507
+ commands.push(' rm -f "$__ci_cache_diag"');
274
508
  commands.push('else');
275
509
  commands.push('if [ -f "$CACHE_FILE" ]; then');
276
510
  commands.push(' echo "Cache found (local), extracting..."');
277
511
  if (isDebugMode) {
278
512
  commands.push(' echo "Cache file size: $(du -h "$CACHE_FILE" | cut -f1)"');
279
513
  }
280
- commands.push(' zstd -dc "$CACHE_FILE" | tar -xf - -C /');
514
+ commands.push(' zstd -dc "$CACHE_FILE" | __ci_cache_extract');
281
515
  commands.push(' echo "CACHE_SOURCE=local"');
282
516
  // Peer fallback
283
517
  if (peerCacheDir) {
284
518
  commands.push('elif [ -f "$PEER_CACHE_FILE" ] 2>/dev/null; then');
285
519
  commands.push(' echo "Cache found (peer), extracting..."');
286
- commands.push(' tee "$CACHE_FILE.tmp" < "$PEER_CACHE_FILE" | zstd -dc | tar -xf - -C /');
520
+ commands.push(' tee "$CACHE_FILE.tmp" < "$PEER_CACHE_FILE" | zstd -dc | __ci_cache_extract');
287
521
  commands.push(' mv "$CACHE_FILE.tmp" "$CACHE_FILE"');
288
522
  commands.push(' echo "CACHE_SOURCE=peer"');
289
523
  }
@@ -375,6 +609,13 @@ export class CachePullStepExecutor extends BaseStepExecutor {
375
609
  }
376
610
  commands.push('CACHE_FILE="$CACHE_DIR/$CACHE_KEY.tar.zst"');
377
611
  commands.push('');
612
+ // Where each class of member is restored to. Every path below extracts, so
613
+ // this is emitted once, above the transport split. `allPaths` is the same
614
+ // union cache-push archives from, which is what keeps the two sides
615
+ // agreeing about which class a path is in.
616
+ commands.push('# Restore roots');
617
+ commands.push(...emitCacheExtractHelper([...new Set(chain.flatMap(p => p.paths))], config));
618
+ commands.push('');
378
619
  // Transport split. On a runner the cache lives on the host and is reached
379
620
  // over HTTP; standalone, it is a directory on this machine. The daemon URL
380
621
  // is the switch, so the same generated script serves both.
@@ -394,7 +635,7 @@ export class CachePullStepExecutor extends BaseStepExecutor {
394
635
  if (isDebugMode) {
395
636
  commands.push(' echo "Cache file size: $(du -h "$CACHE_FILE" | cut -f1)"');
396
637
  }
397
- commands.push(' zstd -dc "$CACHE_FILE" | tar -xf - -C /');
638
+ commands.push(' zstd -dc "$CACHE_FILE" | __ci_cache_extract');
398
639
  // LRU bump: lift this tarball to the top of `ls -t` so retention's age
399
640
  // cap counts last-used time, not last-written. Without this, a tarball
400
641
  // hit daily would still be reaped on its 31st day from creation.
@@ -408,7 +649,7 @@ export class CachePullStepExecutor extends BaseStepExecutor {
408
649
  commands.push(' echo "Peer cache file size: $(du -h "$PEER_CACHE_FILE" | cut -f1)"');
409
650
  }
410
651
  // Single NFS read: tee forks the stream to local cache file AND decompression
411
- commands.push(' tee "$CACHE_FILE.tmp" < "$PEER_CACHE_FILE" | zstd -dc | tar -xf - -C /');
652
+ commands.push(' tee "$CACHE_FILE.tmp" < "$PEER_CACHE_FILE" | zstd -dc | __ci_cache_extract');
412
653
  commands.push(' mv "$CACHE_FILE.tmp" "$CACHE_FILE"');
413
654
  commands.push(' echo "CACHE_SOURCE=peer CACHE_KEY=$CACHE_KEY"');
414
655
  }
@@ -447,7 +688,7 @@ export class CachePullStepExecutor extends BaseStepExecutor {
447
688
  if (isDebugMode) {
448
689
  commands.push(' echo "Fallback size: $(du -h "$__ci_fb" | cut -f1)"');
449
690
  }
450
- commands.push(' zstd -dc "$__ci_fb" | tar -xf - -C /');
691
+ commands.push(' zstd -dc "$__ci_fb" | __ci_cache_extract');
451
692
  // LRU bump for the fallback tarball — we restored from it, so it earns
452
693
  // its place at the top of the retention sort even if the exact-key
453
694
  // tarball is the one that gets created in this run's cache-push.
@@ -552,23 +793,11 @@ export class CachePushStepExecutor extends BaseStepExecutor {
552
793
  commands.push('# Check which paths exist');
553
794
  commands.push('PATHS_TO_CACHE=()');
554
795
  for (const path of cachePaths) {
555
- // Resolve path identifiers (e.g., "derived_data:pattern" -> "~/Library/.../DerivedData/pattern")
556
- let resolvedPath = path;
557
- // Check if path uses identifier format (identifier:pattern)
558
- if (path.includes(':')) {
559
- const [identifier, pattern] = path.split(':', 2);
560
- switch (identifier) {
561
- case 'derived_data':
562
- resolvedPath = `${config.paths.derivedDataDir}/${pattern}`;
563
- break;
564
- case 'builds':
565
- resolvedPath = `${config.paths.buildsDir}/${pattern}`;
566
- break;
567
- default:
568
- // Unknown identifier, use as-is
569
- resolvedPath = path;
570
- }
571
- }
796
+ // Resolve path identifiers (e.g., "derived_data:pattern" -> "~/Library/.../DerivedData/pattern").
797
+ // Shared with cache-pull's member classification: if only one side
798
+ // resolved these, the two would disagree about which restore root the
799
+ // path belongs to.
800
+ const resolvedPath = resolveCachePathIdentifier(path, config);
572
801
  // Expand tilde by using parameter expansion
573
802
  commands.push(`EXPANDED_PATH="${this.escapeBash(resolvedPath)}"`);
574
803
  commands.push('EXPANDED_PATH="${EXPANDED_PATH/#~/${CIBUILD_USER_HOME:-$HOME}}"');
@@ -218,17 +218,28 @@ describe('Step Implementations', () => {
218
218
  mkdirSync(swiftpm, { recursive: true });
219
219
  writeFileSync(join(swiftpm, 'Package.resolved'), '{"pins":[],"version":3}');
220
220
  }
221
- test('a cold daemon miss does not print curl/zstd noise', async () => {
222
- // Reproduce the runner setup: a configured cache daemon whose
223
- // probe fails for an uncached key. (We point at a closed port so
224
- // curl errors instantly with no server handle to leak — the fix
225
- // silences curl's stderr regardless of the failure mode, exactly
226
- // as it does for the 404 seen in production.) The probe must fall
227
- // through to cold quietly: no "curl:" or "unexpected end of file".
228
- const { stdout, combined } = await runIosPull(writeSpmFixture, {
221
+ test('a daemon that is not listening is named once, and leaks no raw noise', async () => {
222
+ // A closed port: curl errors instantly with no server handle to leak.
223
+ //
224
+ // This used to assert that the output contained no `curl:` at all —
225
+ // curl's stderr was silenced whatever the failure mode was, which is
226
+ // how a 401, a 500 and a dead daemon all came to print the same
227
+ // `No cache found` as an empty cache. The contract is now narrower: a
228
+ // *miss* is silent (the 404 case, covered against a real listener in
229
+ // cache-pull-daemon.test.ts), a *failure* is named exactly once, and
230
+ // neither leaks the raw pipeline chatter that made the old blanket
231
+ // silence attractive in the first place.
232
+ const { stdout, stderr, combined } = await runIosPull(writeSpmFixture, {
229
233
  CIBUILD_CACHE_DAEMON: 'http://127.0.0.1:1',
230
234
  });
231
- expect(combined).not.toContain('curl:');
235
+ expect(stdout).toContain('Warning: cache-pull could not restore');
236
+ expect(stdout).toMatch(/Failed to connect|Couldn't connect|Connection refused/);
237
+ // Once, not once per request the step happens to make.
238
+ expect(stdout.match(/Warning: cache-pull could not restore/gu)).toHaveLength(1);
239
+ // Whatever curl said arrives inside that one line, on stdout. Nothing
240
+ // reaches the build log as loose interleaved stderr, and zstd/tar still
241
+ // do not editorialise about a stream that never arrived.
242
+ expect(stderr).not.toContain('curl:');
232
243
  expect(combined).not.toContain('unexpected end of file');
233
244
  // Still resolves the SPM key and reports a cold miss.
234
245
  expect(stdout).toContain('CACHE_SOURCE=cold');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invarn/cibuild",
3
- "version": "2.5.9",
3
+ "version": "2.6.1",
4
4
  "description": "CI Build CLI — local pipeline orchestration and validation",
5
5
  "type": "module",
6
6
  "main": "dist/cli.cjs",