@invarn/cibuild 2.6.0 → 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,6 +86,126 @@ 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
+ }
89
209
  /** The token `%{http_code}` is reported under; see `daemonCurl`. */
90
210
  const DAEMON_STATUS_TOKEN = 'ci_http_status';
91
211
  /**
@@ -182,16 +302,53 @@ function daemonCurlKeepingErrorBody(args) {
182
302
  * reported success. Every curated Invarn template uses the explicit-key form,
183
303
  * so nothing was ever stored by any of them. One definition cannot diverge
184
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.
185
319
  */
186
320
  function emitDaemonPushCommands() {
187
321
  return [
188
- ` 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`,
189
326
  ' echo "Cache created successfully"',
190
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[@]}")',
191
331
  // Never fatal: a build that produced good output should not be failed by
192
332
  // a cache upload, and the next build simply misses. Saying *why* costs
193
333
  // nothing and is the difference between a warning and a diagnosis.
194
- " __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',
195
352
  ' __ci_push_msg=${__ci_push_msg:0:300}',
196
353
  ' if [ -n "$__ci_push_msg" ]; then',
197
354
  ' echo "Warning: failed to upload cache to the daemon — $__ci_push_msg"',
@@ -199,6 +356,7 @@ function emitDaemonPushCommands() {
199
356
  ' echo "Warning: failed to upload cache to the daemon"',
200
357
  ' fi',
201
358
  ' fi',
359
+ ' rm -f "$__ci_push_out"',
202
360
  ];
203
361
  }
