@invarn/cibuild 2.5.9 → 2.6.0

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,73 @@ function resolvePresetChain(technology) {
86
86
  }
87
87
  return chain;
88
88
  }
89
+ /** The token `%{http_code}` is reported under; see `daemonCurl`. */
90
+ const DAEMON_STATUS_TOKEN = 'ci_http_status';
89
91
  /**
90
92
  * Emits the `curl` invocation the cache steps talk to the daemon with. The
91
93
  * bearer token is per-build and only present on the Invarn runner, so it is
92
94
  * expanded conditionally and the same script works without one.
95
+ *
96
+ * Every request also reports the HTTP status it got, on **stderr** — the
97
+ * response body is the tarball and goes to stdout, so a status written there
98
+ * would corrupt it. `%{stderr}` is curl 7.63+ (2018); an older curl prints the
99
+ * token literally instead of a number, which the reader below tolerates.
100
+ *
101
+ * `-f`, not `--fail-with-body`: on the pull side the error body would be piped
102
+ * straight into `zstd`. The status is what distinguishes the answers anyway,
103
+ * and it now arrives without the body.
93
104
  */
94
105
  function daemonCurl(args) {
95
- return `curl -fsS \${CIBUILD_CACHE_TOKEN:+-H "Authorization: Bearer $CIBUILD_CACHE_TOKEN"} ${args}`;
106
+ return `curl -fsS -w '%{stderr}${DAEMON_STATUS_TOKEN}=%{http_code}\\n' \${CIBUILD_CACHE_TOKEN:+-H "Authorization: Bearer $CIBUILD_CACHE_TOKEN"} ${args}`;
107
+ }
108
+ /**
109
+ * Emitted once inside a daemon pull branch, before the first fetch: a file for
110
+ * curl's stderr, and the reader that turns it into one line — or into nothing
111
+ * at all when the daemon simply had no such entry.
112
+ *
113
+ * `{ curl | zstd | tar; } 2>/dev/null` used to swallow curl's message
114
+ * entirely, so a 401, a 400, a 503, a 500 and a daemon that was not listening
115
+ * all printed the same `No cache found for key: …` as an empty cache. That is
116
+ * the same class as the push-side defect: the instrument reported the normal
117
+ * case for every abnormal one.
118
+ *
119
+ * **A miss stays quiet and a failure gets loud.** A cold cache is the normal
120
+ * case and must not start printing errors; the point is only that a broken
121
+ * daemon stops looking like one.
122
+ */
123
+ function emitDaemonPullDiagnostics(indent = '') {
124
+ return [
125
+ `${indent}__ci_cache_diag="\${TMPDIR:-/tmp}/cibuild-cache-pull.$$.diag"`,
126
+ `${indent}__ci_cache_daemon_said() {`,
127
+ `${indent} local __said=''`,
128
+ `${indent} if [ -s "$__ci_cache_diag" ]; then`,
129
+ `${indent} __said=$(tr '\\n' ' ' < "$__ci_cache_diag")`,
130
+ `${indent} fi`,
131
+ `${indent} case "$__said" in`,
132
+ // 404 is the daemon's answer for `cache_miss` and `no_cache_for_scope` —
133
+ // an empty cache, which is normal. The second pattern reads curl's own
134
+ // English for a curl too old to honour `%{stderr}`, so neither reader
135
+ // failing alone can turn a cold cache noisy.
136
+ `${indent} *${DAEMON_STATUS_TOKEN}=404*|*'error: 404'*) __said='' ;;`,
137
+ `${indent} esac`,
138
+ // Always exits 0. A non-zero return would be the assignment's status at
139
+ // every call site, and under errexit that ends the step on a cache miss.
140
+ `${indent} printf '%s' "\${__said:0:300}"`,
141
+ `${indent}}`,
142
+ ];
143
+ }
144
+ /**
145
+ * The `else` arm of a daemon fetch: says why it did not restore, unless the
146
+ * daemon simply had no such entry. Never fatal — a build that cannot read a
147
+ * cache still builds, it just builds cold.
148
+ */
149
+ function emitDaemonPullFailureDiagnosis(indent, what) {
150
+ return [
151
+ `${indent}__ci_cache_why=$(__ci_cache_daemon_said)`,
152
+ `${indent}if [ -n "$__ci_cache_why" ]; then`,
153
+ `${indent} echo "Warning: cache-pull could not restore ${what} from the daemon — $__ci_cache_why"`,
154
+ `${indent}fi`,
155
+ ];
96
156
  }
