@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.
- package/dist/cli.cjs +10 -10
- package/dist/src/envman/envstore-round-trip.test.d.ts +27 -0
- package/dist/src/envman/envstore-round-trip.test.d.ts.map +1 -0
- package/dist/src/envman/envstore-round-trip.test.js +130 -0
- package/dist/src/envman/shim.d.ts +16 -0
- package/dist/src/envman/shim.d.ts.map +1 -0
- package/dist/src/envman/shim.js +66 -0
- package/dist/src/runner.d.ts.map +1 -1
- package/dist/src/runner.js +2 -50
- package/dist/src/yaml/steps/cache-pull-daemon.test.d.ts +27 -0
- package/dist/src/yaml/steps/cache-pull-daemon.test.d.ts.map +1 -0
- package/dist/src/yaml/steps/cache-pull-daemon.test.js +203 -0
- package/dist/src/yaml/steps/cache-pull-restore-roots.test.d.ts +42 -0
- package/dist/src/yaml/steps/cache-pull-restore-roots.test.d.ts.map +1 -0
- package/dist/src/yaml/steps/cache-pull-restore-roots.test.js +200 -0
- package/dist/src/yaml/steps/cache-push-daemon.test.js +32 -2
- package/dist/src/yaml/steps/cache.d.ts.map +1 -1
- package/dist/src/yaml/steps/cache.js +259 -30
- package/dist/src/yaml/steps/steps.test.js +20 -9
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
160
|
-
//
|
|
161
|
-
|
|
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 |
|
|
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(
|
|
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" |
|
|
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 |
|
|
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" |
|
|
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 |
|
|
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" |
|
|
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
|
-
|
|
557
|
-
//
|
|
558
|
-
|
|
559
|
-
|
|
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
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
|
|
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(
|
|
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');
|