@scrymore/scry-deployer 0.9.1-next.20260927093719 → 0.9.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/bin/cli.js CHANGED
@@ -400,7 +400,15 @@ async function runDeployment(argv) {
400
400
  }
401
401
  );
402
402
  const uploadMs = uploadTimer.stop();
403
- logger.success('✅ Archive uploaded.');
403
+ // A metadata upload that failed is the essential part of this deploy
404
+ // failing: no success line may follow it (RCA 2: the log printed
405
+ // "Archive uploaded" and "Upload complete" after the timeout).
406
+ const metadataFailed = uploadResult?.metadataUpload?.success === false;
407
+ if (metadataFailed) {
408
+ logger.info('Storybook ZIP uploaded; the metadata upload failed (see above and below).');
409
+ } else {
410
+ logger.success('✅ Archive uploaded.');
411
+ }
404
412
  logger.debug(`Upload result: ${JSON.stringify(uploadResult)}`);
405
413
 
406
414
  await postPRComment(buildDeployResult(argv, coverageSummary, uploadResult), coverageSummary);
@@ -414,8 +422,8 @@ async function runDeployment(argv) {
414
422
  // credential — and this command previously printed
415
423
  // "Deployment successful" over it, sending people looking for the
416
424
  // cause three steps downstream (ISSUES.md #4).
417
- logger.success('\n✅ Upload complete.');
418
- logUploadLinks(argv, coverageSummary, uploadResult, logger);
425
+ if (!metadataFailed) logger.success('\n✅ Upload complete.');
426
+ logUploadLinks(argv, coverageSummary, uploadResult, logger, { indexed: !metadataFailed });
419
427
 
420
428
  reportIndexingOutcome({ argv, analysis, uploadResult, emptyArchive, sbcovFailure, dropped, logger });
421
429
 
@@ -475,9 +483,15 @@ function reportIndexingOutcome({ argv, analysis, uploadResult, emptyArchive, sbc
475
483
  // check tested only that the object existed, printed "uploaded but
476
484
  // not queued" and exited 0.
477
485
  process.exitCode = 1;
486
+ const zip = uploadResult?.zipUpload || {};
487
+ const build = zip.buildId
488
+ ? `Build ${zip.buildId}${zip.buildNumber !== undefined ? ` (#${zip.buildNumber})` : ''}`
489
+ : 'The build';
478
490
  logger.error(
479
491
  `\n❌ The metadata upload failed (${metadataUpload.error || 'no reason given'}), so NOTHING WILL BE INDEXED.\n` +
480
- HOSTED_NOT_SEARCHABLE
492
+ HOSTED_NOT_SEARCHABLE + '\n' +
493
+ ` ${build} is left pending: the upload service has no way to mark it failed,\n` +
494
+ ' so it will not show as failed. Re-run this job to index it.'
481
495
  );
482
496
  return;
483
497
  }
@@ -1102,10 +1116,14 @@ function buildDeployResult(argv, coverageSummary, uploadResult) {
1102
1116
  };
1103
1117
  }
1104
1118
 
1105
- function logUploadLinks(argv, coverageSummary, uploadResult, logger) {
1119
+ function logUploadLinks(argv, coverageSummary, uploadResult, logger, { indexed = true } = {}) {
1106
1120
  const deployResult = buildDeployResult(argv, coverageSummary, uploadResult);
1107
1121
 
1108
- logger.success('\n✅ Storybook hosted.\n');
1122
+ if (indexed) {
1123
+ logger.success('\n✅ Storybook hosted.\n');
1124
+ } else {
1125
+ logger.info('\nStorybook hosted (browsable, NOT indexed):\n');
1126
+ }
1109
1127
  logger.info(`📖 Storybook: ${deployResult.viewUrl}`);
1110
1128
  if (deployResult.coverageUrl) {
1111
1129
  logger.info(`📊 Coverage: ${deployResult.coverageUrl}`);
package/lib/apiClient.js CHANGED
@@ -22,6 +22,12 @@ const COVERAGE_RETRY_DELAY_MS = 60000;
22
22
  const UPLOAD_MAX_ATTEMPTS = 4;
23
23
  const UPLOAD_BACKOFF_BASE_MS = 2000;
24
24
 
25
+ // Bodies POSTed through the upload service (metadata ZIP, coverage JSON).
26
+ const SERVICE_UPLOAD_MAX_ATTEMPTS = 3;
27
+ const UPLOAD_TIMEOUT_FLOOR_MS = 60000;
28
+ const UPLOAD_MIN_BYTES_PER_S = 100 * 1024;
29
+ const UPLOAD_TIMEOUT_CAP_MS = 15 * 60 * 1000;
30
+
25
31
  // Network-level failures that a later attempt can plausibly survive. Flapping
26
32
  // DNS reports EAI_AGAIN (and, mid-flap, ENOTFOUND) rather than a clean refusal.
27
33
  const TRANSIENT_CODES = new Set([
@@ -93,6 +99,80 @@ async function withUploadRetry(attemptFn, label) {
93
99
  throw lastError;
94
100
  }
95
101
 
102
+ /**
103
+ * How long one upload through the upload service may take: max(60 s,
104
+ * bytes / 100 KB/s), capped at 15 min (RCA 2 of storybook-preview-ci-runtime).
105
+ * A fixed 60 s failed a 28 MB metadata ZIP on a 0.4-0.85 MB/s runner uplink.
106
+ * SCRY_UPLOAD_TIMEOUT_FLOOR_MS lowers or raises the 60 s floor (tests, very
107
+ * slow links).
108
+ *
109
+ * @param {number} bytes
110
+ * @param {NodeJS.ProcessEnv} [env]
111
+ * @returns {number} milliseconds
112
+ */
113
+ function uploadTimeoutMs(bytes, env = process.env) {
114
+ const floorRaw = Number(env.SCRY_UPLOAD_TIMEOUT_FLOOR_MS);
115
+ const floor = Number.isFinite(floorRaw) && floorRaw > 0 ? floorRaw : UPLOAD_TIMEOUT_FLOOR_MS;
116
+ const scaled = Math.ceil((Math.max(0, bytes) / UPLOAD_MIN_BYTES_PER_S) * 1000);
117
+ return Math.min(UPLOAD_TIMEOUT_CAP_MS, Math.max(floor, scaled));
118
+ }
119
+
120
+ function formatBytes(n) {
121
+ if (n >= 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
122
+ if (n >= 1024) return `${(n / 1024).toFixed(1)} KB`;
123
+ return `${n} B`;
124
+ }
125
+
126
+ function describeUploadError(error) {
127
+ if (error?.response?.status) return `HTTP ${error.response.status}`;
128
+ if (error?.code === 'ECONNABORTED' || /timeout/i.test(String(error?.message))) return `timeout: ${error.message}`;
129
+ return error?.code || error?.message || 'unknown error';
130
+ }
131
+
132
+ /**
133
+ * POST a body through the upload service with a size-scaled timeout, retrying
134
+ * timeouts, 5xx/429 and network errors (never other 4xx) up to 3 attempts
135
+ * with backoff. Every attempt is logged with its size and elapsed time, so a
136
+ * slow link reads as slow, not hung. Throws the last error.
137
+ *
138
+ * @param {axios.AxiosInstance} apiClient
139
+ * @param {string} url
140
+ * @param {Buffer|string|object} body
141
+ * @param {{label:string, bytes:number, headers:object, log?:{info:Function}}} o
142
+ */
143
+ async function postThroughService(apiClient, url, body, { label, bytes, headers, log = logger }) {
144
+ const timeout = uploadTimeoutMs(bytes);
145
+ const backoffRaw = Number(process.env.SCRY_UPLOAD_BACKOFF_MS);
146
+ const backoffBase = Number.isFinite(backoffRaw) && backoffRaw >= 0 ? backoffRaw : UPLOAD_BACKOFF_BASE_MS;
147
+ const size = formatBytes(bytes);
148
+ let lastError;
149
+ for (let attempt = 1; attempt <= SERVICE_UPLOAD_MAX_ATTEMPTS; attempt++) {
150
+ const started = Date.now();
151
+ log.info(`${label}: attempt ${attempt}/${SERVICE_UPLOAD_MAX_ATTEMPTS}, ${size}, timeout ${Math.round(timeout / 1000)} s...`);
152
+ try {
153
+ const response = await apiClient.post(url, body, {
154
+ headers,
155
+ timeout,
156
+ maxContentLength: 100 * 1024 * 1024,
157
+ maxBodyLength: 100 * 1024 * 1024,
158
+ });
159
+ log.info(`${label}: sent ${size} in ${((Date.now() - started) / 1000).toFixed(1)} s (attempt ${attempt}/${SERVICE_UPLOAD_MAX_ATTEMPTS}).`);
160
+ return response;
161
+ } catch (error) {
162
+ lastError = error;
163
+ const elapsed = ((Date.now() - started) / 1000).toFixed(1);
164
+ if (!isTransientUploadError(error) || attempt === SERVICE_UPLOAD_MAX_ATTEMPTS) {
165
+ log.info(`${label}: attempt ${attempt}/${SERVICE_UPLOAD_MAX_ATTEMPTS} (${size}) failed after ${elapsed} s (${describeUploadError(error)}).`);
166
+ throw error;
167
+ }
168
+ const backoff = backoffBase * Math.pow(2, attempt - 1);
169
+ log.info(`${label}: attempt ${attempt}/${SERVICE_UPLOAD_MAX_ATTEMPTS} (${size}) failed after ${elapsed} s (${describeUploadError(error)}); retrying in ${backoff / 1000} s...`);
170
+ await sleep(backoff);
171
+ }
172
+ }
173
+ throw lastError;
174
+ }
175
+
96
176
  /**
97
177
  * Creates a pre-configured axios instance for making API calls.
98
178
  * @param {string} apiUrl The base URL of the API.
@@ -329,14 +409,12 @@ async function uploadCoverageReportDirectly(apiClient, target, coverageReport) {
329
409
  logger.debug(`Uploading coverage report for ${projectName}/${versionName}`);
330
410
 
331
411
  try {
332
- const response = await apiClient.post(
412
+ const json = JSON.stringify(coverageReport);
413
+ const response = await postThroughService(
414
+ apiClient,
333
415
  `/upload/${projectName}/${versionName}/coverage`,
334
- coverageReport,
335
- {
336
- headers: {
337
- 'Content-Type': 'application/json',
338
- },
339
- }
416
+ json,
417
+ { label: 'Coverage report', bytes: Buffer.byteLength(json), headers: { 'Content-Type': 'application/json' } },
340
418
  );
341
419
 
342
420
  logger.debug(`Coverage upload response status: ${response.status}`);
@@ -402,10 +480,11 @@ async function uploadMetadataZip(apiClient, target, metadataZipPath, customLogge
402
480
 
403
481
  try {
404
482
  const fileBuffer = fs.readFileSync(metadataZipPath);
405
- const response = await apiClient.post(url, fileBuffer, {
483
+ const response = await postThroughService(apiClient, url, fileBuffer, {
484
+ label: 'Metadata ZIP',
485
+ bytes: fileBuffer.length,
406
486
  headers: { 'Content-Type': 'application/zip' },
407
- maxContentLength: 100 * 1024 * 1024,
408
- maxBodyLength: 100 * 1024 * 1024,
487
+ log: customLogger,
409
488
  });
410
489
 
411
490
  const data = response.data || {};
@@ -458,19 +537,27 @@ async function uploadBuild(apiClient, target, options) {
458
537
  // failure in the optional artifact was silently taking down the essential
459
538
  // one. Observed on a 467-story library: every story captured, nothing
460
539
  // indexed, twice.
540
+ // Timeouts, 5xx and network errors are retried inside the upload. The
541
+ // one 4xx worth waiting for is the build record not existing yet (the
542
+ // presigned call creates it), so only that gets the old 60 s second try.
543
+ const coverageFailed = (err) => logger.warn(
544
+ `⚠️ Coverage report upload failed: ${err.message}\n` +
545
+ ' Continuing — this affects the coverage report only. Screenshots and\n' +
546
+ ' metadata still upload, so components will still be indexed.'
547
+ );
461
548
  try {
462
549
  coverageUpload = await uploadCoverageReportDirectly(apiClient, target, options.coverageReport);
463
550
  } catch (error) {
464
- logger.info('Coverage upload failed; retrying in 60s...');
465
- await sleep(COVERAGE_RETRY_DELAY_MS);
466
- try {
467
- coverageUpload = await uploadCoverageReportDirectly(apiClient, target, options.coverageReport);
468
- } catch (retryError) {
469
- logger.warn(
470
- `⚠️ Coverage report upload failed: ${retryError.message}\n` +
471
- ' Continuing — this affects the coverage report only. Screenshots and\n' +
472
- ' metadata still upload, so components will still be indexed.'
473
- );
551
+ if (error.statusCode === 404 && /Build not found/.test(error.message)) {
552
+ logger.info('Coverage upload found no build yet; retrying in 60s...');
553
+ await sleep(COVERAGE_RETRY_DELAY_MS);
554
+ try {
555
+ coverageUpload = await uploadCoverageReportDirectly(apiClient, target, options.coverageReport);
556
+ } catch (retryError) {
557
+ coverageFailed(retryError);
558
+ }
559
+ } else {
560
+ coverageFailed(error);
474
561
  }
475
562
  }
476
563
  }
@@ -502,5 +589,8 @@ module.exports = {
502
589
  putToPresignedUrl,
503
590
  isTransientUploadError,
504
591
  withUploadRetry,
592
+ uploadTimeoutMs,
593
+ postThroughService,
505
594
  UPLOAD_MAX_ATTEMPTS,
595
+ SERVICE_UPLOAD_MAX_ATTEMPTS,
506
596
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scrymore/scry-deployer",
3
- "version": "0.9.1-next.20260927093719",
3
+ "version": "0.9.1",
4
4
  "description": "A CLI to automate the deployment of Storybook static builds.",
5
5
  "main": "index.js",
6
6
  "bin": {