97
157
  /**
98
158
  * The same request, keeping the response body when the daemon refuses.
@@ -156,12 +216,18 @@ function emitDaemonPushCommands() {
156
216
  function emitDaemonPullCommands() {
157
217
  return [
158
218
  '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`,
219
+ ...emitDaemonPullDiagnostics(),
220
+ // Exact key. zstd's and tar's stderr is silenced — a miss is the normal
221
+ // case and they both editorialise about it; pipefail still carries the
222
+ // exit status. curl's own stderr is kept, in the file the diagnostics
223
+ // 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`,
162
225
  ' echo "Cache found (daemon), extracting..."',
163
226
  ' echo "CACHE_SOURCE=daemon CACHE_KEY=$CACHE_KEY"',
164
227
  'else',
228
+ // Said before the fallback is attempted, so the reason names the request
229
+ // it belongs to rather than whichever request happened to fail last.
230
+ ...emitDaemonPullFailureDiagnosis(' ', '$CACHE_KEY'),
165
231
  // Scope fallback: the newest tarball for this <keyPrefix>-<projectId>.
166
232
  // Gradle/Xcode re-hash their inputs on top of the warm directory, hitting
167
233
  // the unchanged work and rebuilding only what actually moved.
@@ -177,10 +243,11 @@ function emitDaemonPullCommands() {
177
243
  // than seed from ancient state. Exact-key hits are unaffected.
178
244
  ' if [ "$__ci_fb_age_days" -le 30 ]; then',
179
245
  ' 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`,
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`,
181
247
  ' echo "CACHE_SOURCE=fallback_daemon CACHE_KEY=$CACHE_KEY FALLBACK_KEY=$__ci_fb_key"',
182
248
  ' else',
183
249
  ' echo "Cache fallback $__ci_fb_key could not be fetched — going cold"',
250
+ ...emitDaemonPullFailureDiagnosis(' ', '$__ci_fb_key'),
184
251
  ' echo "CACHE_SOURCE=cold CACHE_KEY=$CACHE_KEY"',
185
252
  ' fi',
186
253
  ' else',
@@ -192,6 +259,7 @@ function emitDaemonPullCommands() {
192
259
  ' echo "CACHE_SOURCE=cold CACHE_KEY=$CACHE_KEY"',
193
260
  ' fi',
194
261
  'fi',
262
+ 'rm -f "$__ci_cache_diag"',
195
263
  ];
196
264
  }
197
265
  /**
@@ -264,13 +332,16 @@ export class CachePullStepExecutor extends BaseStepExecutor {
264
332
  // Transport split — see the preset path. An explicit-key step has no scope
265
333
  // to fall back to, so the daemon side is the exact key or nothing.
266
334
  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`);
335
+ 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`);
268
337
  commands.push(' echo "Cache found (daemon), extracting..."');
269
338
  commands.push(' echo "CACHE_SOURCE=daemon"');
270
339
  commands.push(' else');
340
+ commands.push(...emitDaemonPullFailureDiagnosis(' ', '$CACHE_KEY'));
271
341
  commands.push(` echo "No cache found for key: ${this.escapeBash(cacheKey)}"`);
272
342
  commands.push(' echo "CACHE_SOURCE=cold"');
273
343
  commands.push(' fi');
344
+ commands.push(' rm -f "$__ci_cache_diag"');
274
345
  commands.push('else');
275
346
  commands.push('if [ -f "$CACHE_FILE" ]; then');
276
347
  commands.push(' echo "Cache found (local), extracting..."');
@@ -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.0",
4
4
  "description": "CI Build CLI — local pipeline orchestration and validation",
5
5
  "type": "module",
6
6
  "main": "dist/cli.cjs",