204
362
  /**
@@ -221,7 +379,7 @@ function emitDaemonPullCommands() {
221
379
  // case and they both editorialise about it; pipefail still carries the
222
380
  // exit status. curl's own stderr is kept, in the file the diagnostics
223
381
  // above read: it is the only thing that knows *why*.
224
- `if { ${daemonCurl('"$CIBUILD_CACHE_DAEMON/cache/$CACHE_KEY.tar.zst" 2>"$__ci_cache_diag"')} | zstd -dc | tar -xf - -C /; } 2>/dev/null; then`,
382
+ `if { ${daemonCurl('"$CIBUILD_CACHE_DAEMON/cache/$CACHE_KEY.tar.zst" 2>"$__ci_cache_diag"')} | zstd -dc | __ci_cache_extract; } 2>/dev/null; then`,
225
383
  ' echo "Cache found (daemon), extracting..."',
226
384
  ' echo "CACHE_SOURCE=daemon CACHE_KEY=$CACHE_KEY"',
227
385
  'else',
@@ -243,7 +401,7 @@ function emitDaemonPullCommands() {
243
401
  // than seed from ancient state. Exact-key hits are unaffected.
244
402
  ' if [ "$__ci_fb_age_days" -le 30 ]; then',
245
403
  ' echo "Cache fallback (daemon): using prior tarball $__ci_fb_key (${__ci_fb_age_days}d old)"',
246
- ` if { ${daemonCurl('"$CIBUILD_CACHE_DAEMON/cache/$__ci_fb_key.tar.zst" 2>"$__ci_cache_diag"')} | 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`,
247
405
  ' echo "CACHE_SOURCE=fallback_daemon CACHE_KEY=$CACHE_KEY FALLBACK_KEY=$__ci_fb_key"',
248
406
  ' else',
249
407
  ' echo "Cache fallback $__ci_fb_key could not be fetched — going cold"',
@@ -326,6 +484,11 @@ export class CachePullStepExecutor extends BaseStepExecutor {
326
484
  commands.push(`PEER_CACHE_DIR="${this.escapeBash(peerCacheDir)}"`);
327
485
  commands.push(`PEER_CACHE_FILE="$PEER_CACHE_DIR/${this.escapeBash(cacheKey)}.tar.zst"`);
328
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));
329
492
  // Check if cache file exists
330
493
  commands.push('');
331
494
  commands.push('# Check if cache exists');
@@ -333,7 +496,7 @@ export class CachePullStepExecutor extends BaseStepExecutor {
333
496
  // to fall back to, so the daemon side is the exact key or nothing.
334
497
  commands.push('if [ -n "$CIBUILD_CACHE_DAEMON" ]; then');
335
498
  commands.push(...emitDaemonPullDiagnostics(' '));
336
- commands.push(` if { ${daemonCurl('"$CIBUILD_CACHE_DAEMON/cache/$CACHE_KEY.tar.zst" 2>"$__ci_cache_diag"')} | zstd -dc | tar -xf - -C /; } 2>/dev/null; then`);
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`);
337
500
  commands.push(' echo "Cache found (daemon), extracting..."');
338
501
  commands.push(' echo "CACHE_SOURCE=daemon"');
339
502
  commands.push(' else');
@@ -348,13 +511,13 @@ export class CachePullStepExecutor extends BaseStepExecutor {
348
511
  if (isDebugMode) {
349
512
  commands.push(' echo "Cache file size: $(du -h "$CACHE_FILE" | cut -f1)"');
350
513
  }
351
- commands.push(' zstd -dc "$CACHE_FILE" | tar -xf - -C /');
514
+ commands.push(' zstd -dc "$CACHE_FILE" | __ci_cache_extract');
352
515
  commands.push(' echo "CACHE_SOURCE=local"');
353
516
  // Peer fallback
354
517
  if (peerCacheDir) {
355
518
  commands.push('elif [ -f "$PEER_CACHE_FILE" ] 2>/dev/null; then');
356
519
  commands.push(' echo "Cache found (peer), extracting..."');
357
- 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');
358
521
  commands.push(' mv "$CACHE_FILE.tmp" "$CACHE_FILE"');
359
522
  commands.push(' echo "CACHE_SOURCE=peer"');
360
523
  }
@@ -446,6 +609,13 @@ export class CachePullStepExecutor extends BaseStepExecutor {
446
609
  }
447
610
  commands.push('CACHE_FILE="$CACHE_DIR/$CACHE_KEY.tar.zst"');
448
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('');
449
619
  // Transport split. On a runner the cache lives on the host and is reached
450
620
  // over HTTP; standalone, it is a directory on this machine. The daemon URL
451
621
  // is the switch, so the same generated script serves both.
@@ -465,7 +635,7 @@ export class CachePullStepExecutor extends BaseStepExecutor {
465
635
  if (isDebugMode) {
466
636
  commands.push(' echo "Cache file size: $(du -h "$CACHE_FILE" | cut -f1)"');
467
637
  }
468
- commands.push(' zstd -dc "$CACHE_FILE" | tar -xf - -C /');
638
+ commands.push(' zstd -dc "$CACHE_FILE" | __ci_cache_extract');
469
639
  // LRU bump: lift this tarball to the top of `ls -t` so retention's age
470
640
  // cap counts last-used time, not last-written. Without this, a tarball
471
641
  // hit daily would still be reaped on its 31st day from creation.
@@ -479,7 +649,7 @@ export class CachePullStepExecutor extends BaseStepExecutor {
479
649
  commands.push(' echo "Peer cache file size: $(du -h "$PEER_CACHE_FILE" | cut -f1)"');
480
650
  }
481
651
  // Single NFS read: tee forks the stream to local cache file AND decompression
482
- 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');
483
653
  commands.push(' mv "$CACHE_FILE.tmp" "$CACHE_FILE"');
484
654
  commands.push(' echo "CACHE_SOURCE=peer CACHE_KEY=$CACHE_KEY"');
485
655
  }
@@ -518,7 +688,7 @@ export class CachePullStepExecutor extends BaseStepExecutor {
518
688
  if (isDebugMode) {
519
689
  commands.push(' echo "Fallback size: $(du -h "$__ci_fb" | cut -f1)"');
520
690
  }
521
- commands.push(' zstd -dc "$__ci_fb" | tar -xf - -C /');
691
+ commands.push(' zstd -dc "$__ci_fb" | __ci_cache_extract');
522
692
  // LRU bump for the fallback tarball — we restored from it, so it earns
523
693
  // its place at the top of the retention sort even if the exact-key
524
694
  // tarball is the one that gets created in this run's cache-push.
@@ -623,23 +793,11 @@ export class CachePushStepExecutor extends BaseStepExecutor {
623
793
  commands.push('# Check which paths exist');
624
794
  commands.push('PATHS_TO_CACHE=()');
625
795
  for (const path of cachePaths) {
626
- // Resolve path identifiers (e.g., "derived_data:pattern" -> "~/Library/.../DerivedData/pattern")
627
- let resolvedPath = path;
628
- // Check if path uses identifier format (identifier:pattern)
629
- if (path.includes(':')) {
630
- const [identifier, pattern] = path.split(':', 2);
631
- switch (identifier) {
632
- case 'derived_data':
633
- resolvedPath = `${config.paths.derivedDataDir}/${pattern}`;
634
- break;
635
- case 'builds':
636
- resolvedPath = `${config.paths.buildsDir}/${pattern}`;
637
- break;
638
- default:
639
- // Unknown identifier, use as-is
640
- resolvedPath = path;
641
- }
642
- }
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);
643
801
  // Expand tilde by using parameter expansion
644
802
  commands.push(`EXPANDED_PATH="${this.escapeBash(resolvedPath)}"`);
645
803
  commands.push('EXPANDED_PATH="${EXPANDED_PATH/#~/${CIBUILD_USER_HOME:-$HOME}}"');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invarn/cibuild",
3
- "version": "2.6.0",
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",