@scrymore/scry-deployer 0.8.0 → 0.9.0-next.20260927093344

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/README.md CHANGED
@@ -217,6 +217,9 @@ The CLI is configured through a combination of command-line options and environm
217
217
  | `--capture-mode` | `SCRY_CAPTURE_MODE` | Screenshot framing forwarded to scry-sbcov: `root` (crop to the component) or `viewport`. | No | unset: sbcov decides (`root` from sbcov 0.6) |
218
218
  | `--capture-scale` | `SCRY_CAPTURE_SCALE` | Screenshot device scale factor forwarded to scry-sbcov, `0 < n <= 4`. | No | unset: sbcov decides (`2` from sbcov 0.6) |
219
219
  | `--capture-viewport` | `SCRY_CAPTURE_VIEWPORT` | Browser viewport `WIDTHxHEIGHT` forwarded to scry-sbcov. | No | unset: sbcov decides (`1280x720`) |
220
+ | - | `SCRY_CONCURRENCY` (or `concurrency` in `.storybook-deployer.json`) | Stories scry-sbcov renders at once (`--concurrency`, 1-32). Forwarded only to scry-sbcov 0.7+; with an older one the log says it was not applied. | No | unset: sbcov decides (`4` from 0.7) |
221
+ | - | `SCRY_RENDER_TIMEOUT_MS` (or `renderTimeoutMs`) | How long a story may take to show something before it is written off (`--render-timeout`, 100-600000 ms). sbcov 0.7+ only, as above. | No | unset: sbcov decides (`5000` from 0.7) |
222
+ | - | `SCRY_EXECUTE_BUDGET_BASE_S`, `SCRY_EXECUTE_BUDGET_PER_STORY_S` | Story execution budget = base + per story × declared stories. Over it: a warning (`::warning::` in GitHub Actions), never a failure. See "CI time". | No | `120` and `0.5` |
220
223
  | `--verbose` | `STORYBOOK_DEPLOYER_VERBOSE` | Enable verbose logging for debugging purposes. | No | `false` |
221
224
  | - | `SCRY_NO_UPDATE_CHECK=1` | Skip the check against npm `latest` (a one-line warning when this deployer is older; 2 s limit, never fails the deploy). | No | check on |
222
225
  | `--help`, `-h` | - | Show the help message. | - | - |
@@ -249,6 +252,38 @@ which does not know the flag, it is not passed and the log says dropped stories
249
252
  Before 0.7.0 the metadata-upload failure, the "not queued" case, an empty archive, a non-zero
