@scrymore/scry-deployer 0.8.1-next.20260927082448 → 0.9.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.
- package/README.md +39 -0
- package/bin/cli.js +193 -3
- package/lib/apiClient.js +25 -8
- package/lib/ciTimings.js +350 -0
- package/lib/config.js +4 -1
- package/lib/coverage.js +104 -15
- package/lib/metadataArchive.js +7 -3
- package/lib/templates.js +39 -2
- package/package.json +2 -2
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
|
-
*
|
|
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 {
|
|
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 {
|
|
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;
|
package/lib/ciTimings.js
ADDED
|
@@ -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
|
|
338
|
+
const sbcovHelp = new Map();
|
|
303
339
|
|
|
304
340
|
/**
|
|
305
|
-
*
|
|
306
|
-
*
|
|
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 {
|
|
345
|
+
* @returns {string}
|
|
310
346
|
*/
|
|
311
|
-
function
|
|
312
|
-
if (
|
|
313
|
-
let
|
|
347
|
+
function sbcovHelpText(commandPrefix) {
|
|
348
|
+
if (sbcovHelp.has(commandPrefix)) return sbcovHelp.get(commandPrefix);
|
|
349
|
+
let out = '';
|
|
314
350
|
try {
|
|
315
|
-
|
|
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
|
|
323
|
-
// caller prints
|
|
324
|
-
|
|
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
|
-
|
|
327
|
-
return
|
|
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,
|
package/lib/metadataArchive.js
CHANGED
|
@@ -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.
|
|
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.
|
|
3
|
+
"version": "0.9.0",
|
|
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.
|
|
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",
|