250
253
  scry-sbcov exit and a workflow that simply forgot `--with-analysis` all ended green (ISSUES.md #50).
251
254
 
255
+ ### CI time (0.9.0)
256
+
257
+ Every deploy measures how much CI time Scry took and records it with the build (ISSUES.md #54:
258
+ a 461-story preview ran for 20 minutes and nothing said so). What you see in the log:
259
+
260
+ ```
261
+ Story execution: 461 stories in 3.5 min (4 workers), budget 5.8 min.
262
+ CI time recorded: deployer 4.4 min, job 6.1 min so far (Actions API).
263
+ CI timings: stored with the build.
264
+ ```
265
+
266
+ - **Budget.** Story execution is judged against `120 s + 0.5 s × declared stories`
267
+ (`SCRY_EXECUTE_BUDGET_BASE_S`, `SCRY_EXECUTE_BUDGET_PER_STORY_S`). Over it, the run gets a
268
+ `::warning title=Scry story execution over budget::…` annotation naming the time, the budget and
269
+ where the time went (`timeout 40 s, …`). It is a warning; the exit code does not change.
270
+ - **What is recorded** on the build (`ciTimings`): `analyzeMs`, `executeMs` (from scry-sbcov;
271
+ `executeSource: "deployer-wall"` when an older sbcov does not report it and the whole sbcov run
272
+ is used instead), `archiveMs`, `uploadMs`, `deployerTotalMs`, story counts, time lost per reason,
273
+ sbcov and deployer versions, `runner` (`github-hosted` / `self-hosted` / `unknown`), the Actions
274
+ run id and attempt, `budgetMs` and `overBudget`. A number that could not be measured is left
275
+ out, never sent as 0.
276
+ - **Whole-job time** needs `permissions: actions: read` and `GITHUB_TOKEN` in the deploy step (the
277
+ generated workflows have both). The deployer reads its own job's start time from the Actions API
278
+ (5 s limit). Without it the log says `CI time recorded: deployer time only (job start unknown:
279
+ no-token | forbidden | timeout | not-github | …)` and only the deployer's own time is recorded.
280
+ - **Never fails a deploy.** An upload service without the CI-timings route answers 404: the log
281
+ says `the upload service does not record CI timings yet; not stored` once and the summary line
282
+ reads `CI timings: final record not stored (1)`. A record the service rejects (400) is a warning
283
+ with the reason. Exit codes come from indexing only (table above).
284
+ - The generated workflows also set `timeout-minutes: 20` on the Storybook job, so a stuck run
285
+ stops after 20 minutes instead of GitHub's default 6 hours.
286
+
252
287
  ### Story File Auto-Detection
253
288
 
254
289
  The analysis feature now automatically detects `.stories.*` files anywhere in your project! You no longer need to specify a stories directory - the system intelligently searches for story files with these features:
@@ -598,6 +633,10 @@ The PR preview workflow uses these environment variables (configured via GitHub
598
633
  | `SCRY_VIEW_URL` | GitHub Variable | No | Base URL where users view Storybooks (default: `https://view.scrymore.com`) |
599
634
  | `SCRY_API_KEY` | GitHub Secret | No | API authentication key (if required) |
600
635
  | `STORYBOOK_DEPLOYER_WITH_ANALYSIS` | GitHub Variable | No | Set to `false` to disable build processing service integration (enabled by default in generated workflows) |
636
+ | `SCRY_MAX_DROPPED` | GitHub Variable | No | Stories allowed to fail capture before the deploy ends red (default 0) |
637
+ | `SCRY_CONCURRENCY`, `SCRY_RENDER_TIMEOUT_MS` | GitHub Variable | No | Stories rendered at once (default 4) and how long one may take to show something (default 5000 ms); scry-sbcov 0.7+ |
638
+ | `SCRY_EXECUTE_BUDGET_BASE_S`, `SCRY_EXECUTE_BUDGET_PER_STORY_S` | GitHub Variable | No | Story execution budget (default 120 s + 0.5 s per story); over it = a warning, see "CI time" |
639
+ | `GITHUB_TOKEN` | Actions token | No | Posts the PR comment and, with `permissions: actions: read`, lets the deployer record whole-job CI time |
601
640
 
602
641
  **Important:** `SCRY_API_URL` (where files are uploaded) and `SCRY_VIEW_URL` (where users view the deployed Storybook) are two different URLs:
603
642
  - **API URL**: Backend service endpoint (e.g., `https://api.scrymore.com`)
package/bin/cli.js CHANGED
@@ -24,6 +24,7 @@ const { resolveBuildGitContext } = require('../lib/gitContext.js');
24
24
  const { countMetadataEntries, readSbcovManifest, droppedReasons } = require('../lib/metadataArchive.js');
25
25
  const { checkForNewerVersion } = require('../lib/versionCheck.js');
26
26
  const { version: DEPLOYER_VERSION } = require('../package.json');
27
+ const ciTimings = require('../lib/ciTimings.js');
27
28
 
28
29
  async function runAnalysis(argv) {
29
30
  const logger = createLogger(argv);
@@ -130,7 +131,161 @@ function describeCapture(report) {
130
131
  return { total: typeof total === 'number' ? total : null, firstError };
131
132
  }
132
133
 
134
+ /**
135
+ * The installed scry-sbcov's version, when the deployer runs its own copy.
136
+ * Unknown (undefined) under SCRY_SBCOV_CMD or when it cannot be resolved.
137
+ */
138
+ function installedSbcovVersion() {
139
+ if (process.env.SCRY_SBCOV_CMD) return undefined;
140
+ try {
141
+ return require('@scrymore/scry-sbcov/package.json').version;
142
+ } catch (_) {
143
+ return undefined;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * The pre-upload part of the CI timings record (storybook-preview-ci-runtime,
149
+ * ISSUES.md #54): phases measured so far, story counts, versions, runner, run
150
+ * ids and the execute budget. Anything not measured is left out, never 0.
151
+ */
152
+ function buildPreUploadTimings({ coverage, manifest, archiveMs, env = process.env }) {
153
+ // sbcov 0.7 writes its execution block into the manifest (in the metadata
154
+ // archive) as `execution`, and into the report at `execution.timing`; the
155
+ // report is the fallback when there is no archive (execution without screenshots).
156
+ const fromReport = coverage.coverageReport?.execution?.timing;
157
+ const execution = manifest?.execution
158
+ || (fromReport && typeof fromReport === 'object' && !Array.isArray(fromReport) ? fromReport : null);
159
+ // No report = sbcov crashed or never ran: its wall time is neither an
160
+ // analysis nor an execute time, so neither is recorded.
161
+ const executedTimes = !coverage.coverageReport ? {} : ciTimings.splitSbcovTime({
162
+ sbcovWallMs: coverage.sbcovWallMs ?? null,
163
+ manifestExecution: execution,
164
+ report: coverage.coverageReport,
165
+ executed: Boolean(coverage.executed),
166
+ });
167
+ const stories = coverage.executed
168
+ ? ciTimings.storyCounts({ manifest, manifestExecution: execution, report: coverage.coverageReport })
169
+ : undefined;
170
+ const timeLostMs = ciTimings.timeLost(execution);
171
+ const share = typeof execution?.failedTimeShare === 'number' && execution.failedTimeShare >= 0 && execution.failedTimeShare <= 1
172
+ ? execution.failedTimeShare
173
+ : undefined;
174
+ const concurrency = Number.isInteger(execution?.concurrency) && execution.concurrency > 0 ? execution.concurrency : undefined;
175
+
176
+ let budget = null;
177
+ if (executedTimes.executeMs !== undefined) {
178
+ budget = ciTimings.resolveBudget({ declared: stories?.declared ?? null, env });
179
+ }
180
+ const overBudget = budget && budget.budgetMs !== null ? executedTimes.executeMs > budget.budgetMs : undefined;
181
+
182
+ const record = ciTimings.compact({
183
+ ...executedTimes,
184
+ archiveMs,
185
+ stories,
186
+ timeLostMs,
187
+ failedTimeShare: share,
188
+ concurrency,
189
+ sbcovVersion: typeof manifest?.sbcovVersion === 'string' ? manifest.sbcovVersion.slice(0, 40) : installedSbcovVersion(),
190
+ deployerVersion: DEPLOYER_VERSION,
191
+ runner: ciTimings.detectRunner(env),
192
+ ci: ciTimings.readCiContext(env) || undefined,
193
+ budgetMs: budget?.budgetMs ?? undefined,
194
+ overBudget,
195
+ });
196
+ return { record, budget };
197
+ }
198
+
199
+ /**
200
+ * The duration line, and the budget warning when story execution took longer
201
+ * than its budget (G6). A warning, never a failure: a slow run is information.
202
+ */
203
+ function reportExecutionTime(record, budget, logger, env = process.env) {
204
+ if (budget) {
205
+ for (const w of budget.warnings) logger.warn(`⚠️ ${w}.`);
206
+ }
207
+ if (record.executeMs === undefined) return;
208
+ const fmt = ciTimings.formatDuration;
209
+ const n = record.stories?.declared;
210
+ const stories = typeof n === 'number' ? `${n} ${n === 1 ? 'story' : 'stories'}` : 'stories';
211
+ const workers = record.concurrency ? ` (${record.concurrency} ${record.concurrency === 1 ? 'worker' : 'workers'})` : '';
212
+ const source = record.executeSource === 'deployer-wall' ? ' [sbcov run wall time; this scry-sbcov does not report execution time]' : '';
213
+ const budgetText = record.budgetMs !== undefined ? `, budget ${fmt(record.budgetMs)}` : ', no budget (story count unknown)';
214
+ const lost = ciTimings.describeTimeLost(record.timeLostMs);
215
+ logger.info(`Story execution: ${stories} in ${fmt(record.executeMs)}${workers}${budgetText}${lost ? `; time lost: ${lost}` : ''}${source}.`);
216
+
217
+ if (record.overBudget) {
218
+ const formula = `${budget.baseS} s + ${budget.perStoryS} s × ${typeof n === 'number' ? n : '?'} stories`;
219
+ const detail = `Executing ${stories} took ${fmt(record.executeMs)}, over the ${fmt(record.budgetMs)} budget (${formula})` +
220
+ (lost ? `. Time lost: ${lost}` : '') +
221
+ '. Set SCRY_EXECUTE_BUDGET_BASE_S / SCRY_EXECUTE_BUDGET_PER_STORY_S to change the budget.';
222
+ if (env.GITHUB_ACTIONS === 'true') {
223
+ // A workflow command must start the line, on stdout.
224
+ process.stdout.write(`::warning title=Scry story execution over budget::${detail.replace(/\r?\n/g, ' ')}\n`);
225
+ }
226
+ logger.warn(`⚠️ Story execution took ${fmt(record.executeMs)}, over its ${fmt(record.budgetMs)} budget (${formula}).`);
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Finish the record (upload, total, whole-job time) and send it to the
232
+ * ci-timings route. Never throws and never changes the exit code; every way it
233
+ * can fall short is said once, and counted in the summary line.
234
+ */
235
+ async function recordCiTimings({ apiClient, argv, preUpload, uploadMs, totalTimer, uploadResult, logger }) {
236
+ const summary = { notStored: 0 };
237
+ try {
238
+ const job = await ciTimings.fetchJobElapsed();
239
+ const record = ciTimings.compact({
240
+ ...preUpload,
241
+ uploadMs,
242
+ ...job,
243
+ deployerTotalMs: totalTimer.stop(),
244
+ });
245
+ // The build this deploy created, from the presigned-URL response.
246
+ const buildId = uploadResult?.zipUpload?.buildId;
247
+ const sent = await ciTimings.sendCiTimings(apiClient, { project: argv.project, version: argv.version }, buildId, record);
248
+ if (sent.stored && sent.dropped) {
249
+ summary.droppedFields = sent.dropped.length;
250
+ logger.warn(`⚠️ CI timings: stored, but the upload service dropped ${sent.dropped.length} field(s) it would not accept: ${sent.dropped.join(', ')}.`);
251
+ }
252
+ if (!sent.stored) {
253
+ summary.notStored += 1;
254
+ if (sent.reason === 'not-supported') {
255
+ logger.warn('⚠️ CI timings: the upload service does not record CI timings yet; not stored.');
256
+ } else if (sent.reason === 'rejected') {
257
+ logger.warn(`⚠️ CI timings: the upload service rejected the CI timings (${sent.detail}); not stored.`);
258
+ } else if (sent.reason === 'build-not-found') {
259
+ logger.warn(`⚠️ CI timings: the upload service has the CI-timings route but did not find this build (${sent.detail}); not stored.`);
260
+ } else if (sent.reason === 'no-build-id') {
261
+ logger.warn('⚠️ CI timings: the upload service returned no build id, so the final record could not be sent; not stored.');
262
+ } else {
263
+ logger.warn(`⚠️ CI timings: could not reach the upload service (${sent.detail}); not stored.`);
264
+ }
265
+ }
266
+ const fmt = ciTimings.formatDuration;
267
+ // "recorded" only when the service kept it; otherwise it was measured and printed here.
268
+ const verb = sent.stored ? 'CI time recorded' : 'CI time measured (not stored)';
269
+ if (record.jobTimeSource === 'actions-api') {
270
+ logger.info(`${verb}: deployer ${fmt(record.deployerTotalMs)}, job ${fmt(record.jobElapsedMs)} so far (Actions API).`);
271
+ } else {
272
+ logger.info(`${verb}: deployer time only (job start unknown: ${record.jobTimeReason}). Deployer ${fmt(record.deployerTotalMs)}.`);
273
+ }
274
+ } catch (err) {
275
+ // Nothing in here may fail a deploy; say what did not happen.
276
+ summary.notStored += 1;
277
+ logger.warn(`⚠️ CI timings: not recorded (${err.message}).`);
278
+ }
279
+ logger.info(summary.notStored
280
+ ? `CI timings: final record not stored (${summary.notStored}); the build's time is incomplete, the deploy is not affected.`
281
+ : summary.droppedFields
282
+ ? `CI timings: stored with the build, ${summary.droppedFields} field(s) dropped by the service.`
283
+ : 'CI timings: stored with the build.');
284
+ return summary;
285
+ }
286
+
133
287
  async function runDeployment(argv) {
288
+ const totalTimer = ciTimings.startTimer();
134
289
  const logger = createLogger(argv);
135
290
  logger.info('🚀 Starting deployment...');
136
291
  // Credentials masked: this line is also a Sentry breadcrumb.
@@ -162,6 +317,13 @@ async function runDeployment(argv) {
162
317
  let metadataToSend = metadataZipPath;
163
318
  let emptyArchive = null;
164
319
  let dropped = null;
320
+ let sbcovManifest = null;
321
+ if (coverage.executionUnsupported && coverage.executionUnsupported.length) {
322
+ logger.warn(
323
+ `⚠️ The installed scry-sbcov does not support ${coverage.executionUnsupported.join(' / ')}, so SCRY_CONCURRENCY /\n` +
324
+ ' SCRY_RENDER_TIMEOUT_MS were not applied. Upgrade @scrymore/scry-sbcov to 0.7 or later.'
325
+ );
326
+ }
165
327
  if (coverage.maxDroppedUnsupported) {
166
328
  logger.warn(
167
329
  '⚠️ The installed scry-sbcov does not support --max-dropped, so stories that fail to\n' +
@@ -176,6 +338,7 @@ async function runDeployment(argv) {
176
338
  if (manifestError) {
177
339
  logger.warn(`⚠️ ${manifestError}; dropped stories cannot be counted for this build.`);
178
340
  } else if (manifest) {
341
+ sbcovManifest = manifest;
179
342
  const allowed = coverage.effectiveMaxDropped ?? 0;
180
343
  const n = manifest.dropped.length;
181
344
  const reasons = n ? ` (${droppedReasons(manifest.dropped)})` : '';
@@ -199,13 +362,18 @@ async function runDeployment(argv) {
199
362
 
200
363
  // 1. Archive only the static Storybook files.
201
364
  logger.info(`1/3: Zipping directory '${argv.dir}'...`);
365
+ const archiveTimer = ciTimings.startTimer();
202
366
  await zipDirectory(argv.dir, outPath);
367
+ const archiveMs = archiveTimer.stop();
203
368
  logger.success(`✅ Archive created: ${outPath}`);
204
369
  logger.debug(`Archive size: ${fs.statSync(outPath).size} bytes`);
205
370
 
206
371
  // 2. Upload Storybook ZIP + coverage + metadata ZIP (if present).
207
372
  logger.info('2/3: Uploading to deployment service...');
208
373
  const apiClient = getApiClient(argv.apiUrl, argv.apiKey);
374
+ // CI timings, pre-upload part: sent with the request that creates the build.
375
+ const { record: preUpload, budget } = buildPreUploadTimings({ coverage, manifest: sbcovManifest, archiveMs });
376
+ reportExecutionTime(preUpload, budget, logger);
209
377
  // Which commit this build is of. `version` is a PR number, a branch, a
210
378
  // tag or a short SHA depending on the CI event, so it identifies a
211
379
  // deploy but never a commit — without this a search result cannot say
@@ -216,6 +384,7 @@ async function runDeployment(argv) {
216
384
  } else {
217
385
  logger.debug('No git context available; build will record no commit SHA');
218
386
  }
387
+ const uploadTimer = ciTimings.startTimer();
219
388
  const uploadResult = await uploadBuild(
220
389
  apiClient,
221
390
  {
@@ -227,13 +396,18 @@ async function runDeployment(argv) {
227
396
  coverageReport,
228
397
  metadataZipPath: metadataToSend,
229
398
  gitContext,
399
+ ciTimings: preUpload,
230
400
  }
231
401
  );
402
+ const uploadMs = uploadTimer.stop();
232
403
  logger.success('✅ Archive uploaded.');
233
404
  logger.debug(`Upload result: ${JSON.stringify(uploadResult)}`);
234
405
 
235
406
  await postPRComment(buildDeployResult(argv, coverageSummary, uploadResult), coverageSummary);
236
407
 
408
+ // CI timings, final record. Never fails the deploy (G7).
409
+ await recordCiTimings({ apiClient, argv, preUpload, uploadMs, totalTimer, uploadResult, logger });
410
+
237
411
  // Report only what actually completed. Uploading is synchronous;
238
412
  // indexing is not. A build can fail in the queue seconds after this
239
413
  // point — during one run the pipeline died 7s later on a revoked
@@ -618,7 +792,7 @@ async function main() {
618
792
  const logger = createLogger(argv);
619
793
 
620
794
  // Capture settings: CLI flag > env > .storybook-deployer.json; unset = sbcov decides.
621
- const { captureMode, captureScale, captureViewport, maxDropped } = loadConfig(argv);
795
+ const { captureMode, captureScale, captureViewport, maxDropped, concurrency, renderTimeoutMs } = loadConfig(argv);
622
796
  const result = await runCoverageAnalysis({
623
797
  storybookDir: argv.dir,
624
798
  baseBranch: argv.coverageBase || 'main',
@@ -629,9 +803,14 @@ async function main() {
629
803
  captureMode,
630
804
  captureScale,
631
805
  captureViewport,
806
+ concurrency,
807
+ renderTimeoutMs,
632
808
  maxDropped,
633
809
  });
634
810
  const report = result.report;
811
+ if (result.executionUnsupported && result.executionUnsupported.length) {
812
+ logger.warn(`⚠️ The installed scry-sbcov does not support ${result.executionUnsupported.join(' / ')}; SCRY_CONCURRENCY / SCRY_RENDER_TIMEOUT_MS not applied.`);
813
+ }
635
814
 
636
815
  if (result.sbcovFailure) {
637
816
  logger.error(`Coverage: ${result.sbcovFailure.reason}${report ? ` (report written to ${argv.output})` : ''}`);
@@ -835,7 +1014,7 @@ async function resolveCoverage(argv, logger) {
835
1014
  const enabled = argv.coverage !== false;
836
1015
  if (!enabled) {
837
1016
  logger.info('Coverage: disabled (--no-coverage)');
838
- return { coverageReport: null, coverageSummary: null, metadataZipPath: null, sbcovFailure: null, effectiveMaxDropped: null, maxDroppedUnsupported: false };
1017
+ return { coverageReport: null, coverageSummary: null, metadataZipPath: null, sbcovFailure: null, effectiveMaxDropped: null, maxDroppedUnsupported: false, sbcovWallMs: null, executed: false, executionUnsupported: [] };
839
1018
  }
840
1019
 
841
1020
  try {
@@ -844,6 +1023,9 @@ async function resolveCoverage(argv, logger) {
844
1023
  let sbcovFailure = null;
845
1024
  let effectiveMaxDropped = null;
846
1025
  let maxDroppedUnsupported = false;
1026
+ let sbcovWallMs = null;
1027
+ let executed = false;
1028
+ let executionUnsupported = [];
847
1029
 
848
1030
  if (argv.coverageReport) {
849
1031
  logger.info(`Coverage: using existing report at ${argv.coverageReport}`);
@@ -864,9 +1046,14 @@ async function resolveCoverage(argv, logger) {
864
1046
  captureMode: argv.captureMode,
865
1047
  captureScale: argv.captureScale,
866
1048
  captureViewport: argv.captureViewport,
1049
+ concurrency: argv.concurrency,
1050
+ renderTimeoutMs: argv.renderTimeoutMs,
867
1051
  maxDropped: argv.maxDropped,
868
1052
  });
869
1053
  report = result.report;
1054
+ sbcovWallMs = result.sbcovWallMs ?? null;
1055
+ executed = Boolean(result.executed);
1056
+ executionUnsupported = result.executionUnsupported || [];
870
1057
  metadataZipPath = result.metadataZipPath;
871
1058
  sbcovFailure = result.sbcovFailure || null;
872
1059
  effectiveMaxDropped = result.effectiveMaxDropped ?? null;
@@ -881,7 +1068,7 @@ async function resolveCoverage(argv, logger) {
881
1068
  logger.info('Coverage: no report generated (tool failed or report shape unexpected)');
882
1069
  }
883
1070
 
884
- return { coverageReport: report, coverageSummary: summary, metadataZipPath, sbcovFailure, effectiveMaxDropped, maxDroppedUnsupported };
1071
+ return { coverageReport: report, coverageSummary: summary, metadataZipPath, sbcovFailure, effectiveMaxDropped, maxDroppedUnsupported, sbcovWallMs, executed, executionUnsupported };
885
1072
  } catch (err) {
886
1073
  logger.error(`Coverage: failed (${err.message})`);
887
1074
  throw err;
@@ -939,6 +1126,9 @@ module.exports = {
939
1126
  runAnalysis,
940
1127
  resolveCoverage,
941
1128
  resolveAnalysis,
1129
+ buildPreUploadTimings,
1130
+ reportExecutionTime,
1131
+ recordCiTimings,
942
1132
  reportIndexingOutcome,
943
1133
  buildDeployResult,
944
1134
  logUploadLinks,
package/lib/apiClient.js CHANGED
@@ -129,8 +129,10 @@ function getApiClient(apiUrl, apiKey) {
129
129
  *
130
130
  * @param {axios.AxiosInstance} apiClient
131
131
  * @param {{project: string, version: string}} target
132
- * @param {{fileName: string, contentType: string}} file
133
- * @returns {Promise<{url: string, visibility?: string}>} presigned URL details
132
+ * @param {{fileName: string, contentType: string, ciTimings?: object}} file
133
+ * `ciTimings`: the pre-upload part of the CI timings record, sent next to
134
+ * contentType. An upload service older than the field ignores it.
135
+ * @returns {Promise<{url: string, visibility?: string, buildId?: string, buildNumber?: number}>} presigned URL details
134
136
  */
135
137
  async function requestPresignedUrl(apiClient, target, file) {
136
138
  const projectName = target.project || 'main';
@@ -140,7 +142,7 @@ async function requestPresignedUrl(apiClient, target, file) {
140
142
 
141
143
  const presignedResponse = await apiClient.post(
142
144
  `/presigned-url/${projectName}/${versionName}/${file.fileName}`,
143
- { contentType: file.contentType },
145
+ file.ciTimings ? { contentType: file.contentType, ciTimings: file.ciTimings } : { contentType: file.contentType },
144
146
  {
145
147
  headers: {
146
148
  'Content-Type': 'application/json',
@@ -170,7 +172,12 @@ async function requestPresignedUrl(apiClient, target, file) {
170
172
  const parsedUrl = validatePresignedUrl(presignedUrl);
171
173
  logger.debug(`Validated presigned URL host: ${parsedUrl.hostname}`);
172
174
 
173
- return { url: presignedUrl, visibility };
175
+ return {
176
+ url: presignedUrl,
177
+ visibility,
178
+ buildId: presignedResponse.data?.buildId,
179
+ buildNumber: presignedResponse.data?.buildNumber,
180
+ };
174
181
  }
175
182
 
176
183
  function getAxiosErrorDetails(error, fallbackUrl) {
@@ -256,7 +263,7 @@ async function putToPresignedUrl(presignedUrl, data, contentType) {
256
263
  * @param {string} payload.project The project name/identifier.
257
264
  * @param {string} payload.version The version identifier.
258
265
  * @param {string} filePath The local path to the file to upload.
259
- * @param {{fileName?: string, contentType?: string}} [file] Optional overrides
266
+ * @param {{fileName?: string, contentType?: string, ciTimings?: object}} [file] Optional overrides
260
267
  * @returns {Promise<object>} A promise that resolves to the upload result.
261
268
  */
262
269
  async function uploadFileDirectly(apiClient, { project, version }, filePath, file = {}) {
@@ -282,9 +289,16 @@ async function uploadFileDirectly(apiClient, { project, version }, filePath, fil
282
289
  // backoff must re-request it — reusing a stale one fails with a confusing
283
290
  // signature error instead of the real network cause.
284
291
  return await withUploadRetry(async () => {
285
- const presigned = await requestPresignedUrl(apiClient, { project, version }, { fileName, contentType });
292
+ const presigned = await requestPresignedUrl(apiClient, { project, version }, { fileName, contentType, ciTimings: file.ciTimings });
286
293
  const upload = await putToPresignedUrl(presigned.url, fileBuffer, contentType);
287
- return { success: true, url: presigned.url, status: upload.status, visibility: presigned.visibility };
294
+ return {
295
+ success: true,
296
+ url: presigned.url,
297
+ status: upload.status,
298
+ visibility: presigned.visibility,
299
+ buildId: presigned.buildId,
300
+ buildNumber: presigned.buildNumber,
301
+ };
288
302
  }, `Upload of ${fileName}`);
289
303
  } catch (error) {
290
304
  logger.debug(`Upload failed. Error type: ${error.constructor.name}, Message: ${error.message}`);
@@ -421,13 +435,16 @@ async function uploadMetadataZip(apiClient, target, metadataZipPath, customLogge
421
435
  *
422
436
  * @param {axios.AxiosInstance} apiClient
423
437
  * @param {{project: string, version: string}} target
424
- * @param {{zipPath: string, coverageReport?: any|null, metadataZipPath?: string|null, gitContext?: {commitSha?: string, branch?: string}}} options
438
+ * @param {{zipPath: string, coverageReport?: any|null, metadataZipPath?: string|null, gitContext?: {commitSha?: string, branch?: string}, ciTimings?: object|null}} options
439
+ * `ciTimings`: the pre-upload part of the CI timings record; rides on the
440
+ * storybook.zip presigned-URL request, which is what creates the build.
425
441
  */
426
442
  async function uploadBuild(apiClient, target, options) {
427
443
  logger.debug('uploadBuild orchestration started');
428
444
  const zipUpload = await uploadFileDirectly(apiClient, target, options.zipPath, {
429
445
  fileName: 'storybook.zip',
430
446
  contentType: 'application/zip',
447
+ ...(options.ciTimings ? { ciTimings: options.ciTimings } : {}),
431
448
  });
432
449
 
433
450
  let coverageUpload = null;
@@ -0,0 +1,350 @@
1
+ const axios = require('axios');
2
+
3
+ /**
4
+ * CI timings recorded with every upload (storybook-preview-ci-runtime, ISSUES.md #54).
5
+ *
6
+ * A dashboard PR preview took 19-21 minutes and nothing measured it: no line
7
+ * said how long story execution took, nothing compared it with a budget, and
8
+ * the build document stored no time at all. The deployer now measures its own
9
+ * phases, judges story execution against a budget scaled to the number of
10
+ * stories, and sends the record to the upload service in two parts (the
11
+ * pre-upload part in the presigned-URL body, the final record on
12
+ * POST /upload/:project/:version/builds/:buildNumber/ci-timings).
13
+ *
14
+ * Rules: a number that could not be measured is left out (never 0); nothing
15
+ * here can fail a deploy; every "could not" is said in the log.
16
+ */
17
+
18
+ const DEFAULT_BUDGET_BASE_S = 120;
19
+ const DEFAULT_BUDGET_PER_STORY_S = 0.5;
20
+ const ACTIONS_API_TIMEOUT_MS = 5000;
21
+ const MAX_STRING = 100;
22
+
23
+ /** A monotonic clock in whole milliseconds. */
24
+ function monotonicMs() {
25
+ return Number(process.hrtime.bigint() / 1000000n);
26
+ }
27
+
28
+ /**
29
+ * Start a stopwatch. `stop()` returns whole elapsed ms from a monotonic clock.
30
+ * @returns {{stop: () => number}}
31
+ */
32
+ function startTimer() {
33
+ const start = monotonicMs();
34
+ return { stop: () => Math.max(0, monotonicMs() - start) };
35
+ }
36
+
37
+ /**
38
+ * Which kind of runner this is, from GitHub's RUNNER_ENVIRONMENT.
39
+ * @param {NodeJS.ProcessEnv} env
40
+ * @returns {'github-hosted'|'self-hosted'|'unknown'}
41
+ */
42
+ function detectRunner(env = process.env) {
43
+ const v = String(env.RUNNER_ENVIRONMENT || '').trim().toLowerCase();
44
+ if (v === 'github-hosted') return 'github-hosted';
45
+ if (v === 'self-hosted') return 'self-hosted';
46
+ return 'unknown';
47
+ }
48
+
49
+ function shortString(v) {
50
+ if (typeof v !== 'string') return undefined;
51
+ const t = v.trim();
52
+ return t ? t.slice(0, MAX_STRING) : undefined;
53
+ }
54
+
55
+ /**
56
+ * A workflow or job name in the character set the upload service accepts
57
+ * (`[\w .:@+/()-]`). The service rejects a whole record over one bad string,
58
+ * so "Build & Deploy" or "CI, preview" would lose every timing on every run;
59
+ * other characters become "-".
60
+ */
61
+ function safeLabel(v) {
62
+ const t = shortString(v);
63
+ if (!t) return undefined;
64
+ const clean = t.replace(/[^\w .:@+/()-]/g, '-').replace(/-{2,}/g, '-').trim();
65
+ return clean && /[\w]/.test(clean) ? clean : undefined;
66
+ }
67
+
68
+ function positiveInt(v) {
69
+ const n = Number(v);
70
+ return Number.isInteger(n) && n > 0 ? n : undefined;
71
+ }
72
+
73
+ /**
74
+ * The GitHub Actions run this deploy is part of, or null outside Actions.
75
+ * @param {NodeJS.ProcessEnv} env
76
+ * @returns {null|{provider:'github', runId?:string, runAttempt?:number, workflow?:string, job?:string}}
77
+ */
78
+ function readCiContext(env = process.env) {
79
+ if (env.GITHUB_ACTIONS !== 'true' && !env.GITHUB_RUN_ID) return null;
80
+ const ci = { provider: 'github' };
81
+ const runId = /^\d{1,20}$/.test(String(env.GITHUB_RUN_ID || '')) ? String(env.GITHUB_RUN_ID) : undefined;
82
+ if (runId) ci.runId = runId;
83
+ const attempt = positiveInt(env.GITHUB_RUN_ATTEMPT);
84
+ if (attempt) ci.runAttempt = attempt;
85
+ const workflow = safeLabel(env.GITHUB_WORKFLOW);
86
+ if (workflow) ci.workflow = workflow;
87
+ const job = safeLabel(env.GITHUB_JOB);
88
+ if (job) ci.job = job;
89
+ return ci;
90
+ }
91
+
92
+ /**
93
+ * Read a budget setting from the environment. Unset = the default; set but not
94
+ * a number >= 0 = the default plus a warning (a typo must not silently change
95
+ * the budget).
96
+ */
97
+ function readBudgetSetting(env, name, dflt, warnings) {
98
+ const raw = env[name];
99
+ if (raw === undefined || raw === null || String(raw).trim() === '') return dflt;
100
+ const text = String(raw).trim();
101
+ const n = Number(text);
102
+ if (!/^\d+(\.\d+)?$/.test(text) || !Number.isFinite(n) || n < 0) {
103
+ warnings.push(`${name}=${JSON.stringify(text.slice(0, 20))} is not a number of seconds >= 0; using ${dflt}`);
104
+ return dflt;
105
+ }
106
+ return n;
107
+ }
108
+
109
+ /**
110
+ * The execute budget: SCRY_EXECUTE_BUDGET_BASE_S (120) + SCRY_EXECUTE_BUDGET_PER_STORY_S (0.5) × declared stories.
111
+ * `budgetMs` is null when the number of stories is unknown.
112
+ *
113
+ * @param {{declared:number|null|undefined, env?:NodeJS.ProcessEnv}} opts
114
+ * @returns {{budgetMs:number|null, baseS:number, perStoryS:number, warnings:string[]}}
115
+ */
116
+ function resolveBudget({ declared, env = process.env }) {
117
+ const warnings = [];
118
+ const baseS = readBudgetSetting(env, 'SCRY_EXECUTE_BUDGET_BASE_S', DEFAULT_BUDGET_BASE_S, warnings);
119
+ const perStoryS = readBudgetSetting(env, 'SCRY_EXECUTE_BUDGET_PER_STORY_S', DEFAULT_BUDGET_PER_STORY_S, warnings);
120
+ const n = typeof declared === 'number' && Number.isFinite(declared) && declared >= 0 ? declared : null;
121
+ const budgetMs = n === null ? null : Math.round((baseS + perStoryS * n) * 1000);
122
+ return { budgetMs, baseS, perStoryS, warnings };
123
+ }
124
+
125
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : undefined);
126
+
127
+ /**
128
+ * How long story execution took, and from where.
129
+ *
130
+ * sbcov 0.7+ writes `execution.durationMs` into its manifest; older sbcov puts
131
+ * the executor's own duration in the report (`execution.summary.duration`).
132
+ * Both are sbcov's measurement. Without either, the whole sbcov run (analysis
133
+ * and execution together) is the only number, and it is labelled as such; the
134
+ * analysis part then cannot be separated and is left out.
135
+ *
136
+ * @param {{sbcovWallMs:number|null, manifestExecution?:any, report?:any, executed:boolean}} o
137
+ * @returns {{analyzeMs?:number, executeMs?:number, executeSource?:'sbcov'|'deployer-wall'}}
138
+ */
139
+ function splitSbcovTime({ sbcovWallMs, manifestExecution, report, executed }) {
140
+ const wall = num(sbcovWallMs);
141
+ if (wall === undefined) return {};
142
+ if (!executed) return { analyzeMs: wall };
143
+ const fromSbcov = num(manifestExecution?.durationMs) ?? num(report?.execution?.summary?.duration);
144
+ if (fromSbcov !== undefined && fromSbcov <= wall) {
145
+ return { analyzeMs: wall - fromSbcov, executeMs: fromSbcov, executeSource: 'sbcov' };
146
+ }
147
+ return { executeMs: wall, executeSource: 'deployer-wall' };
148
+ }
149
+
150
+ /**
151
+ * Story counts for the record, from sbcov's manifest `execution` block (0.7+),
152
+ * else from what an older manifest and report hold. Unknown counts are left out.
153
+ *
154
+ * @param {{manifest?:any, manifestExecution?:any, report?:any}} o
155
+ * @returns {{declared?:number, passed?:number, failed?:number, timeouts?:number, notIndexed?:number}|undefined}
156
+ */
157
+ function storyCounts({ manifest, manifestExecution, report }) {
158
+ const e = manifestExecution || {};
159
+ const s = report?.execution?.summary || {};
160
+ const dropped = Array.isArray(manifest?.dropped) ? manifest.dropped : null;
161
+ const out = {};
162
+ const set = (k, v) => { const n = num(v); if (n !== undefined) out[k] = Math.round(n); };
163
+ set('declared', e.declared ?? manifest?.declared ?? s.declared ?? s.total);
164
+ set('passed', e.passed ?? s.passed);
165
+ set('failed', e.failed ?? s.failed);
166
+ set('timeouts', e.timeouts ?? (dropped ? dropped.filter((d) => /timeout/.test(String(d?.reason || ''))).length : undefined));
167
+ set('notIndexed', e.notIndexed ?? (dropped ? dropped.length : s.notIndexed));
168
+ return Object.keys(out).length ? out : undefined;
169
+ }
170
+
171
+ /**
172
+ * sbcov's time lost per reason. `{}` when every story passed; undefined when
173
+ * the sbcov is too old to say (never a made-up zero).
174
+ */
175
+ function timeLost(manifestExecution) {
176
+ const t = manifestExecution?.timeLostMs;
177
+ if (!t || typeof t !== 'object' || Array.isArray(t)) return undefined;
178
+ const out = {};
179
+ for (const [k, v] of Object.entries(t)) {
180
+ const key = String(k).slice(0, 40);
181
+ const n = num(v);
182
+ if (n !== undefined) out[key] = Math.round(n);
183
+ }
184
+ return out;
185
+ }
186
+
187
+ /** "212 s", "5.8 min" */
188
+ function formatDuration(ms) {
189
+ if (typeof ms !== 'number' || !Number.isFinite(ms)) return '?';
190
+ if (ms < 1000) return `${ms} ms`;
191
+ const s = ms / 1000;
192
+ if (s < 120) return `${s < 10 ? s.toFixed(1) : Math.round(s)} s`;
193
+ return `${(s / 60).toFixed(1)} min`;
194
+ }
195
+
196
+ /** "timeout 40 s, console_error 3 s" (largest first) */
197
+ function describeTimeLost(t) {
198
+ if (!t) return '';
199
+ return Object.entries(t)
200
+ .filter(([, v]) => v > 0)
201
+ .sort((a, b) => b[1] - a[1])
202
+ .slice(0, 3)
203
+ .map(([k, v]) => `${k} ${formatDuration(v)}`)
204
+ .join(', ');
205
+ }
206
+
207
+ /**
208
+ * Whole-job elapsed time from the GitHub Actions API: this job's started_at.
209
+ *
210
+ * GitHub exposes no job start time in the environment, so the deployer asks
211
+ * GET /repos/{repo}/actions/runs/{run}/attempts/{n}/jobs (needs GITHUB_TOKEN
212
+ * with `actions: read`), bounded to 5 s. The job is the in-progress one on
213
+ * this runner (RUNNER_NAME), else the one named GITHUB_JOB. Any failure gives
214
+ * `jobTimeSource: "deployer-only"` and the reason; never throws.
215
+ *
216
+ * @param {{env?:NodeJS.ProcessEnv, timeoutMs?:number, now?:() => number, http?:{get:Function}}} [o]
217
+ * @returns {Promise<{jobElapsedMs?:number, jobTimeSource:'actions-api'|'deployer-only', jobTimeReason?:string}>}
218
+ */
219
+ async function fetchJobElapsed({ env = process.env, timeoutMs = ACTIONS_API_TIMEOUT_MS, now = Date.now, http = axios } = {}) {
220
+ const only = (reason) => ({ jobTimeSource: 'deployer-only', jobTimeReason: reason });
221
+ if (env.GITHUB_ACTIONS !== 'true') return only('not-github');
222
+ const repo = String(env.GITHUB_REPOSITORY || '');
223
+ const runId = String(env.GITHUB_RUN_ID || '');
224
+ const attempt = positiveInt(env.GITHUB_RUN_ATTEMPT) || 1;
225
+ if (!/^[\w.-]+\/[\w.-]+$/.test(repo) || !/^\d+$/.test(runId)) return only('no-run-id');
226
+ const token = env.GITHUB_TOKEN || env.GH_TOKEN;
227
+ if (!token) return only('no-token');
228
+
229
+ const base = String(env.GITHUB_API_URL || 'https://api.github.com').replace(/\/$/, '');
230
+ const url = `${base}/repos/${repo}/actions/runs/${runId}/attempts/${attempt}/jobs?per_page=100`;
231
+ let data;
232
+ try {
233
+ const res = await http.get(url, {
234
+ timeout: timeoutMs,
235
+ headers: {
236
+ Authorization: `Bearer ${token}`,
237
+ Accept: 'application/vnd.github+json',
238
+ 'X-GitHub-Api-Version': '2022-11-28',
239
+ },
240
+ // Never follow a redirect with the token to another host.
241
+ maxRedirects: 0,
242
+ });
243
+ data = res.data;
244
+ } catch (error) {
245
+ const status = error?.response?.status;
246
+ if (status === 401 || status === 403) return only('forbidden');
247
+ if (status === 404) return only('not-found');
248
+ if (typeof status === 'number') return only(`http-${status}`);
249
+ if (error?.code === 'ECONNABORTED' || error?.code === 'ETIMEDOUT' || /timeout/i.test(String(error?.message))) return only('timeout');
250
+ return only('network-error');
251
+ }
252
+
253
+ const jobs = Array.isArray(data?.jobs) ? data.jobs : [];
254
+ const running = jobs.filter((j) => j && j.status === 'in_progress');
255
+ let job = null;
256
+ if (env.RUNNER_NAME) job = running.find((j) => j.runner_name === env.RUNNER_NAME) || null;
257
+ if (!job && env.GITHUB_JOB) {
258
+ const named = running.filter((j) => j.name === env.GITHUB_JOB);
259
+ if (named.length === 1) job = named[0];
260
+ }
261
+ if (!job && running.length === 1) job = running[0];
262
+ if (!job) return only('job-not-found');
263
+
264
+ const started = Date.parse(job.started_at);
265
+ if (!Number.isFinite(started)) return only('job-not-found');
266
+ const elapsed = now() - started;
267
+ if (!(elapsed >= 0 && elapsed < 24 * 3600 * 1000)) return only('clock-skew');
268
+ return { jobElapsedMs: Math.round(elapsed), jobTimeSource: 'actions-api' };
269
+ }
270
+
271
+ /**
272
+ * Send the final record to POST /upload/:project/:version/builds/:buildId/ci-timings.
273
+ * Keyed by the build id the presigned-URL response returned (a build number
274
+ * could name a newer build of the same version by the time this is sent).
275
+ * Never throws: an upload service without the route (404), one that has the
276
+ * route but not the build (404 "Build not found"), one that rejects the record
277
+ * (400) or one that cannot be reached is reported, and the deploy result does
278
+ * not change.
279
+ *
280
+ * @param {import('axios').AxiosInstance} apiClient
281
+ * @param {{project?:string, version?:string}} target
282
+ * @param {string|null|undefined} buildId
283
+ * @param {object} record
284
+ * @returns {Promise<{stored:true, dropped?:string[]}|{stored:false, reason:'no-build-id'|'not-supported'|'build-not-found'|'rejected'|'error', detail?:string}>}
285
+ */
286
+ async function sendCiTimings(apiClient, target, buildId, record) {
287
+ if (typeof buildId !== 'string' || !/^[\w-]{1,128}$/.test(buildId)) {
288
+ return { stored: false, reason: 'no-build-id' };
289
+ }
290
+ const project = encodeURIComponent(target.project || 'main');
291
+ const version = encodeURIComponent(target.version || 'latest');
292
+ try {
293
+ const res = await apiClient.post(
294
+ `/upload/${project}/${version}/builds/${encodeURIComponent(buildId)}/ci-timings`,
295
+ { ciTimings: record },
296
+ { headers: { 'Content-Type': 'application/json' }, timeout: 15000 },
297
+ );
298
+ // The service drops a bad field and keeps the rest; it names what it dropped.
299
+ const dropped = Array.isArray(res?.data?.dropped)
300
+ ? res.data.dropped.map((d) => String(typeof d === 'string' ? d : d?.path || d?.field || JSON.stringify(d)).slice(0, 60)).slice(0, 20)
301
+ : [];
302
+ return dropped.length ? { stored: true, dropped } : { stored: true };
303
+ } catch (error) {
304
+ const status = error?.response?.status;
305
+ if (status === 404) {
306
+ // The route exists but has no such build, or the service predates the route.
307
+ const body = error.response.data;
308
+ const text = typeof body === 'string' ? body : String(body?.error || '');
309
+ if (/build not found/i.test(text)) return { stored: false, reason: 'build-not-found', detail: text.slice(0, 200) };
310
+ return { stored: false, reason: 'not-supported' };
311
+ }
312
+ if (status === 400) {
313
+ const body = error.response.data || {};
314
+ const issues = Array.isArray(body.issues) ? body.issues.map((i) => (typeof i === 'string' ? i : (i?.path || []).join?.('.') || JSON.stringify(i))).join(', ') : '';
315
+ return { stored: false, reason: 'rejected', detail: (issues || body.error || 'HTTP 400').slice(0, 200) };
316
+ }
317
+ return { stored: false, reason: 'error', detail: status ? `HTTP ${status}` : (error?.code || error?.message || 'unknown error') };
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Drop undefined members so an unmeasured value is absent, not null or 0.
323
+ * @template T
324
+ * @param {T} obj
325
+ * @returns {T}
326
+ */
327
+ function compact(obj) {
328
+ const out = {};
329
+ for (const [k, v] of Object.entries(obj)) if (v !== undefined && v !== null) out[k] = v;
330
+ return /** @type {T} */ (out);
331
+ }
332
+
333
+ module.exports = {
334
+ DEFAULT_BUDGET_BASE_S,
335
+ DEFAULT_BUDGET_PER_STORY_S,
336
+ ACTIONS_API_TIMEOUT_MS,
337
+ startTimer,
338
+ detectRunner,
339
+ readCiContext,
340
+ resolveBudget,
341
+ splitSbcovTime,
342
+ storyCounts,
343
+ timeLost,
344
+ formatDuration,
345
+ describeTimeLost,
346
+ fetchJobElapsed,
347
+ sendCiTimings,
348
+ compact,
349
+ safeLabel,
350
+ };
package/lib/config.js CHANGED
@@ -127,7 +127,10 @@ function loadEnvConfig() {
127
127
  'CAPTURE_SCALE': 'captureScale',
128
128
  'CAPTURE_VIEWPORT': 'captureViewport',
129
129
  // Forwarded to scry-sbcov as --max-dropped
130
- 'MAX_DROPPED': 'maxDropped'
130
+ 'MAX_DROPPED': 'maxDropped',
131
+ // Forwarded to scry-sbcov 0.7+ as --concurrency / --render-timeout (unset = sbcov decides)
132
+ 'CONCURRENCY': 'concurrency',
133
+ 'RENDER_TIMEOUT_MS': 'renderTimeoutMs'
131
134
  };
132
135
 
133
136
  Object.keys(envMapping).forEach(envKey => {
package/lib/coverage.js CHANGED
@@ -2,6 +2,7 @@ const { execSync } = require('child_process');
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
4
  const chalk = require('chalk');
5
+ const { startTimer } = require('./ciTimings.js');
5
6
 
6
7
  /**
7
8
  * @typedef {Object} RunCoverageOptions
@@ -15,6 +16,8 @@ const chalk = require('chalk');
15
16
  * @property {'root'|'viewport'} [captureMode] Screenshot framing forwarded as --capture-mode
16
17
  * @property {number|string} [captureScale] Device scale factor (0 < n <= 4) forwarded as --capture-scale
17
18
  * @property {string|{width:number,height:number}} [captureViewport] "WxH" or {width,height}, forwarded as --capture-viewport
19
+ * @property {number|string} [concurrency] Stories rendered at once, forwarded as --concurrency (sbcov 0.7+; unset: sbcov default 4)
20
+ * @property {number|string} [renderTimeoutMs] How long a story may take to show something, forwarded as --render-timeout (sbcov 0.7+; unset: sbcov default 5000)
18
21
  * @property {number|string} [maxDropped] Forwarded as --max-dropped: sbcov exits 3 when more stories than this were dropped.
19
22
  * Unset: with screenshots on, the deployer passes --max-dropped 0 (any dropped story ends the
20
23
  * deploy red, after the rest are uploaded) when the installed scry-sbcov supports the flag.
@@ -127,6 +130,8 @@ async function runCoverageAnalysis(options) {
127
130
  captureMode,
128
131
  captureScale,
129
132
  captureViewport,
133
+ concurrency,
134
+ renderTimeoutMs,
130
135
  maxDropped,
131
136
  } = options || {};
132
137
 
@@ -139,6 +144,7 @@ async function runCoverageAnalysis(options) {
139
144
  const captureArgs = buildCaptureArgs({ captureMode, captureScale, captureViewport });
140
145
  // Validated up front; the default (0) is decided below, once the command is known.
141
146
  const userMaxDroppedArgs = buildMaxDroppedArgs(maxDropped);
147
+ const executionArgs = buildExecutionArgs({ concurrency, renderTimeoutMs });
142
148
 
143
149
  console.log(chalk.blue('Running Storybook coverage analysis...'));
144
150
 
@@ -218,6 +224,20 @@ async function runCoverageAnalysis(options) {
218
224
  }
219
225
  }
220
226
 
227
+ // --concurrency / --render-timeout (sbcov 0.7+), forwarded only to an sbcov
228
+ // that lists them: an older one rejects an unknown option and captures
229
+ // nothing. What could not be applied is returned for the caller to say.
230
+ /** @type {string[]} */
231
+ const executionUnsupported = [];
232
+ // Without execution the settings have nothing to act on.
233
+ for (let i = 0; (execute || screenshots) && i < executionArgs.length; i += 2) {
234
+ if (sbcovSupportsFlag(sbcovCommandPrefix, executionArgs[i])) {
235
+ cliArgs.push(executionArgs[i], executionArgs[i + 1]);
236
+ } else {
237
+ executionUnsupported.push(executionArgs[i]);
238
+ }
239
+ }
240
+
221
241
  const npxCommand = `${sbcovCommandPrefix} ${cliArgs.map(shellEscape).join(' ')}`;
222
242
 
223
243
  // Debug logging to show the exact command being executed
@@ -227,6 +247,16 @@ async function runCoverageAnalysis(options) {
227
247
  console.log(chalk.yellow('Working directory: ' + process.cwd()));
228
248
  console.log(chalk.yellow('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'));
229
249
 
250
+ // Wall time of the sbcov process (analysis + execution), from a monotonic
251
+ // clock; null when it never started. Used for the CI timings record.
252
+ let sbcovStarted = null;
253
+ let sbcovWallMs = null;
254
+ // `executed`: stories were asked to run AND sbcov produced a report (exit 0
255
+ // or 3). A crashed or missing sbcov ran no stories, so its wall time is not
256
+ // an execute time (review should-fix 2).
257
+ let producedReport = false;
258
+ const extras = () => ({ sbcovWallMs, executionUnsupported, executed: Boolean((execute || screenshots) && producedReport) });
259
+
230
260
  try {
231
261
  // Determine the correct working directory
232
262
  // If storybookDir is relative, resolve it from cwd
@@ -240,17 +270,21 @@ async function runCoverageAnalysis(options) {
240
270
  console.log(chalk.yellow('Project root: ' + projectRoot));
241
271
  console.log(chalk.yellow('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'));
242
272
 
273
+ sbcovStarted = startTimer();
243
274
  execSync(npxCommand, {
244
275
  stdio: 'inherit',
245
276
  cwd: projectRoot // Run from the project root, not scry-node directory
246
277
  });
278
+ sbcovWallMs = sbcovStarted.stop();
247
279
 
248
280
  const report = JSON.parse(fs.readFileSync(outputPath, 'utf-8'));
281
+ producedReport = true;
249
282
  const metadataZipPath = existingZip(screenshots, outputZipPath);
250
283
 
251
284
  if (!keepReport && !providedOutputPath) safeUnlink(outputPath);
252
- return { report, metadataZipPath, sbcovFailure: null, effectiveMaxDropped, maxDroppedUnsupported };
285
+ return { report, metadataZipPath, sbcovFailure: null, effectiveMaxDropped, maxDroppedUnsupported, ...extras() };
253
286
  } catch (error) {
287
+ if (sbcovStarted !== null && sbcovWallMs === null) sbcovWallMs = sbcovStarted.stop();
254
288
  const exitCode = typeof error.status === 'number' ? error.status : null;
255
289
  const signal = error.signal || null;
256
290
  // A throw with no exit status is not sbcov's verdict: the command never
@@ -263,6 +297,7 @@ async function runCoverageAnalysis(options) {
263
297
  // keep both so they can still be uploaded and indexed. Any other failure
264
298
  // promises no complete archive, so a leftover one is removed, never sent.
265
299
  const report = readReportIfPresent(outputPath);
300
+ producedReport = report !== null;
266
301
  let metadataZipPath = null;
267
302
  if (exitCode === 3) {
268
303
  metadataZipPath = existingZip(screenshots, outputZipPath);
@@ -280,6 +315,7 @@ async function runCoverageAnalysis(options) {
280
315
  sbcovFailure: { exitCode, signal, reason },
281
316
  effectiveMaxDropped,
282
317
  maxDroppedUnsupported,
318
+ ...extras(),
283
319
  };
284
320
  }
285
321
  }
@@ -299,32 +335,52 @@ function readReportIfPresent(outputPath) {
299
335
  }
300
336
  }
301
337
 
302
- const maxDroppedSupport = new Map();
338
+ const sbcovHelp = new Map();
303
339
 
304
340
  /**
305
- * Whether this scry-sbcov command knows --max-dropped (0.5.2+), from its --help.
306
- * Cached per command for the life of the process. Any failure to ask = no.
341
+ * This scry-sbcov command's --help text, cached per command for the life of
342
+ * the process. Any failure to ask = '' (no optional flag is judged supported).
307
343
  *
308
344
  * @param {string} commandPrefix
309
- * @returns {boolean}
345
+ * @returns {string}
310
346
  */
311
- function sbcovSupportsMaxDropped(commandPrefix) {
312
- if (maxDroppedSupport.has(commandPrefix)) return maxDroppedSupport.get(commandPrefix);
313
- let supported = false;
347
+ function sbcovHelpText(commandPrefix) {
348
+ if (sbcovHelp.has(commandPrefix)) return sbcovHelp.get(commandPrefix);
349
+ let out = '';
314
350
  try {
315
- const out = execSync(`${commandPrefix} --help`, {
351
+ out = execSync(`${commandPrefix} --help`, {
316
352
  encoding: 'utf-8',
317
353
  stdio: ['ignore', 'pipe', 'pipe'],
318
354
  timeout: 30000,
319
355
  });
320
- supported = /--max-dropped\b/.test(out);
321
356
  } catch (_) {
322
- // An sbcov whose --help fails is judged unable to take the flag; the
323
- // caller prints that dropped stories cannot be checked.
324
- supported = false;
357
+ // An sbcov whose --help fails is judged unable to take optional flags;
358
+ // the caller prints what could not be applied.
359
+ out = '';
325
360
  }
326
- maxDroppedSupport.set(commandPrefix, supported);
327
- return supported;
361
+ sbcovHelp.set(commandPrefix, out);
362
+ return out;
363
+ }
364
+
365
+ /**
366
+ * Whether this scry-sbcov command knows --max-dropped (0.5.2+), from its --help.
367
+ *
368
+ * @param {string} commandPrefix
369
+ * @returns {boolean}
370
+ */
371
+ function sbcovSupportsMaxDropped(commandPrefix) {
372
+ return /--max-dropped\b/.test(sbcovHelpText(commandPrefix));
373
+ }
374
+
375
+ /**
376
+ * Whether this scry-sbcov command knows a flag, from its --help.
377
+ *
378
+ * @param {string} commandPrefix
379
+ * @param {string} flag e.g. '--concurrency'
380
+ * @returns {boolean}
381
+ */
382
+ function sbcovSupportsFlag(commandPrefix, flag) {
383
+ return new RegExp(`${flag.replace(/[-]/g, '\\-')}\\b`).test(sbcovHelpText(commandPrefix));
328
384
  }
329
385
 
330
386
  /**
@@ -342,6 +398,37 @@ function buildMaxDroppedArgs(maxDropped) {
342
398
  return ['--max-dropped', String(Number(text))];
343
399
  }
344
400
 
401
+ /**
402
+ * --concurrency / --render-timeout for scry-sbcov 0.7+ (storybook-preview-ci-runtime).
403
+ * Only settings that were set are returned; each is validated to a bounded
404
+ * whole number before it gets near a shell command, and anything else throws.
405
+ *
406
+ * @param {{concurrency?:any, renderTimeoutMs?:any}} [settings]
407
+ * @returns {string[]}
408
+ */
409
+ function buildExecutionArgs(settings = {}) {
410
+ const { concurrency, renderTimeoutMs } = settings || {};
411
+ const isSet = (v) => v !== undefined && v !== null && v !== '';
412
+ const args = [];
413
+ if (isSet(concurrency)) {
414
+ const text = String(concurrency).trim();
415
+ const n = Number(text);
416
+ if (!/^\d{1,2}$/.test(text) || n < 1 || n > 32) {
417
+ throw new Error(`Invalid concurrency ${JSON.stringify(concurrency)} (SCRY_CONCURRENCY): expected a whole number of stories from 1 to 32`);
418
+ }
419
+ args.push('--concurrency', String(n));
420
+ }
421
+ if (isSet(renderTimeoutMs)) {
422
+ const text = String(renderTimeoutMs).trim();
423
+ const n = Number(text);
424
+ if (!/^\d{1,6}$/.test(text) || n < 100 || n > 600000) {
425
+ throw new Error(`Invalid renderTimeoutMs ${JSON.stringify(renderTimeoutMs)} (SCRY_RENDER_TIMEOUT_MS): expected whole milliseconds from 100 to 600000`);
426
+ }
427
+ args.push('--render-timeout', String(n));
428
+ }
429
+ return args;
430
+ }
431
+
345
432
  /**
346
433
  * Load a coverage report from disk.
347
434
  *
@@ -512,8 +599,10 @@ module.exports = {
512
599
  runCoverageAnalysis,
513
600
  buildCaptureArgs,
514
601
  buildMaxDroppedArgs,
602
+ buildExecutionArgs,
515
603
  describeSbcovExit,
516
604
  sbcovSupportsMaxDropped,
605
+ sbcovSupportsFlag,
517
606
  loadCoverageReport,
518
607
  extractCoverageSummary,
519
608
  normalizeGitBaseRef,
@@ -43,13 +43,14 @@ function countMetadataEntries(zipPath) {
43
43
 
44
44
  /**
45
45
  * Read scry-sbcov's `sbcov-manifest.json` from an analysis archive (0.5.2+):
46
- * `{declared, captured, dropped:[{storyId, storyTitle, reason}], capture, sbcovVersion}`.
46
+ * `{declared, captured, dropped:[{storyId, storyTitle, reason}], capture, sbcovVersion}`,
47
+ * plus `execution` (timings and counts) from 0.7.
47
48
  *
48
49
  * Never throws. `manifest: null` with no error = an older sbcov that writes no
49
50
  * manifest; with an error = present but unreadable, which the caller surfaces.
50
51
  *
51
52
  * @param {string} zipPath
52
- * @returns {{manifest: null|{declared:number|null, captured:number|null, dropped:Array<{storyId?:string, storyTitle?:string, reason?:string}>, sbcovVersion?:string}, error: string|null}}
53
+ * @returns {{manifest: null|{declared:number|null, captured:number|null, dropped:Array<{storyId?:string, storyTitle?:string, reason?:string}>, sbcovVersion?:string, execution:object|null}, error: string|null}}
53
54
  */
54
55
  function readSbcovManifest(zipPath) {
55
56
  try {
@@ -60,8 +61,11 @@ function readSbcovManifest(zipPath) {
60
61
  return { manifest: null, error: 'sbcov-manifest.json has no dropped list' };
61
62
  }
62
63
  const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
64
+ // sbcov 0.7+ adds an `execution` timing block (storybook-preview-ci-runtime);
65
+ // older manifests have none and it stays null, never a made-up zero.
66
+ const execution = m.execution && typeof m.execution === 'object' && !Array.isArray(m.execution) ? m.execution : null;
63
67
  return {
64
- manifest: { declared: num(m.declared), captured: num(m.captured), dropped: m.dropped, sbcovVersion: m.sbcovVersion },
68
+ manifest: { declared: num(m.declared), captured: num(m.captured), dropped: m.dropped, sbcovVersion: m.sbcovVersion, execution },
65
69
  error: null,
66
70
  };
67
71
  } catch (err) {
package/lib/templates.js CHANGED
@@ -63,9 +63,10 @@ function getCacheValue(packageManager) {
63
63
  * the "nothing will be indexed" guards (0.7.0), never a surprise major. The
64
64
  * repo's own package.json cannot lower it, because the deployer is installed
65
65
  * into its own folder (ISSUES.md #50: a repo pinned to 0.2.2 ran it for seven
66
- * weeks through a bare `npx @scrymore/scry-deployer`).
66
+ * weeks through a bare `npx @scrymore/scry-deployer`). 0.9.0: CI timings and
67
+ * the execute budget warning (ISSUES.md #54).
67
68
  */
68
- const DEPLOYER_RANGE = '^0.7.0';
69
+ const DEPLOYER_RANGE = '^0.9.0';
69
70
 
70
71
  /**
71
72
  * Where the deployer is installed on the runner: outside the checkout, so
@@ -120,6 +121,17 @@ function getDeployerSetupSteps() {
120
121
  `;
121
122
  }
122
123
 
124
+ /**
125
+ * Job limits shared by both workflows (ISSUES.md #54).
126
+ *
127
+ * timeout-minutes: a Storybook run once took 21 minutes and nothing bounded it;
128
+ * GitHub's default is 6 hours. 20 still lets today's slowest run finish.
129
+ * permissions: actions: read lets the deployer read its own job's start time
130
+ * from the Actions API (whole-job CI time). A permissions block drops every
131
+ * scope it does not list, so contents: read (checkout) is listed too.
132
+ */
133
+ const JOB_TIMEOUT_MINUTES = 20;
134
+
123
135
  /** Deployer flags shared by both workflows. */
124
136
  const COMMON_FLAGS = ` \${{ vars.SCRY_COVERAGE_ENABLED == 'false' && '--no-coverage' || '' }} \\
125
137
  \${{ vars.SCRY_COVERAGE_FAIL_ON_THRESHOLD == 'true' && '--coverage-fail-on-threshold' || '' }} \\
@@ -148,6 +160,14 @@ on:
148
160
  jobs:
149
161
  deploy:
150
162
  runs-on: ubuntu-latest
163
+ timeout-minutes: ${JOB_TIMEOUT_MINUTES}
164
+
165
+ permissions:
166
+ contents: read
167
+ actions: read
168
+ # Listed because a permissions block drops every scope it does not name
169
+ # (GitHub Packages installs need it).
170
+ packages: read
151
171
 
152
172
  steps:
153
173
  - name: Checkout code
@@ -181,6 +201,13 @@ ${COMMON_FLAGS}
181
201
  STORYBOOK_DEPLOYER_API_KEY: \${{ secrets.SCRY_API_KEY }}
182
202
  # Optional: stories allowed to fail capture before the deploy ends red (default 0).
183
203
  SCRY_MAX_DROPPED: \${{ vars.SCRY_MAX_DROPPED }}
204
+ # Optional: stories rendered at once (default 4) and how long one may take to show something (ms, default 5000).
205
+ SCRY_CONCURRENCY: \${{ vars.SCRY_CONCURRENCY }}
206
+ SCRY_RENDER_TIMEOUT_MS: \${{ vars.SCRY_RENDER_TIMEOUT_MS }}
207
+ # Optional: story execution budget = base + per story × stories (default 120 s + 0.5 s); over it = a warning.
208
+ SCRY_EXECUTE_BUDGET_BASE_S: \${{ vars.SCRY_EXECUTE_BUDGET_BASE_S }}
209
+ SCRY_EXECUTE_BUDGET_PER_STORY_S: \${{ vars.SCRY_EXECUTE_BUDGET_PER_STORY_S }}
210
+ # Also lets the deployer read this job's start time (needs actions: read above).
184
211
  GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
185
212
  `;
186
213
  }
@@ -214,10 +241,12 @@ jobs:
214
241
  # Drafts are skipped; marking the PR ready for review deploys it.
215
242
  if: github.event.pull_request.draft == false
216
243
  runs-on: ubuntu-latest
244
+ timeout-minutes: ${JOB_TIMEOUT_MINUTES}
217
245
 
218
246
  permissions:
219
247
  contents: read
220
248
  pull-requests: write
249
+ actions: read
221
250
 
222
251
  steps:
223
252
  - name: Checkout code
@@ -260,6 +289,13 @@ ${COMMON_FLAGS}
260
289
  STORYBOOK_DEPLOYER_API_KEY: \${{ secrets.SCRY_API_KEY }}
261
290
  # Optional: stories allowed to fail capture before the deploy ends red (default 0).
262
291
  SCRY_MAX_DROPPED: \${{ vars.SCRY_MAX_DROPPED }}
292
+ # Optional: stories rendered at once (default 4) and how long one may take to show something (ms, default 5000).
293
+ SCRY_CONCURRENCY: \${{ vars.SCRY_CONCURRENCY }}
294
+ SCRY_RENDER_TIMEOUT_MS: \${{ vars.SCRY_RENDER_TIMEOUT_MS }}
295
+ # Optional: story execution budget = base + per story × stories (default 120 s + 0.5 s); over it = a warning.
296
+ SCRY_EXECUTE_BUDGET_BASE_S: \${{ vars.SCRY_EXECUTE_BUDGET_BASE_S }}
297
+ SCRY_EXECUTE_BUDGET_PER_STORY_S: \${{ vars.SCRY_EXECUTE_BUDGET_PER_STORY_S }}
298
+ # Also lets the deployer read this job's start time (needs actions: read above).
263
299
  GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
264
300
  `;
265
301
  }
@@ -268,4 +304,5 @@ module.exports = {
268
304
  generateMainWorkflow,
269
305
  generatePRWorkflow,
270
306
  DEPLOYER_RANGE,
307
+ JOB_TIMEOUT_MINUTES,
271
308
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scrymore/scry-deployer",
3
- "version": "0.8.0",
3
+ "version": "0.9.0-next.20260927093344",
4
4
  "description": "A CLI to automate the deployment of Storybook static builds.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -37,7 +37,7 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@octokit/rest": "^20.0.0",
40
- "@scrymore/scry-sbcov": "^0.6.0",
40
+ "@scrymore/scry-sbcov": "^0.7.0",
41
41
  "@sentry/node": "^10.33.0",
42
42
  "archiver": "^7.0.1",
43
43
  "axios": "^1.12.2",